學習 spring-cloud-aibaba第七篇,JWT認證授權


特別聲明:整理自慕課網大目師兄的微服務視頻,鏈接: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&param2=b&param3=c&sign=asfdljgljoixcnogn,三個參數param1param2param3,再加上一個sign簽名,sign就是三個參數+secretKey的加密字符串

  • 作用
    保證請求參數不會被篡改
  • 如何保證?
    請求方在發送請求之前,把請求參數按照參數名從小到大(或者從大到小)順序排列好,再加上secretKey形成一個新的字符串,然後加密,得到sign值,例如
secretKey=""123456
sign = md5(param1=a&param2=b&param3=c&secretKey=""123456)

服務端在收到請求之後,也按照相同的拼裝順序憑藉參數和secretKey,再次md5得到一個加密字符串,然後比較加密字符串和請求sign是否相同,相同說明請求在傳輸的過程中,參數沒有被篡改,如果對不上,不是參數被改了,就是簽名被改了,就是非法請求。
破壞分子改了我的請求參數,他沒辦法得到一個正確的sign,因爲他不知道我的secretKey,知道secretKey的只有請求方和服務方,早商量好的

4.2 一般調用接口爲什麼要加時間戳?

按理說,上節4.1的簽名機制已經可以保證參數的安全性了,爲什麼還要加上時間戳呢?

因爲破壞分子不通過修改你的請求參數了來噁心你了,他可以抓取你整個的請求數據包,原封不動的多次發送請求搞破壞。這叫重放攻擊

我看其他的文章裏說,破壞分子要完成這個操作,花費的時間要遠超過60s,這時候加上時間戳就很有必要了,時間戳也是請求參數之一,也是簽名加密的一部分,所以時間戳也不會被修改,這樣服務端就可以拿到這個請求的時間戳和當前時間做比較,如果當前時間比時間戳大60s,那說明這個請求不正常,因爲一般請求也不會從發出,到接收請求花這麼長時間的,所以超過60s的就算非法請求,時間戳的作用就體現出來了。你也可以定義成50s40s

4.3 加了時間戳爲什麼又要加隨機數?

上節4.2通過時間戳比較來攔截請求,畢竟有個60s的空檔,這是一個60s的很大的漏洞,隨機數就可以補好這個漏洞,隨機數怎麼發揮它的作用呢?
每次發送請求,出了時間戳,再帶上一個很大的隨機數,例如0~10000000,請求一旦發送出去,破壞分子也沒法修改這個隨機數,隨機數也是簽名加密的一部分,如果改了,簽名就對不上
服務端收到請求之後,先判斷時間戳有沒有大於60s,大於肯定就拒絕,如果時間戳沒問題,再查一下Redis裏有沒有sign這個簽名,如果沒有就把sign存放到Redis中,超時時間設置爲60s,和時間戳的臨界值保持一致,並且給這次請求正常的返回。如果查到Redis已存在sign這個簽名,說明這個請求已經在60s內請求一次了,屬於非法請求

整個第4節都是個人所思所想,不能作爲準則。App,小程序之類的客戶端還好,畢竟代碼不可見,瀏覽器端就不行了,js是公開的啊,怎麼保密secretKey?而且我覺得只需要對需要登錄操作的請求進行簽名驗證就可以了,公開接口做這個沒有意義

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章