JWT單點登錄
什麼是JWT?
JWT,全稱是Json Web Token, 是JSON風格輕量級的授權和身份認證規範,可實現無狀態、分佈式的Web應用授權;官網:https://jwt.io
GitHub上jwt的java客戶端:https://github.com/jwtk/jjwt
兩種登錄狀態
有狀態登錄
爲了保證客戶端cookie的安全性,服務端需要記錄每次會話的客戶端信息,從而識別客戶端身份,根據用戶身份進行請求的處理,典型的設計如tomcat中的session。
例如登錄:用戶登錄後,我們把登錄者的信息保存在服務端session中,並且給用戶一個cookie值,記錄對應的session。然後下次請求,用戶攜帶cookie值來,我們就能識別到對應session,從而找到用戶的信息。
缺點是什麼?
- 服務端保存大量數據,增加服務端壓力
- 服務端保存用戶狀態,無法進行水平擴展
- 客戶端請求依賴服務端,多次請求必須訪問同一臺服務器
即使使用redis保存用戶的信息,也會損耗服務器資源。
無狀態登錄
微服務集羣中的每個服務,對外提供的都是Rest風格的接口。而Rest風格的一個最重要的規範就是:服務的無狀態性,即:
- 服務端不保存任何客戶端請求者信息
- 客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份
帶來的好處是什麼呢?
- 客戶端請求不依賴服務端的信息,任何多次請求不需要必須訪問到同一臺服務
- 服務端的集羣和狀態對客戶端透明
- 服務端可以任意的遷移和伸縮
- 減小服務端存儲壓力
無狀態登錄流程
無狀態登錄的流程:
- 當客戶端第一次請求服務時,服務端對用戶進行信息認證(登錄)
- 認證通過,將用戶信息進行加密形成token,返回給客戶端,作爲登錄憑證
- 以後每次請求,客戶端都攜帶認證的token
- 服務的對token進行解密,判斷是否有效。
流程圖:
整個登錄過程中,最關鍵的點是什麼?
token的安全性
token是識別客戶端身份的唯一標示,如果加密不夠嚴密,被人僞造那就完蛋了。
採用何種方式加密纔是安全可靠的呢?
我們將採用JWT + RSA非對稱加密
jwt實現無狀態登錄
JWT,全稱是Json Web Token, 是JSON風格輕量級的授權和身份認證規範,可實現無狀態、分佈式的Web應用授權;官網:https://jwt.io
GitHub上jwt的java客戶端:https://github.com/jwtk/jjwt
數據格式
JWT包含三部分數據:
-
Header:頭部,通常頭部有兩部分信息:
- token類型:JWT
- 加密方式:base64(HS256)
-
Payload:載荷,就是有效數據,一般包含下面信息:
- 用戶身份信息(注意,這裏因爲採用base64編碼,可解碼,因此不要存放敏感信息)
- 註冊聲明:如token的簽發時間,過期時間,簽發人等
這部分也會採用base64編碼,得到第二部分數據
-
Signature:簽名,是整個數據的認證信息。根據前兩步的數據,再加上指定的密鑰(secret)(不要泄漏,最好週期性更換),通過base64編碼生成。用於驗證整個數據完整和可靠性
JWT交互流程
流程圖:
步驟翻譯:
- 1、用戶登錄
- 2、服務的認證,通過後根據secret生成token
- 3、將生成的token返回給瀏覽器
- 4、用戶每次請求攜帶token
- 5、服務端利用公鑰解讀jwt簽名,判斷簽名有效後,從Payload中獲取用戶信息
- 6、處理請求,返回響應結果
因爲JWT簽發的token中已經包含了用戶的身份信息,並且每次請求都會攜帶,這樣服務的就無需保存用戶信息,甚至無需去數據庫查詢,完全符合了Rest的無狀態規範。
非對稱加密
加密技術是對信息進行編碼和解碼的技術,編碼是把原來可讀信息(又稱明文)譯成代碼形式(又稱密文),其逆過程就是解碼(解密),加密技術的要點是加密算法,加密算法可以分爲三類:
- 對稱加密,如AES
- 基本原理:將明文分成N個組,然後使用密鑰對各個組進行加密,形成各自的密文,最後把所有的分組密文進行合併,形成最終的密文。
- 優勢:算法公開、計算量小、加密速度快、加密效率高
- 缺陷:雙方都使用同樣密鑰,安全性得不到保證
- 非對稱加密,如RSA
- 基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱祕保存,公鑰可以下發給信任客戶端
- 私鑰加密,持有公鑰纔可以解密
- 公鑰加密,持有私鑰纔可解密
- 優點:安全,難以破解
- 缺點:算法比較耗時
- 基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱祕保存,公鑰可以下發給信任客戶端
- 不可逆加密,如MD5,SHA
- 基本原理:加密過程中不需要使用密鑰,輸入明文後由系統直接經過加密算法處理成密文,這種加密後的數據是無法被解密的,無法根據密文推算出明文。
RSA算法歷史:
1977年,三位數學家Rivest、Shamir 和 Adleman 設計了一種算法,可以實現非對稱加密。這種算法用他們三個人的名字縮寫:RSA
代碼實現
本次示例採用SpringBoot工程搭建
核心依賴
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.18</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.3</version>
</dependency>
核心工具類
如果需要使用JWT單點登錄,RAS工具類和JWT工具類是必不可少的
JWT工具類
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.joda.time.DateTime;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Map;
public class JwtUtils {
/**
* 私鑰加密token
*
* @param map 載荷中的數據
* @param expireMinutes 過期時間,單位秒
* @return
* @throws Exception
*/
public static String generateToken(Map<String, Object> map, PrivateKey key, int expireMinutes) throws Exception {
return Jwts.builder()
.setClaims(map)
.setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
.signWith(key, SignatureAlgorithm.RS256)
.compact();
}
/**
* 公鑰解析token
*
* @param token 用戶請求中的token
* @return
* @throws Exception
*/
private static Jws<Claims> parserToken(String token, PublicKey key) {
return Jwts.parser().setSigningKey(key).parseClaimsJws(token);
}
/**
* 獲取token中的用戶信息
*
* @param token 用戶請求中的令牌
* @return 用戶信息
* @throws Exception
*/
public static Map<String, Object> getInfoFromToken(String token, PublicKey key) throws Exception {
Jws<Claims> claimsJws = parserToken(token, key);
return claimsJws.getBody();
}
}
RSA工具類
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
public class RsaUtils {
/**
* 從文件中讀取公鑰
*
* @param filename 公鑰保存路徑,相對於classpath
* @return 公鑰對象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 從文件中讀取密鑰
*
* @param filename 私鑰保存路徑,相對於classpath
* @return 私鑰對象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 獲取公鑰
*
* @param bytes 公鑰的字節形式
* @return
* @throws Exception
*/
public static PublicKey getPublicKey(byte[] bytes) throws Exception {
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 獲取密鑰
*
* @param bytes 私鑰的字節形式
* @return
* @throws Exception
*/
public static PrivateKey getPrivateKey(byte[] bytes) throws Exception {
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根據密文,生存rsa公鑰和私鑰,並寫入指定文件
*
* @param publicKeyFilename 公鑰文件路徑
* @param privateKeyFilename 私鑰文件路徑
* @param secret 生成密鑰的密文
* @throws IOException
* @throws NoSuchAlgorithmException
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(2048, secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 獲取公鑰並寫出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
writeFile(publicKeyFilename, publicKeyBytes);
// 獲取私鑰並寫出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
writeFile(privateKeyFilename, privateKeyBytes);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
測試
公鑰和私鑰的區別,其實不用太過於糾結,我們可以理解爲:
- 私鑰屬於私密,用於生成token,返回加密數據給前端
- 公鑰屬於公文,用於解析token,返回用戶數據給前端
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.HashMap;
import java.util.Map;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = JwtDemoApplication.class)
public class JwtDemoApplicationTests {
// 公鑰文件生成地址
private static final String PUB_KEY_PATH = "D:\\IT notes\\jwt\\tool\\rsa.pub";
// 私鑰文件生成地址
private static final String PRI_KEY_PATH = "D:\\IT notes\\jwt\\tool\\rsa.pri";
private PublicKey publicKey;
private PrivateKey privateKey;
// 生成公鑰和私鑰
@Test
public void testRsa() throws Exception {
RsaUtils.generateKey(PUB_KEY_PATH, PRI_KEY_PATH, "234");
}
// 先生成 再獲取 生成之前把@Before註釋掉!
// 獲取公鑰和私鑰
@Before
public void testGetRsa() throws Exception {
this.publicKey = RsaUtils.getPublicKey(PUB_KEY_PATH);
this.privateKey = RsaUtils.getPrivateKey(PRI_KEY_PATH);
}
// 生成token
@Test
public void testGenerateToken() throws Exception {
Map<String, Object> map = new HashMap<>();
map.put("id", "11");
map.put("username", "liuyan");
// 生成token
String token = JwtUtils.generateToken(map, privateKey, 1);
System.out.println("token = " + token);
}
// 解析token
@Test
public void testParseToken() throws Exception {
String token = "eyJhbGciOiJSUzI1NiJ9.eyJpZCI6IjExIiwidXNlcm5hbWUiOiJsaXV5YW4iLCJleHAiOjE1ODc2MjYwODZ9.kO22BSWogDJPD6oG92dfFukothGMJej3FKLIfDJRVjGiF_O7kLPSmycmuyByy8wd7X_nOVDCPoMvvhoUzviDsgzIC0xiILoMobwyUDtbYdStCfiLVikqHmnf0Our5tuxwVaPOK2igoWW3zRRI7HG5RLh0p2pUAQe1C-is_8zczn2T5CQ-7vEwPS6U5FLn7_1y8rHNVsKlHqNBdSDxQn7jLOkHkKnRiShZ2_iBuXTzo6uZt2461IV8qk6Lmn35fyX7JHwHIVvuQyniFEsdYNW5t8P3Eo1UEbL3ZD5ZbhcIsK5gnvpXdsne6uK1jHQzClQi-hcGONuHXpS2IkueWEizg";
// 解析token
Map<String, Object> map = JwtUtils.getInfoFromToken(token, publicKey);
System.out.println("id: " + map.get("id"));
System.out.println("userName: " + map.get("username"));
}
}