在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工具记录。
特别的,在第二个注解上,我们引入了RoleEnum
和Logical
这两个自定义的类。对于第一个,这是一个权限的枚举,方便之后指定接口所需权限。第二个则是标明所需的权限逻辑操作,主要用于身份判断处,具体代码如下:
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;
}
}
|
这里有几个要点,首先就是利用requiresLogin
和requiresRoles
来判断是否需要进行拦截。其次我们用了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"))
);
}
}
|
参考文献