Json web token (JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(Single Sign On,SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
组成
(注:如下所涉及的base64指base64 URL算法,其与普通的base64算法有区别:Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法)
由句号分隔的三段base64 URL串b1.b2.b3,如:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
- header:头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等,为json格式。用base64 URL算法转成一个串b1。示例:
{
"typ": "JWT",
"alg": "HS256"
} - paload:放入一些自定义信息,为json格式。用base64转成一个串b2。jwt预放入了五个字段:
- iss: 该JWT的签发者
- sub: 该JWT所面向的用户
- aud: 接收该JWT的一方
- exp(expires): 什么时候过期,这里是一个Unix时间戳
- iat(issued at): 在什么时候签发的
- signature:用header中所声明的签名算法(需要为之提供一个key),根据 base64( header).base64(paload) 算得签名值:第三个base64串b3。
注:
三部分都是明文的,可以通过base64解码看出原始内容,故jwt payload等部分中一定不要放入敏感数据如密码等内容。
注意签名和加密的区别:前者指根据内容产生一段摘要信息,摘要信息长度通常固定且比原始值少很多且不可逆,签名也可以理解为摘要、指纹、哈希等,具体算法有MD5、HS256等;后者则指用某种算法将原始内容转换成不可读的内容,只有知道解密方法才能根据被加密的内容获知原始内容,具体算法有RSA等。
校验原理
由于用base64,所以可以直接逆转码提取header、payload信息。服务端收到token后会根据header声明的加密算法再计算下signature,若与token中的signature不同则当成未授权的token。
JWT认证方式的实现方式
1、客户端不需要持有密钥,由服务端通过密钥生成Token。
2、客户端登录时通过账号和密码到服务端进行认证,认证通过后,服务端通过持有的密钥生成Token,Token中一般包含失效时长和用户唯一标识,如用户ID,服务端返回Token给客户端。
3、客户端保存服务端返回的Token。
4、客户端进行业务请求时在Head的Authorization字段里面放置Token,如:Authorization: Bearer Token
5、服务端对请求的Token进行校验,并通过Redis查找Token是否存在,主要是为了解决用户注销,但Token还在时效内的问题,如果Token在Redis中存在,则说明用户已注销;如果Token不存在,则校验通过。
6、服务端可以通过从Token取得的用户唯一标识进行相关权限的校验,并把此用户标识赋予到请求参数中,业务可通过此用户标识进行业务处理。
7、用户注销时,服务端需要把还在时效内的Token保存到Redis中,并设置正确的失效时长。
时序图如下:
在上述过程中,登录时服务端需要查询数据库以确定用户名、密码是否正确,在登录成功之后的其他请求中则可以直接从token中提取需要的信息而不需要查询数据库。
功能
(与传统session或token的区别):
- 适合用于向Web应用传递一些非敏感信息如userId、isAdmin等,不能包含密码等敏感信息;
- 本身具备失效判断机制:根据串本身就能知道该token是否失效,而不用自己出来了;
- 服务端不需要存储token,而是分散给各个客户端存储,session机制则要。有利就有弊,jwt增加了计算开销如加解密,但总的利大于弊。
- 服务端能识别被篡改的token,所以只要token校验通过,就可以把里面封装的信息当成可信的。
- 由于jwt是分发到客户端存储的而服务端不需要存储,故很容易借之实现单点登录(假设需单点登录的各域名有共同*域名):只需要将含有JWT的Cookie的domain设置为*域名即可,各子域名站点就能够获得该JWT从而实现共享。
有利就有弊:
- 单纯地实验jwt存在问题:一个浏览器内(即一个session)可以多账号同时登录、一个账号可以在多个浏览器上同时登录,需要借助session等加以解决。
- jwt的一个很重要的特点或优点是使得服务端完全无状态(stateless),服务端无须存储认证相关信息了。但这也成为其缺点:用户注销时token不会立马失效,只能等签发有效期到期。可以通过Redis等为用户登出时的token维护一个黑名单来解决,但这就使得有状态了。
相比较于session/cookie, token能提供更加重要的好处:
1. CORS。
2. 不需要CSRF的保护。
3. 更好的和移动端进行集成。
4. 减少了授权服务器的负载。
5. 不再需要分布式会话的存储。
有一些交互操作会用这种方式需要权衡的地方:
1. 更容易受到XSS攻击
2. 访问令牌可以包含过时的授权声明(e。g当一些用户权限撤销)
3. 在claims 的数在曾长的时候,Access token 也能在一定程度上增长。
4. 文件下载API难以实现的。
5. 无状态和撤销是互斥的。
更好的方案-结合token和kookie:生成token后发给客户端并设置客户端cookie,之后请求优先检验请求头是否有token,没有的话从cookie取。这样对于浏览器端前端无需写带token的逻辑(通过cookie来让浏览器自动带上)、移动端则通过设置请求头token实现认证;而后端则可以兼容这两种场景。
使用示例
依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
代码:
import java.security.Key; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.impl.crypto.MacProvider; public class JWTtest { public static void main(String[] args) {
// 生成jwt
Key key = MacProvider.generateKey();// 这里是加密解密的key。
String compactJws = Jwts.builder()// 返回的字符串便是我们的jwt串了
.setSubject("Joe")// 设置主题
.claim("studentId", 2)// 添加自定义数据
.signWith(SignatureAlgorithm.HS512, key)// 设置算法(必须)
.compact();// 这个是全部设置完成后拼成jwt串的方法
System.out.println("the generated token is: " + compactJws); // 解析jwt
try { Jws<Claims> parseClaimsJws = Jwts.parser().setSigningKey(key).parseClaimsJws(compactJws);// compactJws为jwt字符串
Claims body = parseClaimsJws.getBody();// 得到body后我们可以从body中获取我们需要的信息
// 比如 获取主题,当然,这是我们在生成jwt字符串的时候就已经存进来的
String subject = body.getSubject();
System.out.println("the subject is: " + subject);
System.out.println("the studentId is: " + body.get("studentId")); // OK, we can trust this JWT } catch (SignatureException | MalformedJwtException e) {
// TODO: handle exception
// don't trust the JWT!
// jwt 解析错误
} catch (ExpiredJwtException e) {
// TODO: handle exception
// jwt 已经过期,在设置jwt的时候如果设置了过期时间,这里会自动判断jwt是否已经过期,如果过期则会抛出这个异常,我们可以抓住这个异常并作相关处理。
}
}
}
实践踩坑记录
token失效后如何更新
劣势:jwt与session相比的一大劣势是有效期放在token里保存在客户端,故服务端无法更改有效期,因此如果单只用一个token则在token有效期到后用户就会被提示需重新登录,而不是像session那样每次有访问就可由服务端延长session有效期。
如何解决?
方案:登录后同时生成accessToken、refreshToken,前者在调用业务接口时带上,后者则用于更新accessToken,后者有效期比前者长。当accessToken失效时,由客户端携带refreshToken请求获取新的accessToken,若此时refreshToken也过期,则真正过期了,跳到登录页。此方案可减少用户一直在用系统时被提示重新登录的频率,但没有全部杜绝false positive,因为refreshToken也有过期时间。
实现:生成新accessToken时,需要确保新的与原token具有一样的业务claim。具体实践中,如何更新accessToken?几种方法(以下 更新token 指由refreshToken去获取新的accessToken):
1、登录成功后生成两个token时把accessToken加到refreshToken的claim中,更新token时从refreshToken解析出原accessToken的clasim,根据该claim生成新accessToken。问题在于如果原accessToken失效了,则此时对accessToken parseClaims会报过期错误从而拿不到accessToken中的claim。不可行
2、更新token时由前端将原accessToken作为参数传给后端。与上个方法一样,有parseClaims失败的问题从而拿不到原claim。不可行
3、生成两个token时确保refreshToken包含accessToken所具有的所有业务claim,这样更新时可以仅根据refreshToken即可完成。可行。主要代码示例如下:
@Component
public class TokenFactory {
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS512;
private JwtSettings settings; @Autowired
public TokenFactory(JwtSettings settings) {
this.settings = settings;
} /**
* 根据userContext设置加入到token中的数据
*
* @param userContext
* @return
*/
private Claims generateClaims(UserContext userContext) { String username = userContext.getUsername();
if (null == username || username.trim().equals(""))
throw new IllegalArgumentException("用户名为空无法创建jwt token"); if (userContext.getAuthorities() == null || userContext.getAuthorities().isEmpty())
throw new IllegalArgumentException("用户没有任何权限"); // 设置token里的数据
Claims claims = Jwts.claims().setSubject(userContext.getUsername());
claims.put(JwtToken.basicTokenPayload_keyUserId, userContext.getUserId());
claims.put(JwtToken.basicTokenPayload_keyRoles,
userContext.getAuthorities().stream().map(s -> s.toString()).collect(Collectors.toList()));
Map<String, Object> customProperties = userContext.getCustomProperiesInToken();
if (null != customProperties) {
customProperties.entrySet().forEach(entry -> {
String key = entry.getKey();
if (claims.containsKey(key)) {
throw new IllegalArgumentException(String.format("token payload已包含属性'%s'", key));
} else {
claims.put(key, entry.getValue());
}
});
} return claims;
} /**
* 设置jwt自有的几个payload如签发者、有效期等 并生成token
*
* @param claims
* @param tokenId
* @param ttlMinutes
* @return
*/
private final String createTokenStr(Claims claims, String tokenId, Integer ttlMinutes) { LocalDateTime currentTime = LocalDateTime.now(); String token = Jwts.builder().setClaims(claims).setId(tokenId).setIssuer(settings.getTokenIssuer()) .setIssuedAt(Date.from(currentTime.atZone(ZoneId.systemDefault()).toInstant()))
.setExpiration(
Date.from(currentTime.plusMinutes(ttlMinutes).atZone(ZoneId.systemDefault()).toInstant()))
.signWith(signatureAlgorithm, settings.getTokenSigningKey()).compact(); return token;
} /**
* 根据userContext生成token,返回包含两个元素,分别为accessToken、refreshToken
*
* @param userContext
* @return
*/
@SuppressWarnings("unchecked")
public final List<JwtToken> createTokens(UserContext userContext) {
Claims claims = generateClaims(userContext);
String tokenId = UUID.randomUUID().toString();// 确保生成的两个token id一样 AccessToken accessToken = new AccessToken(
createTokenStr(claims, tokenId, settings.getTokenExpirationTimeMinutes())); // refresh token,与access token的区别:role多包含了一个元素;有效期不同
// role包含access token的role元素,以可根据refresh token生成新的access token
((List<String>) (claims.get(JwtToken.basicTokenPayload_keyRoles))).add(Scopes.REFRESH_TOKEN.authority());
RefreshToken refreshToken = new RefreshToken(
createTokenStr(claims, tokenId, settings.getRefreshTokenExpireTimeMinutes())); return Arrays.asList(accessToken, refreshToken); } public final AccessToken createAccessToken(UserContext userContext) {
return (AccessToken) ((List<JwtToken>) (createTokens(userContext))).get(0);
} /**
* 根据refreshToken生成新的accessToken。新accessToken与原accessToken除了 生成时间 和 有效截止时间
* 不一样外其他均一样
*
* @param refreshToken
* @return
*/
@SuppressWarnings("unchecked")
public AccessToken createAccessToken(RefreshToken refreshToken) {
// 若由旧的accessToken生成新的accessToken则若旧者已过期此时parseClaims会报过期错从而拿不到原claim,故转由refreshToken生成 Claims claims = refreshToken.parseClaims(settings.getTokenSigningKey()).getBody(); // 与生成accessToken、refreshToken时两者的关系对应
((List<String>) (claims.get(JwtToken.basicTokenPayload_keyRoles))).remove(Scopes.REFRESH_TOKEN.authority()); return new AccessToken(createTokenStr(claims, claims.getId(), settings.getTokenExpirationTimeMinutes()));
} }
排他登录、登出的实现
借助中心化缓存如Redis来完成
参考资料
- Json Web Token:http://blog.leapoahead.com/2015/09/06/understanding-jwt/
- Json Web Token单点登录:http://blog.leapoahead.com/2015/09/07/user-authentication-with-jwt/
- https://blog.csdn.net/a82793510/article/details/53509427