一、JWT

1、JWT介绍

JWT(json web token)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用户登录。在传统的用户登录认证中,因为http是无状态的,所以都是采用session方式。用户登录成功,服务端会保存一个session,服务端会返回给客户端一个sessionId,客户端会把sessionId保存在cookie中,每次请求都会携带这个sessionId。
cookie+session这种模式通常是保存在内存中,而且服务从单服务到多服务会面临的session共享问题。虽然目前存在使用Redis进行Session共享的机制,但是随着用户量和访问量的增加,Redis中保存的数据会越来越多,开销就会越来越大,多服务间的耦合性也会越来越大,Redis中的数据也很难进行管理,例如当Redis集群服务器出现Down机的情况下,整个业务系统随之将变为不可用的状态。而JWT不是这样的,只需要服务端生成token,客户端保存这个token,每次请求携带这个token,服务端认证解析就可。

2、JWT的结构解析

jwt 需要存redis吗 jwt为什么要用redis_java


第一部分我们称它为头部(header),第二部分我们称其为载荷(payload),第三部分是签证(signature)

  • header
    jwt的头部承载两部分信息:
    1.声明类型,这里是jwt
    2.声明加密的算法 通常直接使用 HMAC SHA256

完整的头部就像下面这样的JSON:

{
"typ": "JWT",
"alg": "HS256"
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • playload
    载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:标准中注册的声明、公共的声明、私有的声明。
  1. 标准中注册的声明 (建议但不强制使用) :
    iss: jwt签发者
    sub: jwt所面向的用户
    aud: 接收jwt的一方
    exp: jwt的过期时间,这个过期时间必须要大于签发时间
    nbf: 定义在什么时间之前,该jwt都是不可用的.
    iat: jwt的签发时间
    jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
  2. 公共的声明 :
    公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密。
  3. 私有的声明 :
    私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
    定义一个payload:
{
"uid":"e12a34b56c78d9e0f",
"name":"ramostear",
"role":"admin"
}

然后将其进行base64加密,得到Jwt的第二部分:

eyJvcmciOiLku4rml6XlpLTmnaEiLCJuYW1lIjoiRnJlZeeggeWGnCIsImV4cCI6MTUxNDM1NjEwMywiaWF0IjoxNTE0MzU2MDQzLCJhZ2UiOiIyOCJ9
  • signature
    jwt的第三部分是一个签证信息,这个签证信息由三部分组成:header (base64后的)、
    payload (base64后的)、secret。
    这个部分需要base64加密后的header和base64加密后的payload使用。连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
49UF72vSkj-sA4aHHiYN5eoZ9Nb4w5Vb45PsLF7x_NY

密钥secret是保存在服务端的,服务端会根据这个密钥进行生成token和验证,所以需要保护好。

下图为一个JWT生成流程示例:

jwt 需要存redis吗 jwt为什么要用redis_java_02

3、jwt认证流程

在身份验证中,当用户成功登录系统时,授权服务器将会把 JSON Web Token 返回给客户端,用户需要将此凭证信息存储在本地(cookie或浏览器缓存)。当用户发起新的请求时,需要在请求头中附带此凭证信息,当服务器接收到用户请求时,会先检查请求头中有无凭证,是否过期,是否有效。如果凭证有效,将放行请求;若凭证非法或者过期,服务器将回跳到认证中心,重新对用户身份进行验证,直至用户身份验证成功。以访问 API 资源为例,下图显示了获取并使用 JWT 的基本流程:

jwt 需要存redis吗 jwt为什么要用redis_jwt_03

4、集成和使用说明

添加依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jwt.version}</version>
</dependency>

生成以及解析 jwt token方法:

/**
 * 生成token方法
 * @param claims map对象,可传递需要携带的参数
 * @return
 */
public String generateToken(Map<String, Object> claims) {
	return Jwts.builder().setClaims(claims).setExpiration(generateExpirationDate())
			.signWith(SignatureAlgorithm.forName(alg), secret).compact();
}
/**
 * 获得token内的内容
 * @param token
 * @return
 */
public Claims getClaimsFromToken(String token) {
	Claims claims;
	try {
		claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
	} catch (Exception e) {
		claims = new DefaultClaims();
		log.warn("{}", e.getMessage(), e);
	}
	return claims;
}

处理 jwt token 过期问题:

/**
 * 生成过期时间
 * @return date
 */
public Date generateExpirationDate() {
	return new Date(System.currentTimeMillis() + expiration * MILLISECOND);
}
/**
 * 判断token是否失效
 * @param token
 * @return
 */
public Boolean isTokenExpired(String token) {
	final Date expirationDate = getExpirationDateFromToken(token);
	if (expirationDate == null) {
		return false;
	}
	return expirationDate.before(new Date());
}
/**
 * 获取过期时间
 * @param token
 * @return date
 */
public Date getExpirationDateFromToken(String token) {
	Date expirationDate;
	try {
		//获得token内的内容
		final Claims claims = getClaimsFromToken(token);
		expirationDate = claims.getExpiration();
	} catch (Exception e) {
		expirationDate = null;
		log.warn("{}", e.getMessage(), e);
	}
	return expirationDate;
}

jwt token定期刷新处理方法:

/**
 * 刷新token
 * 
 * @param token jwt的token
 * @return 刷新后的token
 */
public String refreshToken(String token) {
	String refreshedToken;
	try {
		final Claims claims = getClaimsFromToken(token);
		claims.put("created", new Date());
		refreshedToken = generateToken(claims);
	} catch (Exception e) {
		refreshedToken = null;
		log.warn("{}", e.getMessage(), e);
	}
	return refreshedToken;
}

二、JWT的利弊以及并发处理

1、 使用 JWT 的优势
使用 JSON Web Token 保护应用安全,你至少可以获得以下几个优势:

  • 更少的数据库连接:因其基于算法来实现身份认证,在使用 JWT 时查询数据的次数更少(更少的数据连接不等于不连接数据库),可以获得更快的系统响应时间。构建更简单:如果你的应用程序本身是无状态的,那么选择 JWT 可以加快系统构建过程。
  • 跨服务调用:你可以构建一个认证中心来处理用户身份认证和发放签名的工作,其他应用服务在后续的用户请求中不需要(理论上)在询问认证中心,可使用自有的公钥对用户签名进行验证。
  • 无状态:你不需要向传统的 Web 应用那样将用户状态保存于 Session 中。

2、使用 JWT 的弊端

  • 严重依赖于秘钥:JWT 的生成与解析过程都需要依赖于秘钥(Secret),且都以硬编码的方式存在于系统中(也有放在外部配置文件中的)。如果秘钥不小心泄露,系统的安全性将收到威胁。
  • 服务端无法管理客户端的信息:如果用户身份发生异常(信息泄露,或者被攻击),服务端很难向操作 Session 那样主动将异常用户进行隔离。
  • 服务端无法主动推送消息:服务端由于是无状态的,他将无法使用像 Session 那样的方式推送消息到客户端,例如过期时间将至,服务端无法主动为用户续约,需要客户端向服务端发起续约请求。
  • 冗余的数据开销:一个 JWT 签名的大小要远比一个 Session ID 长很多,如果你对有效载荷(payload)中的数据不做有效控制,其长度会成几何倍数增长,且在每一次请求时都需要负担额外的网络开销。
  • JSON Web Token 很流行,但是它相比于 Session,OIDC(OpenId Connect)等技术还比较新,支持 JSON Web Token 的库还比较少,而且 JWT 也并非比传统 Session 更安全,他们都没有解决 CSRF 和 XSS 的问题。因此,在决定使用 JWT 前,你需要仔细考虑其利弊。

JWT(Json Web Token)如何解决并发问题的思考

由于JWT这种形式的请求属于无状态的,请求过程中需要等到token过期后采取刷新,在HTTP请求并发这块并没有很好的解决办法;

当服务端在检查到请求的令牌过期之后,会刷新Token重新颁发令牌,并且再次做登录操作,流程上没什么问题,但在页面加载后倘若同一个页面中有多个请求几乎同一时间发起,每一个请求都携带原始令牌,在这样的设计下,就有可能出现在第一个请求到达后刷新了Token,并更改了缓存中的refreshToken的时间戳,以至于剩余请求校验时发现时间戳不一致导致验证失败而在日志中多次打印出当前Token已经失效的log。同时发起的请求越多,log中的异常也就会越多。虽然第一个请求已经刷新了Token,但是其余的请求是失败的,页面中的数据并不完整,显然这是不正常的,那该如何解决呢?

当然实现的方式可以有多种,如我们现在Token过期后刷新再加synchronized生成Token策略,或者前端定时去调用服务端API刷新Token,再如这里即将采用的Token在有效期内定时更新的方式。

在采用有效期内定时刷新的逻辑之前,引用一段介绍:

一个好的模式是在它过期之前刷新令牌。将令牌过期时间设置为一周,并在每次用户打开
Web应用程序并每隔一小时刷新令牌。如果用户超过一周没有打开过应用程序,那他们就
需要再次登录,这是可接受的Web应用程序UX(用户体验)。要刷新令牌,API需要一个新
的端点,它接收一个有效的,没有过期的JWT,并返回与新的到期字段相同的签名的
JWT。然后Web应用程序会将令牌存储在某处。

避免并发情况下token失效问题,可以采用以下方案处理:

  1. Redis锁机制限制并发请求
    为避免多个请求同一时间分别生成不同的Token,我们引入redis锁机制。即我们的目的是同一个用户同一时间的不同请求,只允许获得锁的请求进行令牌刷新,其他的请求因为是在令牌有效期内直接放行。
  2. 加入Token验证通过后定时刷新Token的逻辑
    将原来设计的Token到期后刷新,重新修改为Token在有效期内刷新,使得Token一旦到期,则直接跳转到登录页,保证了同一个用户,并发的请求只会更换一次令牌