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>
代碼:
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 }
實踐踩坑記錄
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來完成
進階
分布式session
見:https://www.cnblogs.com/z-sm/p/5461917.html
參考資料
- 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
