Json Web Token(JWT)
JWT是一种客户端服务端会话的实现方式(Cookie -> Session -> JWT 演进),关于会话技术可参阅这篇文章。
Json web token (JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(Single Sign On,SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
其他三个概念:
JWE(RFC 7516):JSON Web Encryption,表示基于JSON数据结构的加密内容,加密机制对任意八位字节序列进行加密、提供完整性保护和提高破解难度。
JWS(RFC 7515):JSON Web Signature,表示使用JSON数据结构和BASE64URL编码表示经过数字签名或消息认证码(MAC)认证的内容,数字签名或者MAC能够提供完整性保护。
JWA(RFC 7518):JSON Web Algorithm,JSON Web算法,数字签名或者MAC算法
JWT的实现有两种:基于JWE、基于JWS。基于JWE的实现依赖于加解密算法、BASE64URL编码和身份认证等手段提高传输的Claims的被破解难度,而基于JWS的实现使用了BASE64URL编码和数字签名的方式对传输的Claims提供了完整性保护,也就是仅仅保证传输的Claims内容不被篡改,但是会暴露明文。即区别主要在于数据是否被加密。
目前主流的JWT框架中大部分都没有实现JWE,所以下文的讨论是指JWS。
注:上面说的“token也可直接被用于认证” 体现在哪?以JWT为例,通常用于接口访问的授权校验,但实际上授权校验前须先进行认证校验。怎么做认证校验呢?计算签名来验证JWT是否被篡改,若未被篡改则当做认证通过,接下来就可以取JWT里的业务数据进行授权校验了。
组成
(注:如下所涉及的base64指base64 URL算法,其与普通的base64算法有区别:Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法)
由英文句号分隔的三段base64 URL串b1.b2.b3,如:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM。三段分别表示 head、payload、signature 。具体含义如下:
header:头部用于描述关于该JWT的最基本的信息,即元数据信息,例如其类型及签名所用的算法等,为json格式。用base64 URL算法转成一个串b1。示例:
{ "typ": "JWT", "alg": "HS256" }
在JWT规范中称这些Header为JOSE Header(Javascript Object Signature Encryption,Javascript对象签名和加密框架),常用的是上面两个。当然还有其他header,如:
paload:放入一些自定义信息,通常是业务信息,这些字段称作"Claim",。为json格式。用base64转成一个串b2。预定义的字段有(jwt预放入了前五个字段):
iss: 该JWT的签发者
sub: 该JWT所面向的用户
aud: 接收该JWT的一方
exp(expires): 什么时候过期,这里是一个Unix时间戳
iat(issued at): 在什么时候签发的
jti:JWT ID
nbf:Not Before,早于该定义的时间的JWT不能被接受处理
这些预定义的Claim并不要求强制使用,何时选用何种Claim完全由使用者决定,而为了使JWT更加紧凑,这些Claim都使用了简短的命名方式去定义。在不和内建的Claim冲突的前提下,使用者可以自定义新的Claim。自定义的Claim通常包括userId、username、用户权限scope、用户角色等。
signature:用header中所声明的签名算法(需要为之提供一个key),根据 base64( header).base64(paload) 算得签名值,并BASE64URL成第三个base64串b3。
注:
三部分都是明文的,可以通过base64解码看出原始内容,故jwt payload等部分中一定不要放入敏感数据如密码等内容。
注意签名和加密的区别:前者指根据内容产生一段摘要信息,摘要信息长度通常固定且比原始值少很多且不可逆,签名也可以理解为摘要、指纹、哈希等,具体算法有MD5、HS256等;后者则指用某种算法将原始内容转换成无意义的内容,只有知道解密方法才能根据被加密的内容获知原始内容,具体算法有RSA等。可见,加密算法也可以被当做签名算法来用,只不过生成的值可能比较大。
校验原理
由于用base64,所以可以直接逆转码提取header、payload、signature信息。服务端收到token后会提取header原始数据中声明的摘要算法,然后按上面介绍的signature计算过程取计算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取得的用户唯一标识进行相关权限的校验,并把此用户标识赋予到请求参数中(这步通常在 API网关(BFF)做),下游业务可通过此用户标识进行业务处理。
7、用户注销时,服务端需要把还在时效内的Token保存到Redis中,并设置正确的失效时长。
时序图如下:
在上述过程中,登录时服务端需要查询数据库以确定用户名、密码是否正确,在登录成功之后的其他请求中则可以直接从token中提取需要的信息而不需要查询数据库。
基于JWT实现的认证方式下,服务端颁发JWT后不需要保存,客户端可以通过它与服务端交互,由于JWT泄露后有安全风险,故实践时应注意:
JWT需要设置有效期,也就是exp这个Claim必须启用和校验;
JWT需要建立黑名单,一般使用jti这个Claim即可。用户数不多的话可借助Redis Set或ZSet存 jti 黑名单实现(见后文),多的话则可以使用布隆过滤器+数据库组合实现。
JWS的签名算法尽可能使用安全性高的算法。
Claims不要写入敏感信息;
高风险场景如支付操作等不能仅仅依赖JWT认证,需要进行短信、指纹等二次认证;
与基于Cookie/Session的会话技术的对比
可参阅一片通俗易懂的文章:https://mp.weixin.qq.com/s/_ViJ-xtj_sRtHppled6AQg
session 和 token 本质上都是对用户身份的认证机制,只是他们实现的校验机制不一样(一个保存在 server,通过在 redis 等中间件获取来校验,一个保存在 client,通过签名校验的方式来校验)。
cookie方案的一个缺点是跨域问题,特别是在微服务架构下,不同服务域名并不保证有公共的父域名。
最大的区别是无状态,即服务端不需要保存认证数据而是客户端保存,影响:
优点:服务端不用保存token故无内存占用;因为没有Cookie的跨域问题故非常适合SSO或三方授权点场景。
缺点:一个账号可多出同时登录;数据比较长;不太安全(服务端无法随时控制有效期);无法撤销(用户注销或在排他登录时不会使刚刚的token失效);无法更新token(token有效期到了无法自动延长,而是会强制用户重登录)。
选型:多数场景上使用 session 会更合理,但如果在单点登录,一次性命令认证上使用 token 会更合适,最好在不同的业务场景中合理选型,才能达到事半功倍的效果。JWT适合的场景:SSO或三方授权的场景。
现实:无状态和撤销是矛盾的。实际使用中,为了解决token撤销和更新的问题,需要引入Redis等,这使得这种方案变得有状态了。既然如此,可以结合token和kookie:生成token后发给客户端并设置客户端cookie,之后请求优先检验请求头是否有token,没有的话从cookie取。这样对于浏览器端前端无需携带token的逻辑(通过cookie来让浏览器自动带上)、移动端则通过设置请求头token实现认证;而后端则可以兼容这两种场景。在SSO场景下,只需要将含有JWT的Cookie的domain设置为顶级域名即可,各子域名站点就能够获得该JWT从而实现共享。
利弊
(与传统session或token的区别):
- 适合用于向Web应用传递一些非敏感信息如userId、isAdmin等,不能包含密码等敏感信息;
- 本身具备失效判断机制:根据串本身就能知道该token是否失效;
- 服务端能识别被篡改的token,所以只要token校验通过,就可以把里面封装的信息当成可信的。
- 服务端不需要存储token,而是分散给各个客户端存储,session机制则要。有利就有弊,jwt增加了计算开销如加解密,但总的利大于弊。
有利就有弊:
- 单纯使用jwt存在问题:一个浏览器内(即一个session)可以多账号同时登录、一个账号可以在多个浏览器上同时登录,需要借助session等加以解决。
- jwt的一个很重要的特点或优点是使得服务端完全无状态(stateless),服务端无须存储认证相关信息了。但这也成为其缺点:
- 无法撤销:用户注销时token不会立马失效,只能等签发有效期到期。可通过Redis等为用户登出时的token维护一个黑名单来解决,但这就使得有状态了。
- 无法更新:token过期无法自动更新导致强制用户重登录。需要前端配合来解决该问题。
相比较于session/cookie, token能提供更加重要的好处:
1. CORS。
2. 不需要CSRF的保护。
3. 更好地和移动端进行集成。
4. 减少了授权服务器的负载。
5. 不再需要分布式会话的存储。
有一些交互操作会用这种方式需要权衡的地方:
1. 更容易受到XSS攻击
2. 访问令牌可以包含过时的授权声明(如当一些用户权限撤销)
3. 在claims 的数在曾长的时候,Access token 也能在一定程度上增长。
4. 文件下载API难以实现的。
5. 无状态和撤销是互斥的。
使用示例
依赖:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency>
代码:
1 import java.security.Key; 2 3 import io.jsonwebtoken.Claims; 4 import io.jsonwebtoken.ExpiredJwtException; 5 import io.jsonwebtoken.Jws; 6 import io.jsonwebtoken.Jwts; 7 import io.jsonwebtoken.MalformedJwtException; 8 import io.jsonwebtoken.SignatureAlgorithm; 9 import io.jsonwebtoken.SignatureException; 10 import io.jsonwebtoken.impl.crypto.MacProvider; 11 12 public class JWTtest { 13 14 public static void main(String[] args) { 15 // 生成jwt 16 Key key = MacProvider.generateKey();// 这里是加密解密的key。 17 String compactJws = Jwts.builder()// 返回的字符串便是我们的jwt串了 18 .setSubject("Joe")// 设置主题 19 .claim("studentId", 2)// 添加自定义数据 20 .signWith(SignatureAlgorithm.HS512, key)// 设置算法(必须) 21 .compact();// 这个是全部设置完成后拼成jwt串的方法 22 System.out.println("the generated token is: " + compactJws); 23 24 // 解析jwt 25 try { 26 27 Jws<Claims> parseClaimsJws = Jwts.parser().setSigningKey(key).parseClaimsJws(compactJws);// compactJws为jwt字符串 28 Claims body = parseClaimsJws.getBody();// 得到body后我们可以从body中获取我们需要的信息 29 // 比如 获取主题,当然,这是我们在生成jwt字符串的时候就已经存进来的 30 String subject = body.getSubject(); 31 System.out.println("the subject is: " + subject); 32 System.out.println("the studentId is: " + body.get("studentId")); 33 34 // OK, we can trust this JWT 35 36 } catch (SignatureException | MalformedJwtException e) { 37 // TODO: handle exception 38 // don't trust the JWT! 39 // jwt 解析错误 40 } catch (ExpiredJwtException e) { 41 // TODO: handle exception 42 // jwt 已经过期,在设置jwt的时候如果设置了过期时间,这里会自动判断jwt是否已经过期,如果过期则会抛出这个异常,我们可以抓住这个异常并作相关处理。 43 } 44 } 45 }
实践踩坑记录
如下两个问题的解决方案使得无状态的JWT变为了有状态。
无法更新的问题(token失效后如何更新)
劣势:jwt与session相比的一大劣势是有效期放在token里保存在客户端,故服务端无法更改有效期,因此如果单只用一个token则在token有效期到后用户就会被提示需重新登录,而不是像session那样每次有访问就可由服务端延长session有效期。
如何解决?
方案:服务端客户端配合来从效果上“延长”有效期,实际上是生成新accessToken。
登录后同时生成accessToken、refreshToken,前者在调用业务接口时带上,后者则用于更新accessToken,后者有效期比前者长。当accessToken失效时服务端返回accssToken过期的故障码,接着由客户端携带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())); } }
该方案需要前端配合——即在过期时返回错误信息告诉前端过期了前端拿着refresh token去请求新access token,该过程对用户无感。
无法撤销的问题(主动登出、排他登录、禁用或删除的实现)
借助中心化缓存来完成 登出、多处登录 的token黑名单维护,这里用Redis。
数据结构:
用 Redis ZSet 记录当前用户已生成的token(用户名密码校验通过时产生),key、value、score 分别为userId、jwtId、currentTimestamp。系统有配置参数来设置允许一个账号在n个地方同时登录。
用 Redis String 来记录黑名单的token,key、value 分别为 jwtId、加入黑名单的原因(如他处登录、登出、账号禁用、账号删除等),过期时间为各token的剩余有效时间。不用Set之类的结构是因为各key的过期时间不同。
流程:
登录和其他地方登录:当用户登录认证校验通过并生成token后,将该token加入ZSet。若ZSet元素个数比n大,则删除最早的若干个元素使得只剩n个、同时将删掉的几个jwtId加入到黑名单。 JedisPoolClientUtil.zrange(userId, 0, -(AuthConfigParam.maxLoginCount + 1)) ;当用户在其他地方登录时,同样的逻辑。
登出:当用户登出时后台将当前jwtId加入黑名单。
账号删除、禁用等,与登出的逻辑一致。
黑名单使用:带着token来访问接口时,后台会判断该token的jwtId是否在黑名单中。
进阶
分布式session
见:https://www.cnblogs.com/z-sm/p/5461917.html
参考资料
Json Web Token:http://blog.leapoahead.com/2015/09/06/understanding-jwt/
JWT 的实现原理和基本使用:https://www.cnblogs.com/throwable/p/14419015.html
Json Web Token单点登录:http://blog.leapoahead.com/2015/09/07/user-authentication-with-jwt/
https://blog.csdn.net/a82793510/article/details/53509427