--- title: SpringBoot自定义注解实现权限控制 date: 2023-07-29T15:20:02.0000000 tags: - 技术笔记 - Java --- 如题。 ## 需求分析 最近在用`SpringBoot`写一个数据结构的大作业,实现一个日程管理系统。在系统需要有不同用户的权限管理功能。但是`SpringBoot`框架中使用的`Spring Security`权限控制框架有点过于“重”了,对于我们这种小项目来说不太适用。 于是我们打算利用`Spring`中比较强大的注解功能自行设计实现一套权限控制功能。这个功能需要实现一下这些功能: - 基于`Json Web Token`的登录状态维持。 - 不同的用户具有不同的权限 - 使用注解注明每个接口设置的权限类型 - 在控制器中可以获得当前请求用户的身份信息 ## 具体实现 ### 权限等级和策略等级 首先明确一下我们需要支持的权限等级,通过枚举的方式表现: ```java public enum UserPermission { USER(0, "user"), ADMIN(1, "administrator"), SUPER(2, "superman"); private final int code; private final String name; UserPermission(int code, String name) { this.code = code; this.name = name; } public int getCode() { return code; } public String getName() { return name; } } ``` 这里我们使用了经典的三层等级制度。 然后明确一下我们对于各个接口的权限要求,这里也采用枚举的方式给出: ```java public enum AuthorizePolicy { /** * 只要登录就可以访问 */ ONLY_LOGIN("onlyLogin"), /** * 用户在当前请求的组织中 */ CURRENT_GROUP_USER("currentGroupUser"), /** * 用户在当前请求的组织中 且权限在管理员之上 */ CURRENT_GROUP_ADMINISTRATOR("currentGroupAdministrator"), /** * 用户在当前请求的组织中 且权限在超级管理员之上 */ CURRENT_GROUP_SUPERMAN("currentGroupSuperman"), /** * 当前用户可以访问(URL终结点包含用户ID) */ CURRENT_USER("currentUser"), /** * 用户权限超过普通管理员 */ ABOVE_ADMINISTRATOR("aboveAdministrator"), /** * 用户权限超过超级管理员 */ ABOVE_SUPERMAN("aboveSuperman"); private final String implementName; AuthorizePolicy(String implementName) { this.implementName = implementName; } public String getImplementName() { return this.implementName; } } ``` 这里`current`开头相关策略有点粪的一点是,为了确保每个用户只能访问自己相关的数据,我们会从请求`url`中读取当前请求对象的ID。因为我们采用了`RESTful API`的设计思想,因此形如`/user/1`之类的请求就表示对于ID等于1的用户进行请求。但是这样设计就存在两个问题: - 从编程的角度出发,`current`相关策略就只能使用在`url`最后为对象ID的接口上,但是这是一个**口头约定**,实际代码中没有任何限制,因此当错误使用这类策略时就会引起不必要的运行时错误。 - 从应用的角度出发,从`url`中获得数据也显得有一点奇怪。 ### 策略实现服务 为了实现不同策略服务,我们设计了一个认证接口: ```java package top.rrricardo.postcalendarbackend.services; import top.rrricardo.postcalendarbackend.dtos.UserDTO; import top.rrricardo.postcalendarbackend.exceptions.NoIdInPathException; public interface AuthorizeService { /** * 验证用户的权限 * @param user 用户DTO模型 * @param requestUri 请求的URI * @return 是否通过拥有权限 */ boolean authorize(UserDTO user, String requestUri) throws NoIdInPathException; } ``` 其中输入的两个参数分别是在令牌中解析出来的用户信息(通过`json`的形式存储在`JWT`令牌中)和当前请求的`url`。 然后针对每个策略实现一个认证服务: ![image-20230727175807814](spring-boot-custom-authorize/image-20230727175807814.webp) > 具体实现就不在这里给出。 在注入每个服务时,使用策略枚举作为服务的名称,方便后续获得该服务实例。 ![image-20230727175955817](spring-boot-custom-authorize/image-20230727175955817.webp) ### 注解和注解解析 首先实现一个**运行时**,**注解在方法上**的注解: ```java import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented @Inherited public @interface Authorize { AuthorizePolicy policy() default AuthorizePolicy.ONLY_LOGIN; } ``` 然后实现一个注解处理器: ```java package top.rrricardo.postcalendarbackend.components; import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import top.rrricardo.postcalendarbackend.annotations.Authorize; import top.rrricardo.postcalendarbackend.dtos.ResponseDTO; import top.rrricardo.postcalendarbackend.dtos.UserDTO; import top.rrricardo.postcalendarbackend.exceptions.NoIdInPathException; import top.rrricardo.postcalendarbackend.services.JwtService; @Component public class AuthorizeInterceptor implements HandlerInterceptor { private final JwtService jwtService; private final AuthorizeServiceFactory authorizeServiceFactory; private static final ThreadLocal local = new ThreadLocal<>(); public AuthorizeInterceptor(JwtService jwtService, AuthorizeServiceFactory authorizeServiceFactory) { this.jwtService = jwtService; this.authorizeServiceFactory = authorizeServiceFactory; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } var method = handlerMethod.getMethod(); var authorize = method.getAnnotation(Authorize.class); if (authorize == null) { // 没有使用注解 // 说明不需要身份验证 return true; } // 验证是否携带令牌 var tokenHeader = request.getHeader(jwtService.header); if (tokenHeader == null || !tokenHeader.startsWith(jwtService.tokenPrefix)) { var responseDTO = new ResponseDTO("No token provided."); response.setStatus(401); response.getWriter().println(responseDTO); return false; } try { var claims = jwtService.parseJwtToken(tokenHeader); var userDTO = new UserDTO( claims.get("userId", Integer.class), claims.getIssuer(), claims.get("emailAddress", String.class) ); var authService = authorizeServiceFactory.getAuthorizeService(authorize.policy()); if (authService.authorize(userDTO, request.getRequestURI())) { local.set(userDTO); return true; } else { var responseDTO = new ResponseDTO("No permission"); response.setStatus(403); response.getWriter().println(responseDTO); return false; } } catch (JwtException e) { // 解析令牌失败 var responseDTO = new ResponseDTO(e.getMessage()); response.setStatus(401); response.getWriter().println(responseDTO); return false; } catch (NoIdInPathException e) { // 在请求路径中没有获取到用户ID var responseDTO = new ResponseDTO("Internal server error, please contact administrator"); response.setStatus(500); response.getWriter().println(responseDTO); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { local.remove(); } public static UserDTO getUserDTO() { return local.get(); } } ``` 其中重要的部分是声明了一个`ThreadLocal`的变量,当前的处理线程可以通过这个变量获得当前发起请求的用户信息。 在上述实现中还涉及到使用`AuthorizeServiceFactory`的部分,这是因为在配置注解处理器时不能使用依赖注入,需要手动创建对象: ```java import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import top.rrricardo.postcalendarbackend.components.AuthorizeInterceptor; import top.rrricardo.postcalendarbackend.components.AuthorizeServiceFactory; import top.rrricardo.postcalendarbackend.services.JwtService; @Configuration public class WebMvcConfiguration implements WebMvcConfigurer { private final JwtService jwtService; private final AuthorizeServiceFactory authorizeServiceFactory; public WebMvcConfiguration(JwtService jwtService, AuthorizeServiceFactory authorizeServiceFactory) { this.jwtService = jwtService; this.authorizeServiceFactory = authorizeServiceFactory; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthorizeInterceptor(jwtService, authorizeServiceFactory)); } } ``` 我们就实现了一个`AuthorizeServiceFactory`,在解决依赖注入问题的同时封装了一部分的逻辑: ```java import org.springframework.stereotype.Component; import top.rrricardo.postcalendarbackend.enums.AuthorizePolicy; import top.rrricardo.postcalendarbackend.services.AuthorizeService; import java.util.Map; @Component public class AuthorizeServiceFactory { Map authorizeServiceMap; public AuthorizeServiceFactory(Map authorizeServiceMap) { this.authorizeServiceMap = authorizeServiceMap; } public AuthorizeService getAuthorizeService(AuthorizePolicy policy) { return authorizeServiceMap.get(policy.getImplementName()); } } ``` ## 实际引用 我们在项目[PostCalendarBackend](https://github.com/post-guard/PostCalendarBackend)中实际使用的这个技术,相关代码在可以在Github上获取。