文章目錄
特別聲明:整理自慕課網大目師兄的微服務視頻,鏈接:https://coding.imooc.com/learn/list/358.html
1.有狀態 vs 無狀態
1.1 有狀態的做法
- 登錄是怎麼回事?
客戶端和服務器建立連接的時候,客戶端會有一個sessionId,這個sessionId是不會變的,服務端就根據這個sessionId唯一確定你是誰了,你如果做了登錄這個動作,服務端就會把你的登錄信息存在服務端,登錄信息和sessionId是一對一的關係,所以你每次請求,服務端都會找找這個sessionId有沒有對應的登錄信息,如果有,就表示登錄了,如果找不到,就沒有登錄 - 服務端用HttpSession保存登錄信息
把登錄信息放到HttpSession裏是一種很常見的做法,可以設置應用的session過期時間,如果這段時間,用戶沒有操作,HttpSession過期作廢了,用戶再來操作就找不到他的登錄信息了,所以要重新登錄
弊端: 如果啓動了多個應用實例,你說你把用戶的登錄信息保存到哪個應用好呢?,你保存到實例1裏,當用戶的請求被分配到實例2和實例3處理的時候,實例2和實例3沒有存放用戶登錄信息,就會認爲用戶沒有登錄,又讓用戶登錄,這用戶不是要蒙圈了?
解決方法:將nginx的轉發規則配置爲粘性會話,即相同ip的請求,總是轉發到同一個實例當中,這樣就不會找不到登錄信息了。但是這種做法也有弊端,如果用戶斷網重連,ip地址發生改變,把用戶請求發送到其它實例,又會發生找不到登錄信息的情況
- 用第三方存儲登錄信息
如下圖,把登錄信息存放到Session Store裏,可以是Redis,也可以是memberCache,甚至關係型數據庫也行啊,開玩笑了,當然是nosql比較好了,redis,membercache都是nosql。
評論:這樣不管用戶的請求被轉發到哪個實例,都能從第三方存儲裏找到他的登錄信息了,如果Session Store沒有到達存取瓶頸,這樣做是可以的,一般都夠用了。
弊端:
1.訪問量很大的時候,Session Store也忙不過來了,那麼這個方案就還需要改進
2.Session Store掛了,所有微服務都不能用了
3.Session Store遷移了,所有微服務都要改連接地址
1.2 無狀態的做法
含義:無狀態,就是服務端不保存用戶的登錄狀態,既不把用戶登錄信息保存在內存裏,也不把信息保存在第三方存儲。用戶做完登錄,給用戶發一個Token,用戶每次請求都帶上這個Token,放在Header裏或者請求參數裏,服務端拿到這個Token做解析,如果合法且未失效,就認爲是登錄的。Token裏可以存放一些不敏感的用戶信息,例如用戶Id,姓名之類的。不要帶手機號,這信息很敏感
圖示:
弊端:token發放出去,服務器端就無法控制這個token了,不能讓它馬上失效,不能讓這個用戶立刻處於下線狀態,毫無掌控力
1.3 優缺點對比
2.微服務的認證方案
2.1 處處安全方案
- 使用協議
OAuth 2.0,大目師兄推薦的介紹文章:http://ifeve.com/oauth2-tutorial-all/,技術性太強了,我沒看下去 - 代表實現
Spring Cloud Security、JBoss Keycloak
2.2 網關認證授權,內部裸奔
認證過程:用戶的登錄授權,Token的解析判斷,全網關裏實現。後面的微服務不再判斷這個請求有沒有登錄了
優點:邏輯簡單,性能好
缺點:後面微服務毫不設防,有風險
2.3 處處校驗Token
邏輯:網關不再關心業務,做自己的正事,過濾和轉發。Token由專門的認證中心頒發,每個微服務在被請求的時候,都要校驗Token的合法性,不用擔心影響性能,現在的算法很快,毫秒級別的
優點:微服務分工更明確,網關不插手業務
缺點:每個微服務都要校驗Token,祕鑰到處用,增加泄露風險
其實可行的方案遠不止這3種,不管你怎麼實現,反正宗旨是能判斷出用戶有沒有登錄就行了
3.無狀態的JWT (Json Web Token)
3.1 釋義
JWT 的表現形式是長長的一串字符串
- 組成
Signature = Header裏的寫的簽名算法(Base64(Header).Base64(Payload),祕鑰)
例如:HS256(“aaa.bbb”,祕鑰) - 生成JWT
Token = Base64(Header).Base64(Payload).Base64(Signature)
例如:aaa.bbb.ccc
3.2 user-center引入JWT
3.2.1 新建common項目
由於JWT每個微服務都要用到,乾脆新建個工具類項目吧,微服務們依賴這個common項目就好了。項目gitee地址:https://gitee.com/zengchen2016/common
- 新建maven項目
- pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.zc</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<dependencies>
<!--jwt相關-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
- JWTUtils類
package com.zengchen.common.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
public class JWTUtils {
/**
* 從token中獲取claim
*
* @param token token
* @param secret secret 密鑰
* @return claim
*/
public static Claims getClaimsFromToken(String token, String secret) {
try {
return Jwts.parser()
.setSigningKey(secret.getBytes())
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
throw new IllegalArgumentException("Token invalided.");
}
}
/**
* 獲取token的過期時間
*
* @param token token
* @return 過期時間
*/
public static Date getExpirationDateFromToken(String token, String secret) {
return getClaimsFromToken(token, secret)
.getExpiration();
}
/**
* 判斷token是否過期
*
* @param token token
* @return 已過期返回true,未過期返回false
*/
private static Boolean isTokenExpired(String token, String secret) {
Date expiration = getExpirationDateFromToken(token, secret);
return expiration.before(new Date());
}
/**
* 計算token的過期時間
*
* @return 過期時間
*/
private static Date getExpirationTime(Long expirationTimeInSecond) {
return new Date(System.currentTimeMillis() + expirationTimeInSecond * 1000);
}
/**
* 爲指定用戶生成token
*
* @param claims 用戶信息
* @return token
*/
public static String generateToken(Map<String, Object> claims, String secret, Long expirationTimeInSecond) {
Date createdTime = new Date();
Date expirationTime = getExpirationTime(expirationTimeInSecond);
byte[] keyBytes = secret.getBytes();
SecretKey key = Keys.hmacShaKeyFor(keyBytes);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(createdTime)
.setExpiration(expirationTime)
// 你也可以改用你喜歡的算法
// 支持的算法詳見:https://github.com/jwtk/jjwt#features
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 判斷token是否非法
*
* @param token token
* @return 未過期返回true,否則返回false
*/
public static Boolean validateToken(String token, String secret) {
try {
return !isTokenExpired(token, secret);
}catch (Exception e){
return false;
}
}
/**
* 獲取header或者payload
* @param encodedString
* @return
* @throws Exception
*/
public static String getInfo(String encodedString) throws Exception {
byte[] info = Base64Utils.decode(encodedString);
return new String(info);
}
}
- Base64Utils 類
package com.zengchen.common.utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public class Base64Utils {
/**
* <p>
* BASE64字符串解碼爲二進制數據
* </p>
*
* @param base64
* @return
* @throws Exception
*/
public static byte[] decode(String base64) throws Exception {
return Base64.getMimeDecoder().decode(base64);
}
/**
* <p>
* 二進制數據編碼爲BASE64字符串
* </p>
*
* @param bytes
* @return
* @throws Exception
*/
public static String encode(byte[] bytes) throws Exception {
return Base64.getMimeEncoder().encodeToString(bytes);
}
/**
* 將字符串進行壓縮並轉換成base64字符
*
* @param data (非空字符串)
* @return 壓縮將轉換成base64的字符串
*/
public static String zipBase64(String data) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(bos);
gzip.write(data.getBytes());
gzip.finish();
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
/**
* 將字符串進行base64解碼並進行解壓
*
* @param data 被壓縮並轉換成base64的字符串(非空)
* @return
*/
public static String base64Unzip(String data) throws IOException {
ByteArrayInputStream bis = new ByteArrayInputStream(Base64.getDecoder().decode(data));
GZIPInputStream gzip = new GZIPInputStream(bis);
byte[] buf = new byte[16384];
int num = -1;
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
while ((num = gzip.read(buf, 0, buf.length)) != -1) {
bos.write(buf, 0, num);
}
bos.flush();
return new String(bos.toByteArray());
}
}
}
- install common
3.2.2 user-center依賴common
- user-sever的pom裏添加依賴
<dependency>
<groupId>com.zc</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
3.3 測試JWT
- 生成token
祕鑰對長度有限制,太短的會報異常,這裏token失效時間是1小時
public static void main(String[] args) throws Exception {
Long expirationTimeInSecond = 3600L; // 一個小時
String secret = "aaabbbcccdddeeef1111111111111111111111111111111111111";
Map<String,Object> payloadMap = new HashMap<>();
payloadMap.put("id",1);
payloadMap.put("username","一粒塵埃");
String token = JWTUtils.generateToken(payloadMap,secret,expirationTimeInSecond);
log.info("token: "+token);
}
生成的token分成三段,以 “.” 相連
- 測試token的有效性,header,payload和失效時間
token的值是剛纔控制檯裏打印的
String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiLkuIDnspLlsJjln4MiLCJpYXQiOjE1NjcyNjI1NDUsImV4cCI6MTU2NzI2NjE0NX0.MyUgnwEc9SUapRxB7I7rM_1c4oqRnq98XNaXCEp8plU";
log.info("jwt 有效性: " + JWTUtils.validateToken(token,secret));
String header = JWTUtils.getInfo(token.split("\\.")[0]);
log.info("jwt header: " + header);
String payload = JWTUtils.getInfo(token.split("\\.")[1]);
log.info("jwt payload: " + payload);
LocalDateTime createDateTime =LocalDateTime.ofEpochSecond(1567262545,0, ZoneOffset.ofHours(8));
LocalDateTime expireDateTime =LocalDateTime.ofEpochSecond(1567266145,0, ZoneOffset.ofHours(8));
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
log.info("創建時間:"+ dtf.format(createDateTime));
log.info("失效時間:"+ dtf.format(expireDateTime));
結果,生成時間和失效時間正好間隔1個小時
4.關於請求安全,Token安全性個人的一點想法
4.1 簽名起了什麼作用?
所謂簽名就是加密,例如:https://www.xxxx.com/s?param1=a¶m2=b¶m3=c&sign=asfdljgljoixcnogn,三個參數param1,param2,param3,再加上一個sign簽名,sign就是三個參數+secretKey的加密字符串
- 作用
保證請求參數不會被篡改 - 如何保證?
請求方在發送請求之前,把請求參數按照參數名從小到大(或者從大到小)順序排列好,再加上secretKey形成一個新的字符串,然後加密,得到sign值,例如
secretKey=""123456
sign = md5(param1=a¶m2=b¶m3=c&secretKey=""123456)
服務端在收到請求之後,也按照相同的拼裝順序憑藉參數和secretKey,再次md5得到一個加密字符串,然後比較加密字符串和請求sign是否相同,相同說明請求在傳輸的過程中,參數沒有被篡改,如果對不上,不是參數被改了,就是簽名被改了,就是非法請求。
破壞分子改了我的請求參數,他沒辦法得到一個正確的sign,因爲他不知道我的secretKey,知道secretKey的只有請求方和服務方,早商量好的
4.2 一般調用接口爲什麼要加時間戳?
按理說,上節4.1的簽名機制已經可以保證參數的安全性了,爲什麼還要加上時間戳呢?
因爲破壞分子不通過修改你的請求參數了來噁心你了,他可以抓取你整個的請求數據包,原封不動的多次發送請求搞破壞。這叫重放攻擊
我看其他的文章裏說,破壞分子要完成這個操作,花費的時間要遠超過60s,這時候加上時間戳就很有必要了,時間戳也是請求參數之一,也是簽名加密的一部分,所以時間戳也不會被修改,這樣服務端就可以拿到這個請求的時間戳和當前時間做比較,如果當前時間比時間戳大60s,那說明這個請求不正常,因爲一般請求也不會從發出,到接收請求花這麼長時間的,所以超過60s的就算非法請求,時間戳的作用就體現出來了。你也可以定義成50s,40s
4.3 加了時間戳爲什麼又要加隨機數?
上節4.2通過時間戳比較來攔截請求,畢竟有個60s的空檔,這是一個60s的很大的漏洞,隨機數就可以補好這個漏洞,隨機數怎麼發揮它的作用呢?
每次發送請求,出了時間戳,再帶上一個很大的隨機數,例如0~10000000,請求一旦發送出去,破壞分子也沒法修改這個隨機數,隨機數也是簽名加密的一部分,如果改了,簽名就對不上
服務端收到請求之後,先判斷時間戳有沒有大於60s,大於肯定就拒絕,如果時間戳沒問題,再查一下Redis裏有沒有sign這個簽名,如果沒有就把sign存放到Redis中,超時時間設置爲60s,和時間戳的臨界值保持一致,並且給這次請求正常的返回。如果查到Redis已存在sign這個簽名,說明這個請求已經在60s內請求一次了,屬於非法請求
整個第4節都是個人所思所想,不能作爲準則。App,小程序之類的客戶端還好,畢竟代碼不可見,瀏覽器端就不行了,js是公開的啊,怎麼保密secretKey?而且我覺得只需要對需要登錄操作的請求進行簽名驗證就可以了,公開接口做這個沒有意義