Featured image of post Springboot手搓登录权限验证

Springboot手搓登录权限验证

使用拦截器和解析器实现身份权限控制

在22年的时候,当时我写了一篇基于Shiro的身份权限控制方法,然而随着Springboot和Shiro的更新。当我把Springboot升级到3.x版本、Shiro升级到2.x后,发现原来的方法都没有用了。无论我怎么尝试,都无法实现登录的拦截效果。在折腾了许久后,我决定自己手搓安全框架。毕竟当时用Shiro主要是为了找工作,现在做项目只要实现效果就好了

基本思路

我们还是沿用之前文章的设计,使用JWT来作为用户的Token。我们想要实现用户访问到特定的接口,就需要登录,否则进行拦截。对于指定了权限的接口,还需要验证用户是否有权限。

和Shiro的设计一样,我们通过自定义注解来判断接口是否需要验证:@RequiresLogin表示需要登录、@RequiresRoles表示需要指定的身份。用户通过登录接口登录后,会获得一个Token,其中包含用户的信息和身份。在之后的请求中,用户需要在Header中附带这个Token。

具体实现

导入依赖

由于我们是自己手搓,所以没有其他的依赖,只需要导入JWT相关的包即可了。

1
2
3
4
5
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>

封装JWT工具类

这里主要实现JWT的生成、验证、提取JWT封装的信息三个功能

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@Slf4j
public class JwtUtil {
    /**
     * 服务器私钥
     */
    private static final Algorithm ALGORITHM = Algorithm.HMAC256("1");

    /**
     * 生成JSON Web Token
     *
     * @param username  用户名
     * @param issuer    签发者
     * @param subject   面向主体
     * @param roles     用户权限
     * @param ttlMillis 生效时长(单位:毫秒)
     */
    public static String creatJwt(String username, String issuer, String subject, List<String> roles, long ttlMillis) {
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        JWTCreator.Builder builder = JWT.create()
                .withAudience(username)
                .withIssuedAt(now)
                .withSubject(subject)
                .withClaim("roles", roles)
                .withIssuer(issuer);

        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.withExpiresAt(exp);
        }

        return builder.sign(ALGORITHM);
    }

    /**
     * 获取签发对象
     *
     * @param token 需要解密的token
     * @return 解密后获得的对象,失败返回null
     */
    public static String getAudience(String token) {
        try {
            return JWT.decode(token).getAudience().get(0);
        } catch (JWTDecodeException exception) {
            log.error("输入的token无法解析");
            return null;
        } catch (NullPointerException exception) {
            log.error("无法读取签发对象");
            return null;
        }
    }

    /**
     * 获取面向主体
     *
     * @param token 需要解密的token
     * @return 解密后获得的对象,失败返回null
     */
    public static String getSubject(String token) {
        try {
            return JWT.decode(token).getSubject();
        } catch (JWTDecodeException exception) {
            log.error("输入的token无法解析");
            return null;
        } catch (NullPointerException exception) {
            log.error("无法读取面向主体");
            return null;
        }
    }

    /**
     * 获取用户权限
     *
     * @param token 需要解密的token
     * @return 解密后获得的对象,失败返回null
     */
    public static List<String> getRoles(String token) {
        try {
            return JWT.decode(token).getClaim("roles").as(List.class);
        } catch (JWTDecodeException exception) {
            log.error("输入的token无法解析");
            return null;
        } catch (NullPointerException exception) {
            log.error("无法读取用户权限");
            return null;
        }
    }

    /**
     * 验证token是否正确
     *
     * @param token 需要验证的token
     * @return 通过验证返回true,反之抛出异常
     */
    public static Boolean verifyToken(String token) throws JWTVerificationException {
        JWTVerifier verifier = JWT.require(ALGORITHM).build();
        verifier.verify(token);
        return true;
    }
}

自定义注解

通过自定义注解,我们可以细粒度的进行权限控制。这里我们需要进行登录认证和身份认证(由于项目并不大,就略去了权限认证),我们定义了两个注解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import java.lang.annotation.*;

/**
 * 需要登录权限
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequiresLogin {
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import top.lbqaq.questioncollect.pojo.RoleEnum;

import java.lang.annotation.*;

/**
 * 需要身份权限
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequiresRoles {
    //所需的权限名称
    RoleEnum[] value();

    Logical logical() default Logical.AND;
}

注解类上有三个注解:

@Target 用它来指明自定义注解的使用范围,ElementType.TYPE代表可以将该注解使用在类、接口或枚举上,ElementType.METHOD 代表可以应用在类的方法上。

@Retention 用它来指明该注解在.java变.class文件过程中会被保留到那个阶段。RetentionPolicy.RUNTIME 这种类型的注解将被JVM保留,所以他们能在运行时被JVM或其他使用反射机制的代码所读取和使用。

@Documented 注解表明这个注解应该被 javadoc工具记录。

特别的,在第二个注解上,我们引入了RoleEnumLogical这两个自定义的类。对于第一个,这是一个权限的枚举,方便之后指定接口所需权限。第二个则是标明所需的权限逻辑操作,主要用于身份判断处,具体代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public enum Logical {
    /**
     * AND
     */
    AND,
    /**
     * OR
     */
    OR
}

自定义拦截器

我们在interceptor下定义我们自己的拦截器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Component
public class AuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            final HandlerMethod handlerMethod = (HandlerMethod) handler;
            final Class<?> clazz = handlerMethod.getBeanType();
            final Method method = handlerMethod.getMethod();
            final boolean requiresLogin = clazz.isAnnotationPresent(RequiresLogin.class) || method.isAnnotationPresent(RequiresLogin.class);
            final boolean requiresRoles = clazz.isAnnotationPresent(RequiresRoles.class) || method.isAnnotationPresent(RequiresRoles.class);
            // 判断注解是标记在所调用的方法上还是在其类上
            if (requiresLogin || requiresRoles) {
                // 登录鉴权的主要业务逻辑解释是当发现用户没有做登陆的时候,立即抛出一个自定义的业务异常BusinessException,如果登录则return true继续执行后续代码。
                // 直接获取登录用户(防止请求转发时,第二次查询)
                LoginUser loginUser = LoginTokenHelper.getLoginUserFromRequest();
                if (loginUser != null) {
                    return true;
                }

                //获取登录TOKEN
                String token = LoginTokenHelper.getLoginToken();
                if (token == null) {
                    throw new AuthException("用户未登录");
                }

                //验证Token
                try {
                    JwtUtil.verifyToken(token);
                } catch (JWTDecodeException e) {
                    throw new AuthException("不是有效的JWT格式");
                } catch (SignatureVerificationException e) {
                    throw new AuthException("无效的签名");
                } catch (TokenExpiredException e) {
                    throw new AuthException("token已过期");
                }

                //获取Token里的信息
                String userId = JwtUtil.getAudience(token);
                if (userId == null) {
                    throw new AuthException("token中缺少UserId");
                }
                List<String> userRoles = JwtUtil.getRoles(token);
                if (userRoles == null) {
                    throw new AuthException("token中缺少身份");
                }
                loginUser = new LoginUser();
                loginUser.setUid(userId);
                loginUser.setRoles(userRoles);
                String aud = JwtUtil.getSubject(token);
                if ("test".equals(aud) && !"dev".equals(RequestContextUtil.getActiveProfile())) {
                    throw new AuthException("禁止使用测试Token");
                }

                //身份认证
                if (requiresRoles) {
                    List<String> roles = null;
                    Logical logical = null;
                    if (clazz.isAnnotationPresent(RequiresRoles.class)) {
                        roles = RoleEnum.getValuesAsList(clazz.getAnnotation(RequiresRoles.class).value());
                        logical = clazz.getAnnotation(RequiresRoles.class).logical();
                    } else {
                        roles = RoleEnum.getValuesAsList(method.getAnnotation(RequiresRoles.class).value());
                        logical = method.getAnnotation(RequiresRoles.class).logical();
                    }
                    if ((logical.equals(Logical.AND) && !userRoles.containsAll(roles))
                            || (logical.equals(Logical.OR) && userRoles.stream().noneMatch(roles::contains))
                    ) {
                        throw new AuthException("用户无权查看");
                    }
                }
                //登录TOKEN信息放入请求对象,方便后续controller中获取
                LoginTokenHelper.addLoginUserToRequest(loginUser);
                return true;
            }
        }
        return true;
    }
}

这里有几个要点,首先就是利用requiresLoginrequiresRoles来判断是否需要进行拦截。其次我们用了LoginTokenHelper来简化登录验证。我们使用了JwtUtil.getRoles(token)来获取用户的身份,这里是不想每次用户执行操作时都去数据库里进行查询,于是就在用户登录的时候就把roles一起打包到Token里。身份认证就是去遍历接口定义的身份和用户的身份是否匹配。

这里我们自定义了用户类LoginUser,方便之后在接口中使用

1
2
3
4
5
6
@Data
@Hidden
public class LoginUser {
    private String uid;
    private List<String> roles;
}

@Hidden确保其不会生成在文档中,因为这是后端使用的变量,前端无需知道。

登录辅助类

这里主要处理的就是从请求里提取用户的Token,此外将已经解析好的用户信息再存放到请求中去

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class LoginTokenHelper {

    private final static String LOGIN_TOKEN_COOKIE_NAME = "Token";

    private final static String LOGIN_TOKEN_KEY = "LOGIN-TOKEN";

    /**
     * 获取登录的TOKEN
     */
    public static String getLoginToken() {
        HttpServletRequest request = RequestContextUtil.getRequest();
        String token = request.getHeader(LOGIN_TOKEN_COOKIE_NAME);
        return token;
    }

    /**
     * 将登录用户信息放入请求对象
     */
    public static void addLoginUserToRequest(LoginUser loginUser) {
        RequestContextUtil.getRequest().setAttribute(LOGIN_TOKEN_KEY, loginUser);
    }

    /**
     * 获取登录用户信息从请求对象
     */
    public static LoginUser getLoginUserFromRequest() {
        Object loginTokenO = RequestContextUtil.getRequest().getAttribute(LOGIN_TOKEN_KEY);
        if (loginTokenO == null) {
            return null;
        }

        return (LoginUser) loginTokenO;
    }
}

这段代码用到了RequestContextUtil,这段代码就是从网上copy的。我在原始的基础上就加了个判断当前激活的profile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class RequestContextUtil {
    private static ApplicationContext applicationContext;

    public static HttpServletRequest getRequest() {
        return getRequestAttributes().getRequest();
    }

    public static HttpServletResponse getResponse() {
        return getRequestAttributes().getResponse();
    }

    public static HttpSession getSession() {
        return getRequest().getSession();
    }

    public static ServletRequestAttributes getRequestAttributes() {
        return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
    }

    public static ServletContext getServletContext() {
        return ContextLoader.getCurrentWebApplicationContext().getServletContext();
    }

    public static void setApplicationContext(ApplicationContext context) {
        RequestContextUtil.applicationContext = context;
    }

    public static String getActiveProfile() {
        return applicationContext.getEnvironment().getActiveProfiles()[0];
    }
}

配置拦截器

WebConfig.java里重写方法,将我们定义的拦截器注入即可。

1
2
3
4
5
6
7
8
@Resource
AuthInterceptor authInterceptor;

@Override
//添加拦截器
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authInterceptor);
}

到目前位置,用户的非登录请求就已经可以拦截下来了,但如果想要在接口中获取到当前的用户信息该如何操作呢?这就需要通过自定义解析器将参数注入进去。

自定义参数解析器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        final Method method = parameter.getMethod();
        final Class<?> clazz = parameter.getMethod().getDeclaringClass();
        boolean isHasLoginAuthAnn = clazz.isAnnotationPresent(RequiresLogin.class) || method.isAnnotationPresent(RequiresLogin.class) ||
                clazz.isAnnotationPresent(RequiresRoles.class) || method.isAnnotationPresent(RequiresRoles.class);
        boolean isHasLoginUserParameter = parameter.getParameterType().isAssignableFrom(LoginUser.class);
        return isHasLoginAuthAnn && isHasLoginUserParameter;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return LoginTokenHelper.getLoginUserFromRequest();
    }
}

supportsParameter主要是判断是否需要注入登录用户,resolveArgument则是将我们之前存放好的LoginUser返回。

注入参数解析器

和之前的操作一样。

1
2
3
4
5
6
7
8
@Resource
LoginUserArgumentResolver loginUserArgumentResolver;

@Override
//添加解析器
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
    resolvers.add(loginUserArgumentResolver);
}

使用

对于需要身份认证的接口,只需要加上@RequiresLogin或者@RequiresRoles(value = {RoleEnum.ADMIN})即可。想要获得当前的用户,直接写在参数里就好了,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@RequiresLogin
@PostMapping("/updateUserInfo")
@Operation(summary = "更新用户基本信息", security = @SecurityRequirement(name = "Token"))
public Result updateUserInfo(@RequestBody UserDTO userDTO, LoginUser loginUser) {
    if(userDTO.getUid()==null){
        return new Result().setCode(1200).setMessage("用户id不能为空");
    }
    if (!userDTO.getUid().equals(loginUser.getUid())) {
        return new Result().setCode(1200).setMessage("不能修改其他用户信息");
    }
    User user = new User();
    user.setUid(userDTO.getUid());
    user.setName(userDTO.getName());
    user.setPhone(userDTO.getPhone());
    user.setClassName(userDTO.getClassName());
    if(userService.updateByPrimaryKeySelective(user)==0){
        return new Result().setCode(1200).setMessage("更新失败");
    }
    return new Result().setCode(200).setMessage("成功");
}

这里我用了@SecurityRequirement,主要是为了让Swagger调试姛时候可以输入Token。Swagger配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Configuration
public class SwaggerConfig {
    @Bean
    public OpenAPI getOpenAPI() {
        return new OpenAPI()
                .info(new Info().title("Question Collect API")
                                .description("问卷收集系统")
                                .version("1.0.0")
                )
                .components(new Components()
                        .addSecuritySchemes("Token",
                                new SecurityScheme().type(SecurityScheme.Type.APIKEY).in(SecurityScheme.In.HEADER).name("Token"))
                );
    }
}

参考文献