前言
在梳理公司内系统对于Redis的使用时,发现了部门内的开发人员对于登录认证这个模块的实现过于依赖Redis,并且对于JWT的机制存在一些误解。在实现时,登录成功后,生成一个Token放到Redis中,并且返回给前端,要求前端每次请求都携带。在鉴权校验时,与redis中的内容做存在对比,来实现对于身份校验。Redis充当了登录状态保存的作用,以至于Redis故障后,所有接口都会陷入瘫痪。 收集了一些资料,结合自己的理解写一下后续部门内登录认证模块改造的方向,同时也是我自己项目中使用的登录认证方式。
JWT机制
JWT介绍
JWT(Json Web Token)是一个基于Token的认证授权机制。JWT具备了几个特性:
- 紧凑性: JWT采用Base64URL编码,体积小,适合HTTP传输(如URL参数、Header或POST body)
- 自包含性:令牌本身包含所有必要信息(如用户身份、权限、过期时间等)
- 安全性: 采用签名来实现防篡改,采用加密来保护数据实现防窃听
在 RFC-7519 中给出了定义。
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.
数据结构
标准的JWT由三部分组成:Header.Payload.Signature
,三部分的内容均使用Base64URL算法编码后使用 .
拼接。
- Header: 用于申明类型与加密算法,通常是以下结构的Json文本
1{
2 "alg": "HS256",
3 "typ": "JWT"
4}
- Payload:用于保存内容,通常用于保存用户的唯一标识和过期时间,来实现认证 以下是官方给出的7个默认字段,当然也可以添加自定义字段。
1 iss (issuer):签发人
2 exp (expiration time):过期时间
3 sub (subject):主题
4 aud (audience):受众
5 nbf (Not Before):生效时间
6 iat (Issued At):签发时间
7 jti (JWT ID):编号
这部分的数据并非一定会加密,所以不建议在这部分内容中保存敏感信息
- Signature:签名,用于保护前两短信息不被篡改使用以下公示计算
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
,其中secret
为签发方自己保留的密钥,用于验证是否被篡改
与Session ID对比
相比于传统的Session ID方式,JWT将状态维护在客户端,实现了服务端无状态,减轻了服务端的压力,但是在设计上也会带来一些改变。
特性 | JWT | Session Cookie |
---|---|---|
存储位置 | 客户端(LocalStorage等) | 服务端数据库 |
扩展性 | 天然支持分布式系统 | 需要Session共享机制 |
性能 | 无数据库查询(自包含) | 每次请求需验证Session状态 |
安全性 | 依赖密钥安全和传输加密 | 依赖Cookie安全标记(Secure/HttpOnly) |
失效机制 | 需通过逻辑实现(如黑名单) | 服务端直接删除Session |
落地实践
登录流程设计
这里仅讨论单个AccessToken的场景,RefreshToken+AccessToken的方案在 登录状态保存 中说明。
在JWT的登录流程设计中,客户端通过账号密码换取 Token,客户端保存Token。该过程不需要认证,仅和数据库交互验证密码是否正确。
参考伪代码:
1public UserAuth login(UserAuth userAuth) {
2 User user = userMapper.selectOne(userAuth.getUsername());
3 // 用户不存在
4 if (user == null) {
5 throw new NotAuthException(LOGIN_FAILED.getCode(), LOGIN_FAILED.getMsg());
6 }
7
8 // 密码错误
9 if (!BCrypt.checkpw(userAuth.getPassword(), user.getPassword())) {
10 throw new NotAuthException(LOGIN_FAILED.getCode(), LOGIN_FAILED.getMsg());
11 }
12
13 JwtHelper.Token token = jwtHelper.generateToken(userAuth.getUsername());
14 userAuth.setAccessToken(token.accessToken());
15 userAuth.setRefreshToken(token.refreshToken());
16 userAuth.setPassword("******");
17
18 // 新的refresh_token
19 AuthInfo authInfo = authInfoMapper.selectById(userAuth.getUsername());
20 if (authInfo == null) {
21 authInfo = new AuthInfo();
22 authInfo.setId(userAuth.getUsername());
23 authInfo.setType("user");
24 authInfo.setRefreshToken(token.refreshToken());
25 authInfoMapper.insert(authInfo);
26 } else {
27 authInfo.setRefreshToken(token.refreshToken());
28 authInfoMapper.update(authInfo);
29 }
30 logger.info("[Auth] Login success.Username is {}", userAuth.getUsername());
31 return userAuth;
32}
认证流程设计
这里仅讨论单个AccessToken的场景,RefreshToken+AccessToken的方案在 登录状态保存 中说明。
认证流程可以使用SpringBoot的Filter机制实现,将大部分请求拦截,验证Header中的Token是否有效,并且为了用户体验,假设过期时间为30分钟,那么我们在临期10分钟时在Response Header中写入 X-NEW-ACCESS-TOKEN: ${newToken}
来实现自动续签。这个认证过程既不依赖数据库,也不依赖缓存系统,只需要依靠服务本身的计算能力与JWT包含的信息就可以完成验证。
参考代码:
1@Override
2public void doFilter(HttpClassicServerRequest request, HttpClassicServerResponse response, HttpServerFilterChain chain) throws DoHttpServerFilterException {
3 try {
4 // jwt验证与自动续签
5 Optional<String> authorization = request.headers().first("Authorization");
6 String authorizationStr = authorization.orElseThrow(() ->
7 new NotAuthException(TOKEN_ILLEGAL.getCode(), TOKEN_ILLEGAL.getMsg()));
8 String token = authorizationStr.replace("Bearer ", "");
9 Jws<Claims> claimsJws = jwtHelper.validateToken(token);
10 long expire = claimsJws.getPayload().getExpiration().getTime();
11 long now = Date.from(Instant.now()).getTime();
12 if (expire - now < 60 * 1000 * 10 && expire - now >= 0) {
13 // 剩余不足10分钟自动续签
14 response.headers().set("X-NEW-ACCESS-TOKEN",
15 jwtHelper.generateAccessToken(claimsJws.getPayload().getSubject()));
16 }
17 chain.doFilter(request, response);
18 } catch (Throwable e) {
19 logger.error("[Auth] Auth failed.", e);
20 // todo return 4xx HttpCode
21 }
22}
需要额外注意的是,注销功能需要在服务端实现一个黑名单
因为登录状态被保留在了客户端,所以天然无法实现注销功能。需要在注销发生后,将已注销的Token加入黑名单。AuthFilter
中增加Token是否在黑名单的校验,来判断用户的登录状态。
这是一个Redis的适用场景,也是JWT设计中唯一一个Redis的适用场景。建议是选用Redis,如果需要加强安全性可以考虑适用数据库+Redis的方案。如果只使用Redis,Redis故障仅失去黑名单功能,对于业务的安全性有一定影响,且影响范围可控,不影响业务。如果系统对于注销的安全性没有特别高的要求,只适用Redis是一个比较合理的选择,实现简单,且副作用可控。
登录状态保存(可选)
如果不需要使用长期状态保持可以只是用 AccessToken,在过期后引导用户重新输入用户名和密码进行登录即可。
如果需要像APP一样做到长期登录状态的保持,考虑到如果只是延长accessToken过期时间,会无限放大Token泄露风险。所以引入了RefreshToken,该Token不可用于认证,只能用于签发新的AccessToken与RefreshToken,并且服务端将RefreshToken与用户进行绑定。这个绑定关系存储到数据库中保存,在签发新的Token后立刻废弃老的,被废弃的RefreshToken将会被当成无效Token处理。(当然,这样的设计是无法兼容单用户多设备场景的,如果需要兼顾多设备登录场景,可以将 RefreshToken与 user_id,device_id
绑定,实际上与设备绑定可以有效防止Token泄露后被盗用的风险)
在实际的使用中,登录签发两个Token,并将RefreshToken存入数据库。鉴权场景RefreshToken不参加,新增一个使用RefreshToken签发新Token的场景用于存续登录状态,获取新的AccessToken。
本文为原创内容,版权归作者所有。如需转载,请在文章中声明本文标题及链接。
文章标题:JWT认证机制实践 —— [Za_Nks]
文章链接:https://www.zanks.link/2025/03/05/java-jwt/
许可协议:CC BY-NC 4.0