沈阿姨的课程项目,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 /> </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(); if (method.getName().equals("error" )) { return true ; } if (method.isAnnotationPresent(PassToken.class)) { return true ; } else { String token = request.getHeader("token" ); if (token == null ) { throw new AuthorityException(RespStatus.NON_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; 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,再发送请求,后端返回令牌不存在的错误响应: