JWT

1.概念

JSON Web Tokens,一种紧凑的Claims声明格式,旨在用于空间受限的环境进行传输,常见的场景如HTTP授权请求头参数和URI查询参数。
JWT会把Claims转换成JSON格式,而这个JSON内容将会应用为JWS结构的有效载荷或者应用为JWE结构的(加密处理后的)原始字符串,通过消息认证码(Message Authentication Code或者简称MAC)和/或者加密操作对Claims进行数字签名或者完整性保护。

  • JWE:JSON Web Encryption,表示基于JSON数据结构的加密内容,加密机制对任意八位字节序列进行加密、提供完整性保护和提高破解难度
  • JWS:JSON Web Signature,表示使用JSON数据结构和BASE64URL编码表示经过数字签名或消息认证码(MAC)认证的内容,数字签名或者MAC能够提供完整性保护
  • JWA:JSON Web Algorithm,JSON Web算法,数字签名或者MAC算法

2.实现方式

  • 基于JWE实现
    依赖于加解密算法、BASE64URL编码和身份认证等手段提高传输的Claims的被破解难度
  • 基于JWS的实现
    使用了BASE64URL编码和数字签名的方式对传输的Claims提供了完整性保护,也就是仅仅保证传输的Claims内容不被篡改,但是会暴露明文。
    目前主流的JWT框架中大部分都通过JWS的实现

3.Claims

JWT的核心作用就是保护Claims的完整性(或者数据加密),保证JWT传输的过程中Claims不被篡改(或者不被破解)。
Claims在JWT原始内容中是一个JSON格式的字符串,其中单个Claim是K-V结构,作为JsonNode中的一个field-value,这里列出常用的规范中预定义好的Claim:
| iss | Issuer | 发行方 |
| sub | Subject | 主体 |
| aud | Audience | (接收)目标方 |
| exp | Expiration Time | 过期时间 |
| nbf | Not Before | 早于该定义的时间的JWT不能被接受处理 |
| iat | Issued At | JWT发行时的时间戳 |
| jti | JWT ID | JWT的唯一标识 |
Claim并不要求强制使用,何时选用何种Claim完全由使用者决定,而为了使JWT更加紧凑,这些Claim都使用了简短的命名方式去定义。
在不和内建的Claim冲突的前提下,使用者可以自定义新的公共Claim

4.Header

在JWT规范文件中称这些Header为JOSE Header,JOSE的全称为Javascript Object Signature Encryption,也就是Javascript对象签名和加密框架,JOSE Header其实就是Javascript对象签名和加密的头部参数。

5.应用场景

JWT的使用场景和实战#
JWT本质是一个令牌,更多场景下是作为会话ID(session_id)使用,作用是'维持会话的粘性'和携带认证信息(如果用JWT术语,应该是安全地传递Claims)。笔者记得很久以前使用的一种Session ID解决方案是由服务端生成和持久化Session ID,返回的Session ID需要写入用户的Cookie,然后用户每次请求必须携带Cookie,Session ID会映射用户的一些认证信息,这一切都是由服务端管理,一个很常见的例子就是Tomcat容器中出现的J(ava)SESSIONID。与之前的方案不同,JWT是一种无状态的令牌,它并不需要由服务端保存,携带的数据或者会话的数据都不需要持久化,使用JWT只需要关注Claims的完整性和合法性即可,生成JWT时候所有有效数据已经通过编码存储在JWT字符串中。正因JWT是无状态的,一旦颁发后得到JWT的客户端都可以通过它与服务端交互,JWT一旦泄露有可能造成严重安全问题,因此实践的时候一般需要做几点:

JWT需要设置有效期,也就是exp这个Claim必须启用和校验
JWT需要建立黑名单,一般使用jti这个Claim即可,技术上可以使用布隆过滤器加数据库的组合(数量少的情况下简单操作甚至可以用Redis的SET数据类型)
JWS的签名算法尽可能使用安全性高的算法,如RSXXX
Claims尽可能不要写入敏感信息
高风险场景如支付操作等不能仅仅依赖JWT认证,需要进行短信、指纹等二次认证
JWT一般用于认证场景,搭配API网关使用效果甚佳。多数情况下,API网关会存在一些通用不需要认证的接口,其他则是需要认证JWT合法性并且提取JWT中的消息载荷内容进行调用,针对这个场景:

对于控制器入口可以提供一个自定义注解标识特定接口需要进行JWT认证,这个场景在Spring Cloud Gateway中需要自定义实现一个JWT认证的WebFilter
对于单纯的路由和转发可以提供一个URI白名单集合,命中白名单则不需要进行JWT认证,这个场景在Spring Cloud Gateway中需要自定义实现一个JWT认证的GlobalFilter

主流的JWT方案是JWS,此方案是只编码和签名,不加密,务必注意这一点,JWS方案是无状态并且不安全的,关键操作应该做多重认证,也要做好黑名单机制防止JWT泄漏后造成安全性问题。

6.基于jjwt实现

  • 依赖
<!--jwt-->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.0</version>
</dependency>
<!--token生成-->
<dependency>
  <groupId>javax.xml.bind</groupId>
  <artifactId>jaxb-api</artifactId>
  <version>2.3.0</version>
</dependency>
<dependency>
  <groupId>com.sun.xml.bind</groupId>
  <artifactId>jaxb-impl</artifactId>
  <version>2.3.0</version>
</dependency>
<dependency>
  <groupId>com.sun.xml.bind</groupId>
  <artifactId>jaxb-core</artifactId>
  <version>2.3.0</version>
</dependency>
<dependency>
  <groupId>javax.activation</groupId>
  <artifactId>activation</artifactId>
  <version>1.1.1</version>
</dependency>
  • token生成和解析工具类
@Component
@Slf4j
public class TokenProvider {
    // 签名算法
    private SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.RS512;
    // 对称密钥生成
    private SecretKeySpec signingKey = null;
    // 私钥
    private PrivateKey privateKey = null;
    // 公钥
    private PublicKey publicKey = null;

    @PostConstruct
    public void init() {

    }

    public TokenResponse createToken(JwtAccessToken jwtAccessToken, long TTLMills, Map<String, Object> params) throws Exception {
        // 获取公钥和私钥
        try {
            publicKey = KeyUtils.getPublicKey(Constants.RS512_PUBLICKEY);
            privateKey = KeyUtils.getPrivateKey(Constants.RS512_PRIVATEKEY);
        } catch (Exception e) {
            log.error("获取PublicKey,PrivateKey失败", e.getMessage());
        }

        TokenResponse tokenResponse = new TokenResponse();
        Map<String, Object> claims = new HashMap<>();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        claims.put(Claims.ID, "1.0");
        claims.put(Claims.ISSUER, jwtAccessToken.getIss());
        claims.put(Claims.SUBJECT, "LWX-Auth-Manager");
        claims.put(Claims.AUDIENCE, jwtAccessToken.getSub());
        if (params != null) {
            Iterator<String> it = params.keySet().iterator();
            while (it.hasNext()) {
                String key = it.next();
                claims.put(key, params.get(key));
            }
        }
        // 生成token
        JwtBuilder jwtBuilder = Jwts.builder().setClaims(claims);
        if (TTLMills >= 0) {
            long expMills = nowMillis + TTLMills;
            Date exp = new Date(expMills);
            jwtBuilder.setExpiration(exp).setNotBefore(now);
            tokenResponse.setExpTime(DateFormatUtils.format(exp, "yyyy-MM-dd HH:mm:ss"));
        }
        String value = signatureAlgorithm.getValue();
        //加密方式兼容
        String type1 = "HS512";
        String type2 = "RS512";
        if (type1.equals(value)) {
            jwtBuilder.signWith(signatureAlgorithm, signingKey);
        }
        if (type2.equals(value)){
            jwtBuilder.signWith(signatureAlgorithm, privateKey);
        }
        String token = jwtBuilder.compact();
        tokenResponse.setAccessToken(token);
        tokenResponse.setRenewal(TTLMills);
        return tokenResponse;
    }

    public Claims parseToken(String jsonWebToken) throws RuntimeException {
        Claims claims = null;
        try {
            if(privateKey == null){
                privateKey = KeyUtils.getPrivateKey(Constants.RS512_PRIVATEKEY);
            }
            claims = Jwts.parser().setAllowedClockSkewSeconds(60).setSigningKey(privateKey).parseClaimsJws(jsonWebToken).getBody();
        } catch (ExpiredJwtException ex) {
            log.error("登录凭证已经过期", ex);
        } catch (Exception ex) {
            log.error("登录凭证不合法", ex);
        }
        return claims;
    }
}
  • 使用的工具类和实体
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JwtAccessToken {

    private String iss;

    private String iat;

    private Long exp;
    
    private String aud;
    
    private String sub;
}
public class KeyUtils {
    public static PublicKey getPublicKey(String key) throws Exception {
        byte[] keyBytes;
        keyBytes = Base64.getDecoder().decode(key);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);
        return publicKey;
    }

    public static PrivateKey getPrivateKey(String key) throws Exception {
        byte[] keyBytes;
        keyBytes = Base64.getDecoder().decode(key);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
        return privateKey;
    }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponse {
    private String accessToken;

    private String expTime;

    private long renewal = 0L;
}
  • 测试类
public void getJwt() throws Exception {
    JwtAccessToken jwtAccessToken = new JwtAccessToken();
    jwtAccessToken.setIss("Lwx");
    Map<String, Object> params = new HashMap<>();
    params.put(Constants.LOGIN_USER_ID, "123");
    params.put(Constants.LOGIN_ACCOUNT_ID, "321");
    params.put(Constants.LOGIN_USER_NAME, "lwx");
    params.put(Constants.LOGIN_LOGINNAME,"lwxlogin" );
    params.put(Constants.LOGIN_ACCOUNT_NAME, "lwxacc");
    params.put(Constants.LOGIN_APP_ID, "1");
    long expiredTime = Constants.DEFAULT_TOKEN_EXPIRED_TIME;
    
    TokenResponse tokenResponse = null;
    tokenResponse = tokenProvider.createToken(jwtAccessToken, expiredTime, params);
    
    System.out.println(tokenResponse.getAccessToken());


    // 解析token获取claims 储存基本信息
    Claims claims = tokenProvider.parseToken(tokenResponse.getAccessToken());
    if (claims != null) {
        String userId = claims.get(Constants.LOGIN_USER_ID).toString();
        String accountId = claims.get(Constants.LOGIN_ACCOUNT_ID).toString();
        String loginName = claims.get(Constants.LOGIN_ACCOUNT_NAME).toString();
        String appId = claims.get(Constants.LOGIN_APP_ID).toString();
        String userName = "";

        System.out.println(loginName);
    }
}

7.问题

JWT应用于认证场景,算法上使用了安全性稍高的RS256,使用RSA算法进行签名生成。项目上线初期,JWT的过期时间都固定设置为7天,生产日志发现该API网关周期性发生"假死"现象,具体表现为:

Nginx自检周期性出现自检接口调用超时,提示部分或者全部API网关节点宕机
API网关所在机器的CPU周期性飙高,在用户访问量低的时候表现平稳
通过ELK进行日志排查,发现故障出现时段有JWT集中性过期和重新生成的日志痕迹
排查结果表明JWT集中过期和重新生成时候使用RSA算法进行签名是CPU密集型操作,同时重新生成大量JWT会导致服务所在机器的CPU超负载工作。初步的解决方案是:

JWT生成的时候,过期时间添加一个随机数,例如360000(1小时的毫秒数) ~ 8640000(24小时的毫秒数)之间取一个随机值添加到当前时间戳加7天得到exp值
这个方法,对于一些老用户营销场景(老用户长时间没有登录,他们客户端缓存的JWT一般都已经过期)没有效果。有时候运营会通过营销活动唤醒老用户,大量老用户重新登录有可能出现爆发性大批量重新生成JWT的情况,对于这个场景提出两个解决思路:

首次生成JWT时候,考虑延长过期时间,但是时间越长,风险越大
提升API网关所在机器的硬件配置,特别是CPU配置,现在很多云厂商都有弹性扩容方案,可以很好应对这类突发流量场景

8.自动续期

JWT本身也有有效期参与签名,问题在于这个有效期不能更改,也很好理解如果参与签名的参数(有效期)发生变化,Token也就不一样了。如果有效期不能改变,即便时间设计的再长,也会有到期的时候,而且Token这种设计初衷也不能有效期很长,导致用户在操作过程中Token到期授权失败,这种情况根本是无法接受的。

  • redis解决
    生成的JWT,不加入过期时间,在服务端Redis额外存储一个对应的过期时间,并每次操作延期。
    这种设计感觉很多余,既然保存到了Redis,JWT从无状态变成了有状态,既然能够保存过期时间,为啥不把用户信息都保存到Redis中,何必用JWT加密后前后端传来传去没有意义。
  • 临近过期刷新JWT,返回新的Token,很多人也采用的是这种方案。

9.主动终止

通过将终止的token保存,由无状态变为有状态

  • 登出,加入黑名单
public SimpleResponse logout(HttpServletRequest request) {
    String token = request.getHeader(Constants.TOKEN_NAME);
    SysTokenBlackList tokenBlackList = new SysTokenBlackList();
    tokenBlackList.setCreateTime(LocalDateTime.now());
    tokenBlackList.setToken(token);
    tokenBlackListService.save(tokenBlackList);
    return createResponse().build();
}
  • 拦截器拦截时判断是否在黑名单中
// 查看token是否在黑名单中
final SysTokenBlackList tokenBlackList = tokenBlackListService.getByToken(token);
posted @ 2024-04-04 20:43  lwx_R  阅读(18)  评论(0编辑  收藏  举报