Spring Boot+JWT+MongoDB实现权限控制

沈阿姨的课程项目,IST实验室五个研一的同学组队,实现一个基于微服务的工业流程管理平台。在sprint1中我负责平台的权限控制功能,这里记录一下思路和代码实现过程。

一、JWT

1.什么是JWT

JSON Web Token(JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。定义了一种简洁的、自包含的方法用于通信双方之间以JSON对象的形式安全地传递信息,数字签名的存在可以保证这些信息是可信的,没有经过伪造的

2.JWT请求与响应流程

(1)用户使用账号和密码,通过POST请求访问登录API接口,进行登录

(2)服务端登录验证成功,根据密钥生成一个JWT

(3)服务端将生成的JWT返回给浏览器

(4)浏览器下次像服务端发送请求,需要将JWT放在请求头中

(5)服务端检查请求头中的JWT,通过签名算法和密钥验证其合法性,并从中解码用户信息

(6)返回响应给浏览器

3.JWT的结构

JWT包含三部分:

  • Header:头部,包含token类型和加密算法
  • Payload:负载,存放有效信息
  • Signature:签名/签证,标识JWT的合法性

(1)Header头部是一个包含了两个部分的JSON对象:签名使用的算法(通常是HS256或者RSA)和token类型(JWT):

{
"alg": "HS256",
"typ": "JWT"
}

(2)Payload负载也是一个JSON对象,官方提供了一些不强制使用的字段:

  • iss (issuer): jwt签发者
  • sub (subject): 面向的用户(jwt所面向的用户)
  • aud (audience): 接收jwt的一方
  • exp (expiration time): 过期时间戳(jwt的过期时间,这个过期时间必须要大于签发时间)
  • nbf (not before): 定义在什么时间之前,该jwt都是不可用的
  • iat (issued at): jwt的签发时间
  • jti (jwt id): jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

当然除了以上之外也可以自定义字段,Payload示例:

{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

(3)Signature密钥是对前两部分的签名,标识JWT的合法性。首先要制定一个仅服务器可见的密钥(secret),然后使用Header里指定的签名算法(HS256)按照下面公式生成签名:

HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

算出签名后把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”.”分隔,就产生了最终的JWT,返回给用户。

二、项目权限控制功能的实现

先贴上项目的源代码地址:Sping-Boot-Mongodb-JWT

1.pom.xml文件配置依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.9.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.edu.sjtu</groupId>
<artifactId>industry_backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>industry_backend</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>

<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

2.Spring Boot配置文件application.properties

spring.data.mongodb.host = localhost
spring.data.mongodb.port = 27017
spring.data.mongodb.database = industry
server.port = 8080

项目要连接本地MongoDB,数据库名称为’industry’。

3.实体类User.java

@Builder
@Data
@Document(collection = "user")
public class User {

private String id;

private String userId; // 工号
private String username;
private String password;
private Integer role;
private String phone;
private String email;

@Tolerate
User() {}

}

实体类对应MongoDB数据库中的’user’表。用户使用工号userId和密码password登录,id属性是向MongoDB插入数据会自动生成的字段,在业务逻辑中一般不使用。

4.持久化数据层UserRepository.java

@Repository
public interface UserRepository extends MongoRepository<User, String> {
User findUserByUserId(final String userId);
}

只需要继承MongoRepository接口即可,太好用了叭!

5.JWT工具类JWTUtil.java

public class JWTUtil {

public static String encode(User user) {
Algorithm algorithm = Algorithm.HMAC256(JWTConstant.SECRET_KEY);
return JWT.create()
.withExpiresAt(new Date(System.currentTimeMillis() + JWTConstant.VALIDITY_PERIOD))
.withAudience(user.getUserId())
.withClaim("role", user.getRole())
.sign(algorithm);
}

public static String decodeUserId(String token) {
return JWT.decode(token).getAudience().get(0);
}

public static Integer decodeRole(String token) {
return JWT.decode(token).getClaim("role").asInt();
}

public static void verify(String token) {
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(JWTConstant.SECRET_KEY)).build();
try {
jwtVerifier.verify(token);
} catch (JWTVerificationException e) {
throw new AuthorityException(RespStatus.INVALID_TOKEN);
}
}

}

JWT生成、验证、解码的逻辑写在了一个工具类中。生成JWT时,将用户的userId和角色role塞了进去,方便对后面请求进行权限验证,不需要再访问数据库查用户权限了。

6.业务逻辑层UserService.java

@Service
public class UserService {
@Autowired
private UserRepository userRepository;

public String signIn(String userId, String password) {
User user = findUserById(userId);
if(!user.getPassword().equals(password)) {
throw new ServiceException(RespStatus.WRONG_PASSWORD, this.getClass().getName());
}
else {
String token = JWTUtil.encode(user);
return token;
}
}

public void signUp(User user) {
if(userRepository.findUserByUserId(user.getUserId()) != null) {
throw new ServiceException(RespStatus.EXIST_ACCOUNT, this.getClass().getName());
}
userRepository.save(user);
}

public User findUserById(String userId) {
User user = userRepository.findUserByUserId(userId);
if(user == null) {
throw new ServiceException(RespStatus.NON_ACCOUNT, this.getClass().getName());
}
return user;
}
}

登录注册的业务逻辑实现。如果登录成功,会生成一个JWT返回给用户。

7.定义用户角色Role.java

public enum Role {
MAINTAINER,
MANAGER,
PROCESSOR,
OPERATOR
}

系统中一共有四种角色,定义一个枚举类。

8.为每种角色定义权限注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MaintainerToken {
boolean required() default true;
}

每种角色都对应一个权限注解,这些注解标注在Controller中的handler上,表示该handler有哪些角色具有请求权限。以上是Maintainer的权限注解@MaintainerToken,另外三个同上分别是@ManagerToken、@ProcessorToken、@OperatorToken。除此之外,还有第五个@PassToken,这个是通用注解,它标识的handler任何请求都可以访问,不需要权限验证。

9.控制器UserController.java

@RestController
public class UserController {
@Autowired
private UserService userService;
@Autowired
private Mapper mapper;

@PassToken
@RequestMapping(value = "/user/signIn", method = RequestMethod.POST)
public Result signIn(@RequestBody SignInDTO signInDTO) {

String userId = signInDTO.getUserId();
String password = signInDTO.getPassword();
String token = userService.signIn(userId, password);
TokenDTO tokenDTO = TokenDTO.builder().token(token).build();

return ResultUtil.success(tokenDTO);
}

@PassToken
@RequestMapping(value = "/user/signUp", method = RequestMethod.POST)
public Result signUp(@RequestBody UserDTO userDTO) {
User user = mapper.map(userDTO, User.class);
userService.signUp(user);
return ResultUtil.success();
}


/*测试,只有运维人员和管理人员有权限访问的服务*/
@MaintainerToken
@ManagerToken
@RequestMapping(value = "/user/test", method = RequestMethod.GET)
public Result test(@RequestHeader("token") String token) {
String userId = JWTUtil.decodeUserId(token);
User user = userService.findUserById(userId);
UserDTO userDTO = mapper.map(user, UserDTO.class);
return ResultUtil.success(userDTO);
}

}

如图,每个handler哪些角色有权限访问,全部通过自定义的权限注解来实现。比如登录和注册handler有@PassToken注解,说明任何请求都可以访问它,不需要权限验证。测试handler有注解@MaintainerToken和@ManagerToken,说明只有Maintainer和Manager有权限访问它。

10.定义一些DTO类

可以看到控制器中的传输参数用的是DTO类,里面封装了和客户端进行传输的数据类型。使用Dozer进行实体类和DTO类之间的映射,从而方便地进行实体类和DTO类的相互转换。

11.拦截器JWTInterceptor.java

public class JWTInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

if (!(handler instanceof HandlerMethod)) {
return true;
}

HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();

// 异常发生后ErrorController的/error
if(method.getName().equals("error")) {
return true;
}
// 有免验证令牌的handler
if(method.isAnnotationPresent(PassToken.class)) {
return true;
}
else {
// 检查token是否存在
String token = request.getHeader("token");
if (token == null) {
throw new AuthorityException(RespStatus.NON_TOKEN);
}
// 验证token是否合法
JWTUtil.verify(token);

// 检查用户权限
Integer role = JWTUtil.decodeRole(token);
if(role.equals(Role.MAINTAINER.ordinal())) {
if(method.isAnnotationPresent(MaintainerToken.class)) {
return true;
}
throw new AuthorityException(RespStatus.NON_AUTHORITY);
}
if(role.equals(Role.MANAGER.ordinal())) {
if(method.isAnnotationPresent(ManagerToken.class)) {
return true;
}
throw new AuthorityException(RespStatus.NON_AUTHORITY);
}
if(role.equals(Role.PROCESSOR.ordinal())) {
if(method.isAnnotationPresent(ProcessorToken.class)) {
return true;
}
throw new AuthorityException(RespStatus.NON_AUTHORITY);
}
if(role.equals(Role.OPERATOR.ordinal())) {
if(method.isAnnotationPresent(OperatorToken.class)) {
return true;
}
throw new AuthorityException(RespStatus.NON_AUTHORITY);
}
else {
throw new AuthorityException(RespStatus.INVALID_TOKEN);
}

}
}

@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {

}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception e) throws Exception {
}
}

这个拦截器是实现权限控制的核心模块。客户端过来的所有请求,会先被拦截器拦截下来,从这个请求头中拿出JWT,验证其合法性,并解码JWT中的用户权限字段role,然后比照role和所请求的handler的权限注解,如果符合权限要求,就释放这个请求到Controller层去处理,否则抛异常拦截掉这个请求。

12.响应实体类Result.java

@Data
public class Result<T> {
/** 状态码 */
private String status;

/** 错误信息 */
private String message;

/** 返回数据 */
private T data;
}

为了保证响应给前端的数据格式都具有统一的标准,定义响应实体类,由状态码、错误信息、数据三部分组成。

13.状态码枚举类RespStatus.java

@Getter(AccessLevel.PUBLIC)
public enum RespStatus {
SUCCESS("200", "请求成功"),

NON_AUTHORITY("300", "不具备请求权限"),
INVALID_TOKEN("301", "令牌不合法"),
NON_TOKEN("302", "令牌不存在"),
WRONG_PASSWORD("303", "密码错误"),
NON_ACCOUNT("304", "账户不存在"),
EXIST_ACCOUNT("305", "账号已存在"),

SERVER_ERROR("400", "服务器错误"),

UNKNOWN_ERROR("500", "未知错误");

private String code;
private String message;

RespStatus(String code, String message) {
this.code = code;
this.message = message;
}
}

针对响应实体类Result中的属性status和message,枚举类RespStatus定义了规范,根据响应状态,每个响应体对象都会与某个RespStatus对象绑定。

14.响应工具类ResultUtil.java

public class ResultUtil {

public static Result success(Object data) {
Result result = new Result();
result.setStatus(RespStatus.SUCCESS.getCode());
result.setMessage(RespStatus.SUCCESS.getMessage());
result.setData(data);
return result;
}

public static Result success() {
return success(null);
}

public static Result error(RespStatus status) {
Result result = new Result();
result.setStatus(status.getCode());
result.setMessage(status.getMessage());
return result;
}
}

因为对于不同的响应情况,响应实体的构建是不一样的,为了避免在响应实体类Result中实现繁复的构造方法,实现一个响应工具类,根据具体情况快速构造响应体。可以看到,对于每个响应体对象的构建方法,某个RespStatus对象都会作为必要的输入参数。

15.自定义异常类型

自定义了两个异常类型:AuthorityException&&ServiceException

(1)首先看AuthorityException.java:

@Getter(AccessLevel.PUBLIC)
@Setter(AccessLevel.PUBLIC)
public class AuthorityException extends RuntimeException {

private RespStatus status;

public AuthorityException(RespStatus status) {
super();
this.status = status;
}
}

注入了枚举对象RespStatus,说明每个AuthorityException对象都与一个特定的状态码枚举对象RespStatus绑定,标识这个异常发生后对应的响应状态。

AuthorityException全部在权限拦截器JWTInterceptor中抛出。

(2)再看ServiceException:

@Getter(AccessLevel.PUBLIC)
@Setter(AccessLevel.PUBLIC)
public class ServiceException extends RuntimeException {

private String service; // 发生异常的Service
private RespStatus status; // 异常状态码

public ServiceException(RespStatus status, String service) {
super();
this.status = status;
this.service = service;
}
}

与AuthorityException相同,也注入了枚举对象RespStatus。除此之外,还注入了一个String类型的service,用来标识该异常是在哪个Service中抛出的。

ServiceException全部在Service层抛出。

16.全局异常处理类GlobalExceptionHandler.java

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

@ExceptionHandler(MongoException.class)
public Result handleMongoException(final MongoException exception) {
log.warn("Processing MongoException: {}", exception.getMessage());
return ResultUtil.error(RespStatus.SERVER_ERROR);
}

@ExceptionHandler(AuthorityException.class)
public Result handleAuthorityException(final AuthorityException exception) {
log.warn("Processing AuthorityException: {}", exception.getStatus().getMessage());
return ResultUtil.error(exception.getStatus());
}

@ExceptionHandler(ServiceException.class)
public Result handleServiceException(final ServiceException exception) {
log.warn("Processing ServiceException at " + exception.getService() + ": {}", exception.getStatus().getMessage());
return ResultUtil.error(exception.getStatus());
}
}

为了避免在Controller层定义一堆的try……catch块来捕捉Service层抛出的异常,将异常处理和业务逻辑过程解耦,定义一个全局异常处理类。它不仅能捕获Service层抛出的异常,也能捕获权限拦截器JWTInterceptor抛出的异常,并对捕获的异常进行处理。

一个坑:注意@ResponseBody这个注解一定要存在,表示每个handler的返回值是直接写到响应体中的,然后返回给客户端。如果没有这个注解,任何异常发生后,spring boot会自发一个’/error’的请求,映射到默认错误处理类ErrorController的’/error’对应的handler,这个默认方法会进行处理然后返回给客户端一个原生的响应体,覆盖掉我们自己在GlobalExceptionHandler里自定义构建的响应体。这个原生的响应体如下:

{
"timestamp": "2019-10-30T07:53:11.228+0000",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/user/test"
}

因此一定要使用@ResponseBody注解,将异常发生后的返回值直接写入响应体返回给客户端,spring boot就来不及自发这个’/error’请求了。

三、效果演示

前后端是RESTful的交互方式,因此使用Postman工具进行效果演示。

首先,注册一个用户:

检查MongoDB的情况:

注册成功,现在用这个账户进行登录:

登录成功后端返回了一串token,下次请求将这个token放在请求头中,注意/user/test接口的请求方法是GET:

可以看到,用户的角色role为3,对应Role.OPERATOR,而/user/test接口上的注解是@MaintainerToken和@ManagerToken,说明只有Maintainer和Manager具有访问权限,而该用户权限不够。

重新注册一个role为1,对应Role.MANAGER的用户,登录后,请求/user/test接口:

可以看到,这个用户具有访问接口/user/test的权限,请求成功。

把token的第一个字符’e’删掉,再发送请求,后端返回令牌不合法的错误响应:

不带token,再发送请求,后端返回令牌不存在的错误响应:

文章作者: Moon Lou
文章链接: https://loumoon.github.io/2019/10/22/Spring+Boot+JWT+MongoDB实现权限控制/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Moon's Blog