微服务: Token的处理

简介

Win10-安装-Redis微服务-SpringBoot-集成-Redis 分别介绍了如何安装和使用 Redis,今天继续结合 Redis,聊聊 token 授权登录的事情。

今天聊的主角是 JWT,聊完 JWT 之后再结合实例实现用户 token 登录。

JWT 介绍

JWT,JSON Web Token 的缩写,基于 RFC 7519 标准。

下面内容来自 jwd.io,如下:

1
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

JWT 定义了一种紧凑的、自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。该信息可以被验证和信任(因为它是数字签名的)。

JWT 可应用于但不仅限于下面的几种场景:

1、跨域认证

JWT 是一种比较流行的跨域认证解决方案,JWT 的诞生并不是解决 CSRF 跨域攻击,而是解决跨域认证的难题。

A 网站和 B 网站是同一家公司的关联服务,现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,这应该如何实现呢?客户端保存 Token,每次请求都发回给服务器即可。

2、授权(Authorization)

用户一旦登录成功后,后续用户的每个请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的 JWT 的一个特性,因为它的开销很小,并且可以轻松地跨域使用。授权,是使用 JWT 的最常见的场景之一。

3、信息交换(Information Exchange)

对于安全的在各方之间传输信息而言,JWT 是一种很好的方式。JWT 可以被签名,例如,用公钥/私钥对,可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,还可以验证内容没有被篡改。

可以参考阮一峰老师的 JSON Web Token 入门教程,更多详细的介绍可以参考 jwd.io 的相关资料。

使用 JWT

Spring Boot 集成 jjwt

本文以集成 https://github.com/jwtk/jjwt 为例。如果你有兴趣也可以试着去使用 https://github.com/auth0/java-jwt,它是 JWT 的另一个 Java 实现。

截止到该文发布,在 maven repository 仓库中 jjwt 最新版本是 0.9.1

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

修改了哪些文件

本次涉及修改和新增的文件如下:

  • 【修改】MSUserSigninService.java:登录服务的接口;
  • 【修改】MSUserSigninServiceImpl.java:登录服务的接口实现;
  • 【修改】MSSigninController.java:登录的Controller;
  • 【新增】MSAuthTokenUtil.java:token工具类;
  • 【新增】MSAuthConfigurer.java:token配置管理;
  • 【新增】MSAuthInterceptor.java:自定义拦截器;

具体的实现步骤为:

  • 写 token 工具类,实现 token 的生成,校验等工作即 MSAuthTokenUtil.java;
  • 写自定义拦截器,即 MSAuthInterceptor.java,该类实现了 HandlerInterceptor 接口;
    • 拦截客户端相关的 API 请求,对相关的接口进行token的校验;
    • 有了统一的拦截器不需要在每个 Controller 或者对应的 Service 中去做 token 的判断;
  • 写自定义拦截器的配置管理类即 MSAuthConfigurer.java,该类实现了 WebMvcConfigurer 接口;
  • 增加 token 登录的 API,并实现 Redis 缓存 token 的逻辑;

实例演练

用户登录完成后,根据 userID 生成 token,将 token 保存到 Redis 中按照 userID 为 key 来进行存储的。

MSAuthInterceptor.java 是自定义的拦截器,在该拦截器中获取请求的 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
40
41
42
43
44
45
46
47
@Component
public class MSAuthInterceptor implements HandlerInterceptor {
private static final String REQUEST_TOKEN_KEY = "token";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestMethod = request.getMethod();
if ("OPTIONS".equalsIgnoreCase(requestMethod)) {
response.setStatus(HttpServletResponse.SC_OK);
return true;
}
// 请求的Header中拿
String token = request.getHeader(REQUEST_TOKEN_KEY);
// Header中拿不到token
if (null == token) {
String[] tokens = request.getParameterValues("token");
if (null != tokens && tokens.length > 0) {
token = tokens[0];
}
}
if (MSAuthTokenUtil.verifyToken(token)) {
return true;
}
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try {
writer = response.getWriter();
Map<String, Object> result = new HashMap<>(2);
result.put("code", 400);
result.put("msg", "用户令牌token无效");
result.put("data", null);
writer.print(result);
} catch (IOException e) {
} finally {
if (null != writer) {
writer.close();
}
}
return false;
}
}

拦截器的配置在 MSAuthConfigurer.java 中进行管理,关键代码如下:

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
@Configuration
public class MSAuthConfigurer implements WebMvcConfigurer {
private MSAuthInterceptor authInterceptor;
public MSAuthConfigurer(MSAuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 如下路径不做拦截
List<String> excludePaths = new ArrayList<>();
excludePaths.add("/signup/**"); //注册
excludePaths.add("/signin/name/**"); //用户名登录
excludePaths.add("/signin/get/token/**"); //获取token
excludePaths.add("/signout/**"); //登出
excludePaths.add("/static/**"); //静态资源
excludePaths.add("/assets/**"); //静态资源
// 除了 excludePaths 外的请求地址都做拦截
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludePaths);
WebMvcConfigurer.super.addInterceptors(registry);
}
}

接下来重点说一下 MSAuthTokenUtil.java 里面如何生成 token 的,MSAuthTokenUtil.java 主要是完成生成、检验、刷新 token 等工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static String generateToken(String userID) {
String token = "";
Date date = new Date();
// 过期时间
Date expireDate = new Date(System.currentTimeMillis() + TOKEN_EXPIRE_TIME);
token = Jwts.builder().setId(JWTSID)
.setSubject(SUBJECT)
.setAudience(AUDIENCE)
.setIssuedAt(date)
.setExpiration(expireDate)
.claim(CLAIMS_USERID, userID)
.signWith(SignatureAlgorithm.HS256, TOKEN_SECRET)
.compact();
log.info("generateToken token: " + token);
return token;
}

根据用户ID 生成 token,其中 claim(CLAIMS_USERID, userID) 是用于自定义字段的,便于解析 token 时获取相关的信息。

当我们调用用户名+密码登录的时候,会生成对应的 token,然后将该 token 保存到 Redis 中。下次调用 token 登录的接口时,会从 Redis 中取出对应的 token 信息进行校对,校对通过就返回成功,否则返回失败无法登录。

MSSigninController.java 分别实现了获取 token、刷新 token,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
@RequestMapping(value = "/get/token", method = RequestMethod.GET)
@ApiOperation(value = "获取token", httpMethod = "GET", notes = "获取登录")
@ApiImplicitParams({
@ApiImplicitParam(name = "userID", value = "userID", required = true)
})
public MSResponse getToken(@RequestParam(value = "userid") String userID) {
MSResponse response = userSigninService.fetchUserToken(userID);
return response;
}
@RequestMapping(value = "/token", method = RequestMethod.GET)
@ApiOperation(value = "Token登录", httpMethod = "GET", notes = "Token登录")
@ApiImplicitParams({
@ApiImplicitParam(name = "userID", value = "userID", required = true),
@ApiImplicitParam(name = "token", value = "token", required = true)
})
public MSResponse siginWithToken(@RequestParam(value = "userid") String userID, @RequestParam(value = "token") String token) {
MSResponse response = userSigninService.signinUsingToken(userID, token);
return response;
}
@RequestMapping(value = "/refresh/token", method = RequestMethod.GET)
@ApiOperation(value = "刷新Token", httpMethod = "GET", notes = "Token刷新")
@ApiImplicitParams({
@ApiImplicitParam(name = "token", value = "token", required = true)
})
public MSResponse refreshToken(@RequestParam(value = "token") String token) {
MSResponse response = userSigninService.refreshUserToken(token);
return response;
}

为了方便使用了 GET 方式进行网络请求。后续可以改为 POST 请求。

登录逻辑都在 MSUserSigninServiceImpl.java 中,大家可以自行去看源码,这里不再赘述。

API 调用效果

启动 MySQL,启动 Redis,再启动项目即可。

用户登录成功后,调用 /get/token API,如下:

调用 /token 进行登录的 API,如下:

调用 refresh/token API 如下:

待办事项

  • token 配置信息放置到配置文件中;2021.01.17 Done 微服务-Token-相关的重构;
  • Redis 中设置 token 的过期时间;
  • 调用刷新 token 的 API 后更新 Redis 中 token 的有效时间;
  • 刷新 token、使用 token 登录的 API 修改为 POST 方式;
  • Token 的加密,减少 Token 登录的数据库查询次数;

只有弱者才去争取公平,这句话虽然残忍但很现实~

坚持原创技术分享!