一般服务的安全包括认证(Authentication)与授权(Authorization)两部分,认证即证明一个用户是合法的用户,比如通过用户名密码的形式,授权则是控制某个用户可以访问哪些资源。比较成熟的框架有Shiro、Spring Security,如果要实现第三方授权模式,则可采用OAuth2。但如果是一些简单的应用,比如一个只需要鉴别用户是否登录的APP,则可以简单地通过注解+拦截器的方式来实现。本文介绍了具体实现过程,虽基于Spring Boot实现,但稍作修改(主要是拦截器配置)就可以引入其它Spring MVC的项目。

1. 涉及的知识点

  1. 注解:用来标记某个接口是否需要登录
  2. 拦截器:拦截所有请求,判断请求的接口是否需要登录验证(基于是否标记了注解),如果需要,验证相应的信息(token),通过则放行,否则返回错误信息
  3. JWT: Json Web Token,一种流行的认证解决方案,它可以生成携带信息的token,但token一旦生成,其过期时间就不好更新,如果需要实现用户有操作就自动延长过期时间的场景,就相对比较麻烦。我们这里只用来生成token,过期通过redis实现
  4. RedisTemplate: 将token存在redis中,通过redis的过期机制来控制token的有效期
  5. ThreadLocal:可以将一次请求中多个环节需要访问的变量通过ThreadLocal来传递,比如userId

2. 依赖配置

在pom.xml中添加JWT与redis依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

在application.yml配置文件中添加redis相关配置属性

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
redis:
host: localhost
port: 6379
database: 0
password: 123654
timeout: 3000
jedis:
pool:
min-idle: 2
max-idle: 8
max-active: 8
max-wait: 1000

3. 定义注解

注解的定义你可以根据项目的具体场景,比如需要登录的接口比较多,就可以定义如 @SkipAuth 的注解来标记不需要登录的接口,反之,则可以定义如 @NeedAuth 的注解来标记需要登录的接口,总之就是让标记接口这个操作尽可能少。但也可以基于另一种考虑,万一需要登录的接口忘了加不就存在安全问题吗,所以用 @SkipAuth 相对要保险点。

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SkipAuth {
}

4. 定义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
35
36
37
38
39
@Component
public class RedisTokenManager {

@Autowired
private StringRedisTemplate redisTemplate;

/**
* 生成TOKEN
*/
public String createToken(String userId) {
//使用uuid作为源token
String token = Jwts.builder().setId(userId).setIssuedAt(new Date())
.signWith(SignatureAlgorithm.HS256, JwtConstant.JWT_SECRET).compact();
//存储到redis并设置过期时间
redisTemplate.boundValueOps(JwtConstant.AUTHORIZATION + ":" + userId)
.set(token, JwtConstant.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS);
return token;
}

public boolean checkToken(TokenModel model) {
if (model == null) {
return false;
}
String token = redisTemplate.boundValueOps(JwtConstant.AUTHORIZATION + ":"
+ model.getUserId()).get();
if (token == null || !token.equals(model.getToken())) {
return false;
}
//如果验证成功,说明此用户进行了一次有效操作,延长token的过期时间
redisTemplate.boundValueOps(model.getUserId())
.expire(JwtConstant.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS);
return true;
}

public void deleteToken(String userId) {
redisTemplate.delete(userId);
}

}

在登录接口通过时,调用 createToken 创建token,并保存到redis中,设置过期时间, 在调用未被 @SkipAuth 注解标记的接口时,调用 checkToken 来验证,并更新token的过期时间, 退出登录时,删除token。

5. 定义拦截器

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
@Component
@Slf4j
public class AuthInterceptor extends HandlerInterceptorAdapter {

@Autowired
private RedisTokenManager tokenManager;

public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
String requestPath = request.getRequestURI().substring(request.getContextPath().length());
// 如果不是映射到方法直接通过
if (!(handler instanceof HandlerMethod)) {
return true;
}

HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 如果方法注明了 SkipAuth,则不需要登录token验证
if (method.getAnnotation(SkipAuth.class) != null) {
return true;
}

// 从header中得到token
String authorization = request.getHeader(JwtConstant.AUTHORIZATION);
// 验证token
if(StringUtils.isBlank(authorization)){
WebUtil.outputJsonString(ApiResponse.failed("未提供有效Token!"), response);
return false;
}
try {
Claims claims = Jwts.parser().setSigningKey(JwtConstant.JWT_SECRET)
.parseClaimsJws(authorization).getBody();
String userId = claims.getId();
TokenModel model = new TokenModel(userId, authorization);
if (tokenManager.checkToken(model)) {
// 通过ThreadLocal设置下游需要访问的值
AuthUtil.setUserId(model.getUserId());
return true;
} else {
log.info("连接" + requestPath + "拒绝");
WebUtil.outputJsonString(ApiResponse.failed("未提供有效Token!"), response);
return false;
}
} catch (Exception e) {
log.error("连接" + requestPath + "发生错误:", e);
WebUtil.outputJsonString(ApiResponse.failed("校验Token发生异常!"), response);
return false;
}
}


@Override
public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
//结束后清除,否则由于线程池复用,导致ThreadLocal的值被其他用户获取
AuthUtil.clear();
}

}

拦截器通过对请求方法是否标记注解 @SkipAuth 来判断是否需要进行token验证,如果验证通过,则从JWT token中解析出userId,通过AuthUtil工具方法保存到ThreadLocal中,供下游访问。在请求处理结束调用 afterCompletion 方法中,要清除掉ThreadLocal中的值,否则由于线程池的复用,导致被其他用户获取。

然后,注册拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class WebConfiguration implements WebMvcConfigurer {

private AuthInterceptor authInterceptor;

@Autowired
public void setAuthInterceptor(AuthInterceptor authInterceptor){
this.authInterceptor = authInterceptor;
}
/**
* 注册鉴权拦截器
* @param
* @return
*/
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/error");
}
}

这里将 /error 这个接口排除了,因为如果接口处理过程中出现异常,则spring boot会自动跳转到 /error 接口,又会进入拦截器校验(因为/error接口没有标注 @SkipAuth 注解)。

6. 验证

通过以上几步,一个简单的接口认证功能就实现了,我们可以通过添加一个登录接口,两个测试接口(一个需要认证,一个不需要认证)来验证下。
登录接口

1
2
3
4
5
6
7
8
9
10
11
@SkipAuth
@RequestMapping("/login")
public ApiResponse login(@RequestBody Map<String, Object> params) {
String username = MapUtils.getString(params, "username");
String password = MapUtils.getString(params, "password");
if("ksxy".equals(username) && "jboost".equals(password)){
return ApiResponse.success(tokenManager.createToken(username));
} else {
return ApiResponse.failed("用户名或密码错误");
}
}

登录成功后,通过createToken方法创建了JWT token。
测试接口

1
2
3
4
5
6
7
8
9
10
@SkipAuth
@RequestMapping("/skip-auth")
public ApiResponse skipAuth() {
return ApiResponse.success("不需要认证的接口调用");
}

@RequestMapping("/need-auth")
public ApiResponse needAuth() {
return ApiResponse.success("username: " + AuthUtil.getUserId());
}

7. 总结

本文介绍了一个简单的接口认证方案,适用于不需要基于用户角色进行授权的场景。如果有较复杂的授权需求,则还是基于Shiro, Spring Security, OAuth2等框架来实现。这里也可以不用JWT,但是需要自己去做一些处理,比如将userId以某种形式包含在token中,解析时取出。
本文完整实例代码:https://github.com/ronwxy/springboot-demos/tree/master/springboot-simpleauth


微信公众号