您好,登錄后才能下訂單哦!
[TOC]
幾乎絕大部分的應用都需要實現認證與授權,例如用戶使用賬戶密碼登錄就是一個認證過程,認證登錄成功后系統才會允許用戶訪問其賬戶下的相關資源,這就是所謂的授權。而復雜點的情況就是用戶會有角色概念,每個角色所擁有的權限不同,給用戶賦予某個角色的過程也是一個授權過程。
用戶的登錄態在服務器端分為有狀態和無狀態兩種模式,在單體分布式架構的時代,我們為了能讓Session信息在多個Tomcat實例之間共享,通常的解決方案是將Session存儲至一個緩存數據庫中。即下圖中的Session Store,這個Session Store可以是Redis也可以是MemCache,這種模式就是有狀態的:
之所以說是有狀態,是因為服務端需要維護、存儲這個Session信息,即用戶的登錄態實際是在服務端維護的,所以對服務端來說可以隨時得知用戶的登錄態,并且對用戶的Session有比較高的控制權。有狀態模式的缺點主要是在于這個Session Store上,如果作為Session Store的服務只有一個節點的話,當業務擴展、用戶量增多時就會有性能瓶頸問題,而且數據遷移也比較麻煩。當然也可以選擇去增加節點,只不過就需要投入相應的機器成本了。
另一種無狀態模式,指的是服務器端不去記錄用戶的登錄狀態,也就是服務器端不再去維護一個Session。而是在用戶登錄成功的時候,頒發一個token給客戶端,之后客戶端的每個請求都需要攜帶token。服務端會對客戶端請求時所攜帶的token進行解密,校驗token是否合法以及是否已過期等等。token校驗成功后則認為用戶是具有登錄態的,否則認為用戶未登錄:
注:token通常會存儲用戶的唯一ID,解密token就是為了獲取用戶ID然后去緩存或者數據庫中查詢用戶數據。當然也可以選擇將用戶數據都保存在token中,只不過這種方式可能會有安全問題或數據一致性問題
無狀態模式下的token其實和有狀態模式下的session作用是類似的,都是判斷用戶是否具有登錄態的一個憑證。只不過在無狀態模式下,服務器端不需要再去維護、存儲一個Session,只需要對客戶端攜帶的token進行解密和校驗。也就是說存儲實際是交給了客戶端完成,所以無狀態的優點恰恰就是彌補了有狀態的缺點。但是無狀態的缺點也很明顯,因為一旦把token交給客戶端后,服務端就無法去控制這個token了。例如想要強制下線某個用戶在無狀態的模式下就比較難以實現。
有狀態與無狀態各有優缺點,只不過目前業界趨勢更傾向于無狀態:
優缺點 | 有狀態 | 無狀態 |
---|---|---|
優點 | 服務端控制能力強 | 去中心化,無存儲,簡單,任意擴容、縮容 |
缺點 | 存在中心點,雞蛋放在一個籃子里,遷移麻煩。服務端存儲數據,加大了服務端壓力 | 服務端控制能力相對弱 |
微服務認證方案有很多種,需要根據實際的業務需求定制適合自己業務的方案,這里簡單列舉一下業界內常用的微服務認證方案。
1、“處處安全” 方案:
所謂“處處安全” 方案,就是考慮了微服務認證中的方方面面,這種方案主流是使用OAuth3協議進行實現。這種方案的優點是安全性好,但是實現的成本及復雜性比較高。另外,多個微服務之間互相調用需要傳遞token,所以會發生多次認證,有一定的性能開銷
OAuth3的代表實現框架:
參考文章:
2、外部無狀態,內部有狀態方案:
這種方案雖然看著有些奇葩,但是也許多公司在使用。在該方案下,網關不存儲Session,而是接收一個token和JSESSIONID,網關僅對token進行解密、校驗,然后將JSESSIONID轉發到其代理的微服務上,這些微服務則是通過JSESSIONID從Session Store獲取共享Session。如下圖:
這種方案主要是出現在內部有舊的系統架構的情況,在不重構或者沒法全部重構的前提下為了兼容舊的系統,就可以采用該方案。而且也可以將新舊系統分為兩塊,網關將token和JSESSIONID一并轉發到下游服務,這樣無狀態模式的系統則使用token,有狀態模式的系統則使用Session,然后再慢慢地將舊服務進行重構以此實現一個平滑過渡。如下圖:
3、“網關認證授權,內部裸奔” 方案:
在該方案下,認證授權在網關完成,下游的微服務不需要進行認證授權。網關接收到客戶端請求所攜帶的token后,對該token進行解密和校驗,然后將解密出來的用戶信息轉發給下游微服務。這種方案的優點是實現簡單、性能也好,缺點是一旦網關被攻破,或者能越過網關訪問微服務就會有安全問題。如下圖:
4、“內部裸奔” 改進方案:
上一個方案的缺陷比較明顯,我們可以對該方案進行一些改進,例如引入一個認證授權中心服務,讓網關不再做認證和授權以及token的解密和解析。用戶的登錄請求通過網關轉發到認證授權中心完成登錄,登錄成功后由認證授權中心頒發token給客戶端。客戶端每次請求都攜帶token,而每個微服務都需要對token進行解密和解析,以確定用戶的登錄態。改進之后所帶來的好處就是網關不再關注業務,而是單純的請求做轉發,可以在一定程度上解耦業務,并且也更加安全,因為每個微服務不再裸奔而是都需要驗證請求中所攜帶的token。如下圖:
5、方案的對比與選擇:
以上所提到的常見方案只是用于拋磚引玉,沒有哪個方案是絕對普適的。而且實際開發中通常會根據業務改進、組合這些方案演變出不同的變種,所以應該要學會活學活用而不是局限于某一種方案。下面簡單整理了一下這幾種方案,以便做對比:
6、訪問控制模型
了解了常見的微服務認證方案后,我們來簡單看下訪問控制模型。所謂訪問控制,就是用戶需要滿足怎么樣的條件才允許訪問某個系統資源,即控制系統資源的訪問權限。訪問控制模型主要有以下幾種:
Access Control List(ACL,訪問控制列表):
在該模型下的一個系統資源會包含一組權限列表,該列表規定了哪些用戶擁有哪些操作權限。例如有一個系統資源包含的權限列表為:
[Alice: read, write; Bob: read]
;那么就表示Alice這個用戶對該資源擁有read和write權限,而Bob這個用戶則對該資源擁有read權限。該模型通常用于文件系統
Role-based access control(RBAC,基于角色的訪問控制):
即用戶需關聯一個預先定義的角色,而不同的角色擁有各自的權限列表。用戶登錄后只需要查詢其關聯的角色就能查出該用戶擁有哪些權限。例如用戶A關聯了一個名為觀察者的角色,該角色下包含接口A和接口B的訪問權限,那么就表示用戶A僅能夠訪問A和接口B。該模型在業務系統中使用得最多
Attribute-based access control(ABAC,基于屬性的訪問控制):
在該模型下,用戶在訪問某個系統資源時會攜帶一組屬性值包括自身屬性、主題屬性、資源屬性以及環境屬性等。然后系統通過動態計算用戶所攜帶的屬性來判斷是否滿足具有訪問某個資源的權限。屬性通常來說分為四類:用戶屬性(如用戶年齡),環境屬性(如當前時間),操作屬性(如讀取)以及對象屬性等。
為了能讓系統進行權限控制,在該模型下需要以特定的格式定義權限規則,例如:IF 用戶是管理員; THEN 允許對敏感數據進行讀/寫操作。在這條規則中“管理員”是用戶的角色屬性,而“讀/寫”是操作屬性,”敏感數據“則是對象屬性。
ABAC有時也被稱為PBAC(Policy-Based Access Control,基于策略的訪問控制)或CBAC(Claims-Based Access Control,基于聲明的訪問控制)。該模型由于比較復雜,使用得不多,k8s也因為ABAC太復雜而在1.8版本改為使用RBAC模型
Rules-based access control(RBAC,基于規則的訪問控制):
在該模型下通過對某個系統資源事先定義一組訪問規則來實現訪問控制,這些規則可以是參數、時間、用戶信息等。例如:只允許從特定的IP地址訪問或拒絕從特定的IP地址訪問
該模型是在ACL的基礎上添加了時間的概念,可以設置ACL權限在特定的時間才生效。例如:只允許某個系統資源在工作日時間內才能被外部訪問,那么就可以將該資源的ACL權限的有效時間設置為工作日時間內
之前提到過無狀態模式下,服務器端需要生成一個Token頒發給客戶端,而目前主流的方式就是使用JWT的標準來生成Token,所以本小節我們來簡單了解下JWT及其使用。
JWT簡介:
JWT是JSON Web Token的縮寫,JWT實際是一個開放標準(RFC 7519),用來在各方之間安全地傳輸信息,是目前最流行的跨域認證解決方案。JWT可以被驗證和信任,因為它是數字簽名的。官網:https://jwt.io/
JWT的組成結構:
組成 | 作用 | 內容示例 |
---|---|---|
Header(頭) | 記錄Token類型、簽名的算法等 | {"alg": "HS256", "type": "JWT"} |
Payload(有效載荷) | 攜帶一些用戶信息及Token的過期時間等 | {"user_id": "1", "iat": 1566284273, "exp": 1567493873} |
Signature(簽名) | 簽名算法生成的數字簽名,用于防止Token被篡改、確保Token的安全性 | WV5Hhymti3OgIjPprLJKJv3aY473vyxMLeM8c7JLxSk |
JWT生成Token的公式:
Token = Base64(Header).Base64(Payload).Base64(Signature)
示例:eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjYyODIyMjMsImV4cCI6MTU2NzQ5MTgyM30.OtCOFqWMS6ZOzmwCs7NC7hs9u043P-09KbQfZBov97E
簽名是使用Header里指定的簽名算法生成的,公式如下:
Signature = 簽名算法((Base64(Header).Base64(Payload), 秘鑰))
使用JWT:
1、目前Java語言有好幾個操作JWT的第三方庫,這里采用其中較為輕巧的jjwt作為演示。首先添加依賴如下:
<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>
2、編寫一個工具類,將JWT的操作都抽取出來,方便在項目中的使用。具體代碼如下:
package com.zj.node.usercenter.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
/**
* JWT 工具類
*
* @author 01
* @date 2019-08-20
**/
@Slf4j
@Component
@RequiredArgsConstructor
@SuppressWarnings("WeakerAccess")
public class JwtOperator {
/**
* 秘鑰
* - 默認5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ
*/
@Value("${jwt.secret:5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ}")
private String secret;
/**
* 有效期,單位秒
* - 默認2周
*/
@Value("${jwt.expire-time-in-second:1209600}")
private Long expirationTimeInSecond;
/**
* 從token中獲取claim
*
* @param token token
* @return claim
*/
public Claims getClaimsFromToken(String token) {
try {
return Jwts.parser()
.setSigningKey(this.secret.getBytes())
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException | UnsupportedJwtException |
MalformedJwtException | IllegalArgumentException e) {
log.error("token解析錯誤", e);
throw new IllegalArgumentException("Token invalided.");
}
}
/**
* 獲取token的過期時間
*
* @param token token
* @return 過期時間
*/
public Date getExpirationDateFromToken(String token) {
return getClaimsFromToken(token)
.getExpiration();
}
/**
* 判斷token是否過期
*
* @param token token
* @return 已過期返回true,未過期返回false
*/
private Boolean isTokenExpired(String token) {
Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
/**
* 計算token的過期時間
*
* @return 過期時間
*/
private Date getExpirationTime() {
return new Date(System.currentTimeMillis() + this.expirationTimeInSecond * 1000);
}
/**
* 為指定用戶生成token
*
* @param claims 用戶信息
* @return token
*/
public String generateToken(Map<String, Object> claims) {
Date createdTime = new Date();
Date expirationTime = this.getExpirationTime();
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 Boolean validateToken(String token) {
return !isTokenExpired(token);
}
}
3、若默認的配置不符合需求,可以通過在配置文件中添加如下配置進行自定義:
jwt:
# 秘鑰
secret: 5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ
# jwt有效期,單位秒
expire-time-in-second: 1209600
4、完成以上步驟后,就可以在項目中使用JWT了,這里提供了一個比較全面的測試用例,可以參考測試用例來使用該工具類。代碼如下:
package com.zj.node.usercenter.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.security.SignatureException;
import org.apache.tomcat.util.codec.binary.Base64;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.HashMap;
import java.util.Map;
/**
* JwtOperator 測試用例
*
* @author 01
* @date 2019-08-20
**/
@SpringBootTest
@RunWith(SpringRunner.class)
public class JwtOperatorTests {
@Autowired
private JwtOperator jwtOperator;
private String token = "";
@Before
public void generateTokenTest() {
// 設置用戶信息
Map<String, Object> objectObjectHashMap = new HashMap<>();
objectObjectHashMap.put("id", "1");
// 測試1: 生成token
this.token = jwtOperator.generateToken(objectObjectHashMap);
// 會生成類似該字符串的內容: eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjU1ODk4MTcsImV4cCI6MTU2Njc5OTQxN30.27_QgdtTg4SUgxidW6ALHFsZPgMtjCQ4ZYTRmZroKCQ
System.out.println(this.token);
}
@Test
public void validateTokenTest() {
// 測試2: 如果能token合法且未過期,返回true
Boolean validateToken = jwtOperator.validateToken(this.token);
System.out.println("token校驗結果:" + validateToken);
}
@Test
public void getClaimsFromTokenTest() {
// 測試3: 解密token,獲取用戶信息
Claims claims = jwtOperator.getClaimsFromToken(this.token);
System.out.println(claims);
}
@Test
public void decodeHeaderTest() {
// 獲取Header,即token的第一段(以.為邊界)
String[] split = this.token.split("\\.");
String encodedHeader = split[0];
// 測試4: 解密Header
byte[] header = Base64.decodeBase64(encodedHeader.getBytes());
System.out.println(new String(header));
}
@Test
public void decodePayloadTest() {
// 獲取Payload,即token的第二段(以.為邊界)
String[] split = this.token.split("\\.");
String encodedPayload = split[1];
// 測試5: 解密Payload
byte[] payload = Base64.decodeBase64(encodedPayload.getBytes());
System.out.println(new String(payload));
}
@Test(expected = SignatureException.class)
public void validateErrorTokenTest() {
try {
// 測試6: 篡改原本的token,因此會報異常,說明JWT是安全的
jwtOperator.validateToken(this.token + "xx");
} catch (SignatureException e) {
e.printStackTrace();
throw e;
}
}
}
若希望了解各類的JWT庫,可以參考如下文章:
了解了JWT后,我們來使用JWT實現一個認證授權Demo,首先定義一個DTO,其結構如下:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRespDTO {
/**
* 昵稱
*/
private String userName;
/**
* token
*/
private String token;
/**
* 過期時間
*/
private Long expirationTime;
}
然后編寫Service,提供模擬登錄和模擬檢查用戶登錄態的方法。具體代碼如下:
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final JwtOperator jwtOperator;
/**
* 模擬用戶登錄
*/
public LoginRespDTO login(String userName, String password) {
String defPassword = "123456";
if (!defPassword.equals(password)) {
return null;
}
// 密碼驗證通過頒發token
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("userName", userName);
String token = jwtOperator.generateToken(userInfo);
return LoginRespDTO.builder()
.userName(userName)
.token(token)
.expirationTime(jwtOperator.getExpirationDateFromToken(token).getTime())
.build();
}
/**
* 模擬登錄態驗證
*/
public String checkLoginState(String token) {
if (jwtOperator.validateToken(token)) {
Claims claims = jwtOperator.getClaimsFromToken(token);
String userName = claims.get("userName").toString();
return String.format("用戶 %s 的登錄態驗證通過,允許訪問", userName);
}
return "登錄態驗證失敗,token無效或過期";
}
}
接著是Controller層,開放相應的Web接口。代碼如下:
@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/login")
public LoginRespDTO login(@RequestParam("userName") String userName,
@RequestParam("password") String password) {
return userService.login(userName, password);
}
@GetMapping("/checkLoginState")
public String checkLoginState(@RequestParam("token") String token) {
return userService.checkLoginState(token);
}
}
用戶登錄成功,返回Token和用戶基本信息:
校驗登錄態:
Tips:
本小節只是給出了一個極簡的例子,目的是演示如何使用JWT實現用戶登錄成功后頒發Token給客戶端以及通過Token驗證用戶的登錄態,這樣大家完全可以通過之前提到過的方案進行拓展。通常來說Token頒發給客戶端后,客戶端在后續的請求中是將Token放在HTTP Header里進行傳遞的,而不是示例中的參數傳遞。微服務之間的Token傳遞也是如此,一個微服務在向另一個微服務發請求之前,需要先將Token放進本次請求的HTTP Header里。另外,驗證Token的邏輯一般是放在一個全局的過濾器或者攔截器中,這樣就不需要每個接口都寫一遍驗證邏輯。
后續:
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。