令牌機制
為什么不能使用 Session 實現登錄功能?
傳統思路:
- 登錄頁面把用戶名密碼提交給服務器。
- 服務器端驗證用戶名密碼是否正確,并返回校驗結果給前端。
- 如果密碼正確,則在服務器端創建
Session
。通過 Cookie 把sessionId
返回給瀏覽器。
- 問題:
集群環境下無法直接使用 Session。
-
原因分析:
-
右邊的三臺服務器為一個
集群
,集群中的每一個服務器稱為集群的節點
。 -
我們開發的項目,在企業中很少會部署在一臺機器上,容易發生單點故障(單點故障:一旦這臺服務器掛了,整個應用都沒法訪問了)。
-
所以通常情況下,
一個 Web 應用會部署在多個服務器上
,通過Nginx
等進行負載均衡
。此時,來自一個用戶的請求
就會被分發到不同的服務器上
。
-
回憶 Session 機制:
假設我們使用 Session
進行會話跟蹤
,我們來思考如下場景:
- 用戶登錄:用戶登錄請求,經過
負載均衡
,把請求轉給了第一臺服務器,第一臺服務器進行賬號密碼驗證,驗證成功后,把Session
存在了第一臺服務器上。 - 查詢操作:用戶登錄成功之后,攜帶
Cookie
(里面包含sessionId
)繼續執行查詢操作,比如查詢博客列表。此時請求轉發到了第二臺機器,第二臺機器會先進行權限驗證操作(通過 sessionId 驗證用戶是否登錄)
,此時第二臺機器上沒有該用戶的 Session
,就會出現問題,提示用戶登錄,這是用戶不能忍受的。
Session 存儲在內存中(耗費服務器資源),服務重啟,Session 丟失
,接下來我們介紹第三種方案:令牌技術。
令牌技術
令牌的運行機制
令牌其實就是用戶身份的標識,名稱起得很高端,其實本質就是一個字符串
。
比如我們出行在外,會帶著自己的身份證,需要驗證身份時,就掏出身份證。
- 身份證不能偽造,可以辨別真假。
服務器具備生成令牌和驗證令牌的能力。
我們使用令牌技術,繼續思考上述場景:
-
用戶登錄:用戶登錄請求,經過
負載均衡
,把請求
轉給了第一臺服務器
,第一臺服務器進行賬號密碼驗證
,驗證成功后,生成一個令牌,并返回給客戶端。
-
客戶端收到令牌之后,把令牌存儲起來
。可以存儲在Cookie
中,也可以存儲在其他的存儲空間(比如localStorage
)。 -
查詢操作:
用戶登錄成功之后,攜帶令牌繼續執行查詢操作
,比如查詢博客列表。此時請求
轉發到了第二臺機器
,第二臺機器會先進行權限驗證操作
。服務器驗證令牌是否有效,如果有效,就說明用戶已經執行了登錄操作;如果令牌是無效的,就說明用戶之前未執行登錄操作
。
令牌的優缺點
-
優點:
解決了集群環境下的認證問題
(服務重啟,Session 丟失)。令牌無需在服務器端存儲
,減輕服務器的存儲壓力,而Session 存儲在內存中
,會耗費服務器資源。
-
缺點:
- 需要自己實現(包括令牌的生成、令牌的傳遞、令牌的校驗)。
當前企業開發中,解決會話跟蹤使用最多的方案就是令牌技術
。
JWT 令牌介紹
JWT 全稱:JSON Web Token
官網:https://jwt.io/
描述:JSON Web Token(JWT)
是一個開放的行業標準(RFC 7519),用于客戶端和服務器之間傳遞安全可靠的信息
。其本質是一個 token,是一種緊湊的 URL 安全方法
。
JWT 令牌組成
JWT 由三部分組成,每部分中間使用點(.)分隔
,比如:aaaaa.bbbbb.cccc
Header(頭部)
:- 頭部包括
令牌的類型(即 JWT)
及使用的哈希算法
(如HMAC SHA256
或RSA
)。
- 頭部包括
-
Payload(負載)
:負載部分是存放有效信息的地方,里面是一些自定義內容。
比如:
{"userId":"666","userName":"kunkun" }
- 也可以存在 JWT 提供的內置字段,比如
exp
(過期時間戳)等。此部分不建議存放敏感信息,因為此部分可以解碼還原原始內容。
Signature(簽名)
:- 此部分用于
防止 JWT 內容被篡改
,確保安全性。防止被篡改,而不是防止被解析
。 JWT 之所以安全,就是因為最后的簽名。JWT 當中任何一個字符被篡改,整個令牌都會校驗失敗。
- 就好比我們的身份證,之所以能標識一個人的身份,是因為它不能被篡改,而不是因為內容加密(任何人可以看到身份證的信息,JWT 也是)。
- 此部分用于
對上述部分的信息,使用 Base64Url 進行編碼,合并在一起就是 JWT 令牌
。
Base64 是編碼方式,而不是加密方式。
引入 JWT 令牌依賴
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version>
</dependency><!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.5</version><scope>runtime</scope>
</dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred --><version>0.11.5</version><scope>runtime</scope>
</dependency>
引入依賴后,接下來,我們使用 Jar 包中提供的 API 來完成 JWT 令牌的生成和校驗
生成 JWT 令牌
public class JwtTest {@Testvoid genToken(){Map<String, Object> claims = new HashMap<>();claims.put("id", 666);claims.put("name", "kunkun");// 這個 Map 表示存放到 token 中的信息, 用戶登錄 id 為 666, 用戶名為 kunkun// 設置 Jwts 令牌的載荷String compact = Jwts.builder().setClaims(claims).compact();// setClaims() 允許 Map 作為參數// compact() 可以將令牌轉換成可以被打印的信息System.out.println(compact);}
}
運行測試方法,查看運行結果:
將生成的令牌進行解碼:
接下來,我們要為該令牌設置簽名
import java.security.Key; // key 要導入的包public class JwtTest {@Testvoid genToken(){// Keys.hmacShaKeyFor() 用于生成安全密鑰, 在這里是以 "123455556" 這個字符串的 getBytes() 進行對密鑰的生成Key key = Keys.hmacShaKeyFor("123455556".getBytes(StandardCharsets.UTF_8)); // 這里設置的密鑰長度沒有達到要求, 運行程序會在這里報錯Map<String, Object> claims = new HashMap<>();claims.put("id", 666);claims.put("name", "kunkun");String compact = Jwts.builder().setClaims(claims).signWith(key).compact();// signWith() 用于設置簽名, 需要傳一個 Key 類型的參數System.out.println(compact);}
}
Keys.hmacShaKeyFor(byte[] keyMaterial)
方法的作用是根據提供的字節數組
生成一個適用于HMAC-SHA 簽名算法
(如 HS256、HS384、HS512)的密鑰對象
(Key
)。這個方法是JWT 工具類
中用于生成密鑰
的常用方法之一。
運行測試方法 genToken() ,觀察運行結果:
報錯中提到,可以考慮使用 secretKeyFor(SignatureAlgorithm)
方法來創建一個 Key
,接下來,我就來演示該方法如何使用:
public class JwtTest {@Testvoid genToken(){// Key key = Keys.hmacShaKeyFor("123455556".getBytes(StandardCharsets.UTF_8));Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);// 使用 secretKeyFor(SignatureAlgorithm) 方法來創建一個 Key, 該方法每次生成的 Key 都是隨機的Map<String, Object> claims = new HashMap<>();claims.put("id", 666);claims.put("name", "kunkun");String compact = Jwts.builder().setClaims(claims).signWith(key).compact();System.out.println(compact);}
}
運行測試方法,觀察運行結果:
輸出的內容,就是 JWT 令牌。因此,我們服務端生成令牌的方法就寫好了;
固定令牌簽名部分
每次調用 secretKeyFor()
方法,生成的密鑰是隨機的:
// 第一次調用生成的令牌
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.iDb7jpsCG-EBSVNz6Ee4kPoRA5olz3ML6fZJ4ZddVMM// 第二次調用生成的令牌
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.upxovdUjdxF4nXLFeG3rSTRH-Gkw2foz2CICN3kzWlE
觀察到兩次生成的令牌簽名部分不一致,這表明每次調用 secretKeyFor()
方法時生成的密鑰是隨機的。
為了確保服務端的安全性和一致性
,我們使用 secretKeyFor()
方法生成一個固定的密鑰,并將其作為生成令牌的簽名部分。
密鑰(Key):是用于生成和驗證 JWT 簽名的基礎數據。
在代碼中,Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
生成的是一個密鑰,用于簽名算法。簽名信息:是 JWT 的一部分,由密鑰和簽名算法生成的哈希值,用于驗證 JWT 的完整性和真實性。
前面是根據 secretKeyFor()
方法生成的密鑰為基礎,創建令牌。如果希望每次生成的 JWT 簽名一致,需要使用固定的密鑰
。
因此,我們需要先獲取公共令牌的簽名信息
。
@Test
void getKey() {Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);// 使用 HS256 算法生成一個隨機的密鑰String encodedKey = Encoders.BASE64.encode(key.getEncoded());// 將密鑰的字節數組轉換為 Base64 編碼的字符串// getEncoded():獲取密鑰的字節數組表示// Encoders.BASE64.encode():將字節數組轉換為 Base64 編碼的字符串,便于存儲和傳輸System.out.println(encodedKey);// 打印 Base64 編碼的密鑰字符串}
程序運行結果:
sYAN5HvB8HQRzX1QTEFRhseSsgXIDJsggPhC1gNLa0Y=Process finished with exit code 0
因此,我們就獲取到了公共令牌的密鑰;
使用hmacShaKeyFor()
,根據剛剛生成的密鑰字符串來創建密鑰對象:
@Test
void genToken(){// Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);Key key = Keys.hmacShaKeyFor("sYAN5HvB8HQRzX1QTEFRhseSsgXIDJsggPhC1gNLa0Y=".getBytes(StandardCharsets.UTF_8));// 使用剛剛獲取到的密鑰字符串, 來創建密鑰對象Map<String, Object> claims = new HashMap<>();claims.put("id", 666);claims.put("name", "kunkun");String compact = Jwts.builder().setClaims(claims).signWith(key).compact();// signWith(key) 表示設置令牌簽名, 會根據傳入的密鑰和密鑰算法, 轉化為簽名部分System.out.println(compact);
}
運行兩次方法,輸出的內容,就是 JWT 令牌,我們查看一下,生成令牌對應的簽名是否相同:
第一次調用:eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.kSCNNN-_b3aPZRkCaTiAlZ1Jqt5lizfxW0HtPNdcP-Y第二次調用:eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.kSCNNN-_b3aPZRkCaTiAlZ1Jqt5lizfxW0HtPNdcP-Y
此時,兩次調用 genToken()
方法生成的令牌,其中簽名的部分就被固定好了’
通過點(.
)對三個部分進行分割,我們把生成的令牌通過官網進行解析,就可以看到我們存儲的信息了。
HEADER
部分:可以看到使用的算法為HS256
。PAYLOAD
部分:是我們自定義的內容
,exp
表示過期時間
。VERIFY SIGNATURE
部分:是經過簽名算法
計算出來的,所以不會解析
。
校驗 JWT 令牌
服務端完成了令牌的生成,我們需要根據令牌,來校驗令牌的合法性(以防客戶端偽造)。
令牌解析
public class JwtTest {@Testvoid genToken(){Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);Map<String, Object> claims = new HashMap<>();claims.put("id", 666);claims.put("name", "kunkun");String compact = Jwts.builder().setClaims(claims).signWith(key).compact();System.out.println(compact);// 創建解析器,設置簽名密鑰JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();// 解析 token 并打印解析結果System.out.println(build.parse(compact).getBody());// parse() 的參數是一個字符串, 表示要解析的令牌// getBody() 表示獲取解析結果, 進而可以打印除解析的結果}
}
測試方法運行結果:
令牌解析后,我們可以看到里面存儲的信息
。如果在解析的過程中沒有報錯,就說明解析成功了。- 令牌解析時,也會進行
時間有效性的校驗
。如果令牌過期了,解析也會失敗。
令牌是可以被解析的,那么令牌是否可以被修改呢?
public class JwtTest {@Testvoid genToken(){// .....JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();// 原來的令牌也是字符串, 現在解析(原來的令牌+多余字符串)System.out.println(build.parse(compact + "kunkun666").getBody());}
}
運行測試方法,程序運行結果:
因此,修改令牌中的任何一個字符,都會校驗失敗,所以令牌無法篡改。