前言

在梳理公司内系统对于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文本
[ json ]
1{
2  "alg": "HS256",
3  "typ": "JWT"
4}
  • Payload:用于保存内容,通常用于保存用户的唯一标识和过期时间,来实现认证 以下是官方给出的7个默认字段,当然也可以添加自定义字段。
[ text ]
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

落地实践

jwt.md

登录流程设计

这里仅讨论单个AccessToken的场景,RefreshToken+AccessToken的方案在 登录状态保存 中说明。

在JWT的登录流程设计中,客户端通过账号密码换取 Token,客户端保存Token。该过程不需要认证,仅和数据库交互验证密码是否正确。

参考伪代码:

[ java ]
 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包含的信息就可以完成验证。

参考代码:

[ java ]
 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。