微服務的用戶認證與受權雜談(上)

[TOC]html


有狀態 VS 無狀態

幾乎絕大部分的應用都須要實現認證與受權,例如用戶使用帳戶密碼登陸就是一個認證過程,認證登陸成功後系統纔會容許用戶訪問其帳戶下的相關資源,這就是所謂的受權。而複雜點的狀況就是用戶會有角色概念,每一個角色所擁有的權限不一樣,給用戶賦予某個角色的過程也是一個受權過程。java

用戶的登陸態在服務器端分爲有狀態和無狀態兩種模式,在單體分佈式架構的時代,咱們爲了能讓Session信息在多個Tomcat實例之間共享,一般的解決方案是將Session存儲至一個緩存數據庫中。即下圖中的Session Store,這個Session Store能夠是Redis也能夠是MemCache,這種模式就是有狀態的:
微服務的用戶認證與受權雜談(上)node

之因此說是有狀態,是由於服務端須要維護、存儲這個Session信息,即用戶的登陸態實際是在服務端維護的,因此對服務端來講能夠隨時得知用戶的登陸態,而且對用戶的Session有比較高的控制權。有狀態模式的缺點主要是在於這個Session Store上,若是做爲Session Store的服務只有一個節點的話,當業務擴展、用戶量增多時就會有性能瓶頸問題,並且數據遷移也比較麻煩。固然也能夠選擇去增長節點,只不過就須要投入相應的機器成本了。git

另外一種無狀態模式,指的是服務器端不去記錄用戶的登陸狀態,也就是服務器端再也不去維護一個Session。而是在用戶登陸成功的時候,頒發一個token給客戶端,以後客戶端的每一個請求都須要攜帶token。服務端會對客戶端請求時所攜帶的token進行解密,校驗token是否合法以及是否已過時等等。token校驗成功後則認爲用戶是具備登陸態的,不然認爲用戶未登陸:
微服務的用戶認證與受權雜談(上)github

注:token一般會存儲用戶的惟一ID,解密token就是爲了獲取用戶ID而後去緩存或者數據庫中查詢用戶數據。固然也能夠選擇將用戶數據都保存在token中,只不過這種方式可能會有安全問題或數據一致性問題web

無狀態模式下的token其實和有狀態模式下的session做用是相似的,都是判斷用戶是否具備登陸態的一個憑證。只不過在無狀態模式下,服務器端不須要再去維護、存儲一個Session,只須要對客戶端攜帶的token進行解密和校驗。也就是說存儲實際是交給了客戶端完成,因此無狀態的優勢偏偏就是彌補了有狀態的缺點。可是無狀態的缺點也很明顯,由於一旦把token交給客戶端後,服務端就沒法去控制這個token了。例如想要強制下線某個用戶在無狀態的模式下就比較難以實現。算法

有狀態與無狀態各有優缺點,只不過目前業界趨勢更傾向於無狀態:spring

優缺點 有狀態 無狀態
優勢 服務端控制能力強 去中心化,無存儲,簡單,任意擴容、縮容
缺點 存在中心點,雞蛋放在一個籃子裏,遷移麻煩。服務端存儲數據,加大了服務端壓力 服務端控制能力相對弱

微服務認證方案

微服務認證方案有不少種,須要根據實際的業務需求定製適合本身業務的方案,這裏簡單列舉一下業界內經常使用的微服務認證方案。數據庫

一、「到處安全」 方案:apache

所謂「到處安全」 方案,就是考慮了微服務認證中的方方面面,這種方案主流是使用OAuth2協議進行實現。這種方案的優勢是安全性好,可是實現的成本及複雜性比較高。另外,多個微服務之間互相調用須要傳遞token,因此會發生屢次認證,有必定的性能開銷

OAuth2的表明實現框架:

參考文章:

二、外部無狀態,內部有狀態方案:

這種方案雖然看着有些奇葩,可是也許多公司在使用。在該方案下,網關不存儲Session,而是接收一個token和JSESSIONID,網關僅對token進行解密、校驗,而後將JSESSIONID轉發到其代理的微服務上,這些微服務則是經過JSESSIONID從Session Store獲取共享Session。以下圖:
微服務的用戶認證與受權雜談(上)

這種方案主要是出如今內部有舊的系統架構的狀況,在不重構或者無法所有重構的前提下爲了兼容舊的系統,就能夠採用該方案。並且也能夠將新舊系統分爲兩塊,網關將token和JSESSIONID一併轉發到下游服務,這樣無狀態模式的系統則使用token,有狀態模式的系統則使用Session,而後再慢慢地將舊服務進行重構以此實現一個平滑過渡。以下圖:
微服務的用戶認證與受權雜談(上)

三、「網關認證受權,內部裸奔」 方案:

在該方案下,認證受權在網關完成,下游的微服務不須要進行認證受權。網關接收到客戶端請求所攜帶的token後,對該token進行解密和校驗,而後將解密出來的用戶信息轉發給下游微服務。這種方案的優勢是實現簡單、性能也好,缺點是一旦網關被攻破,或者能越過網關訪問微服務就會有安全問題。以下圖:
微服務的用戶認證與受權雜談(上)

四、「內部裸奔」 改進方案:

上一個方案的缺陷比較明顯,咱們能夠對該方案進行一些改進,例如引入一個認證受權中心服務,讓網關再也不作認證和受權以及token的解密和解析。用戶的登陸請求經過網關轉發到認證受權中心完成登陸,登陸成功後由認證受權中心頒發token給客戶端。客戶端每次請求都攜帶token,而每一個微服務都須要對token進行解密和解析,以肯定用戶的登陸態。改進以後所帶來的好處就是網關再也不關注業務,而是單純的請求作轉發,能夠在必定程度上解耦業務,而且也更加安全,由於每一個微服務再也不裸奔而是都須要驗證請求中所攜帶的token。以下圖:
微服務的用戶認證與受權雜談(上)

五、方案的對比與選擇:

以上所提到的常見方案只是用於拋磚引玉,沒有哪一個方案是絕對普適的。並且實際開發中一般會根據業務改進、組合這些方案演變出不一樣的變種,因此應該要學會活學活用而不是侷限於某一種方案。下面簡單整理了一下這幾種方案,以便作對比:
微服務的用戶認證與受權雜談(上)

六、訪問控制模型

瞭解了常見的微服務認證方案後,咱們來簡單看下訪問控制模型。所謂訪問控制,就是用戶須要知足怎麼樣的條件才容許訪問某個系統資源,即控制系統資源的訪問權限。訪問控制模型主要有如下幾種:

  1. Access Control List(ACL,訪問控制列表):

    在該模型下的一個系統資源會包含一組權限列表,該列表規定了哪些用戶擁有哪些操做權限。例若有一個系統資源包含的權限列表爲:[Alice: read, write; Bob: read];那麼就表示Alice這個用戶對該資源擁有read和write權限,而Bob這個用戶則對該資源擁有read權限。該模型一般用於文件系統

  2. Role-based access control(RBAC,基於角色的訪問控制):

    即用戶需關聯一個預先定義的角色,而不一樣的角色擁有各自的權限列表。用戶登陸後只須要查詢其關聯的角色就能查出該用戶擁有哪些權限。例如用戶A關聯了一個名爲觀察者的角色,該角色下包含接口A和接口B的訪問權限,那麼就表示用戶A僅可以訪問A和接口B。該模型在業務系統中使用得最多

  3. Attribute-based access control(ABAC,基於屬性的訪問控制):

    在該模型下,用戶在訪問某個系統資源時會攜帶一組屬性值包括自身屬性、主題屬性、資源屬性以及環境屬性等。而後系統經過動態計算用戶所攜帶的屬性來判斷是否知足具備訪問某個資源的權限。屬性一般來講分爲四類:用戶屬性(如用戶年齡),環境屬性(如當前時間),操做屬性(如讀取)以及對象屬性等。

    爲了能讓系統進行權限控制,在該模型下須要以特定的格式定義權限規則,例如:IF 用戶是管理員; THEN 容許對敏感數據進行讀/寫操做。在這條規則中「管理員」是用戶的角色屬性,而「讀/寫」是操做屬性,」敏感數據「則是對象屬性。

    ABAC有時也被稱爲PBAC(Policy-Based Access Control,基於策略的訪問控制)或CBAC(Claims-Based Access Control,基於聲明的訪問控制)。該模型因爲比較複雜,使用得很少,k8s也由於ABAC太複雜而在1.8版本改成使用RBAC模型

  4. Rules-based access control(RBAC,基於規則的訪問控制):

    在該模型下經過對某個系統資源事先定義一組訪問規則來實現訪問控制,這些規則能夠是參數、時間、用戶信息等。例如:只容許從特定的IP地址訪問或拒絕從特定的IP地址訪問

  5. Time-based access control list(TBACL,基於時間的訪問控制列表):

    該模型是在ACL的基礎上添加了時間的概念,能夠設置ACL權限在特定的時間才生效。例如:只容許某個系統資源在工做日時間內才能被外部訪問,那麼就能夠將該資源的ACL權限的有效時間設置爲工做日時間內


JWT

以前提到過無狀態模式下,服務器端須要生成一個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:

一、目前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>

二、編寫一個工具類,將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);
    }
}

三、若默認的配置不符合需求,能夠經過在配置文件中添加以下配置進行自定義:

jwt:
  # 祕鑰
  secret: 5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ
  # jwt有效期,單位秒
  expire-time-in-second: 1209600

四、完成以上步驟後,就能夠在項目中使用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後,咱們來使用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的邏輯通常是放在一個全局的過濾器或者攔截器中,這樣就不須要每一個接口都寫一遍驗證邏輯。


後續:

相關文章
相關標籤/搜索