承接上一篇,whale系統開篇,聊聊使用者認證

程式設計師老貓發表於2021-04-20

寫在前面

上次老貓和大家說過想要開發一個系統,從簡單的許可權開始做起,有的網友表示還是挺支援的,但是有的網友嗤之以鼻,認為太簡單了,不過也沒事,簡單歸簡單,主要的還是個人技術的一個整合和實戰。

沒錯,系統的名稱老貓也已經定義好了叫做whale,whale是鯨魚的意思。其實沒有別的意思,也是老貓拍腦袋想出來的,可能是受到docker圖示的影響。另外的真要說有點啥麼,那就是老貓希望這個系統是成長的,是演變的,能從簡單的小魚系統成長為遨遊海洋的鯨魚,當然貓也喜歡吃魚,扯遠了......本篇起,老貓正式開始養魚。

使用者認證

開篇我們當然從使用者的登入認證開始說起,關於使用者認證,老貓不曉得大家對此是否熟悉,有些同學可能有所研究,這裡老貓還是得詳細和大家聊聊。說起使用者認證,大家比較有所耳聞的應該就是session認證以及token認證。

傳統的Session認證

我們說http請求是無狀態的。這句話什麼意思?所謂無狀態,就是指使用者向伺服器端發起多個請求,伺服器並不會知道多次請求都是來源於同一個使用者,這就是無狀態。

那麼如何讓伺服器知道我們來自是哪一個使用者的請求呢?所以我們只能在伺服器中儲存一份使用者的登入資訊,這份登入資訊會在響應的時候傳給瀏覽器,並且告訴其儲存為cookie,以便下次請求的時候傳送給我們的應用,這樣我們的應用就識別了請求來源於哪個使用者,這也就是傳統的session認證。簡單地畫了一下原理圖,大概就長下面這樣。

session儲存

當然,傳統的單體架構中的儲存是session記憶體的儲存,隨著使用者的增多,伺服器開銷增加,為了擴充套件,逐漸將session資訊可以存入到redis中介軟體中。這也是擴充套件的一種方式。

聊聊這種方式的短板。

  • 每次認證使用者發起請求時,伺服器需要去建立一個記錄來儲存資訊。當越來越多的使用者發請求時,記憶體的開銷也會不斷增加。 雖然說中介軟體可以緩解這個問題。
  • 在服務端的記憶體中使用Seesion儲存登入資訊,可擴充套件性會比較差。
  • 開放平臺的商業理念開始走向主流,顯然cookie以及session都無法很好的處理授權管理。
  • 跨域資源共享問題,當我們需要讓資料跨多臺移動裝置上使用時,跨域資源的共享會是一個讓人頭疼的問題。在使用Ajax抓取另一個域的資源,就可以會出現禁止請求的情況。

相對的,我們再來看看相關的token認證。

Token認證

我們直接說個大白話,什麼叫做token認證,說白了其實就是暗號。 在一些資料傳輸之前,要先進行暗號的核對,不同的暗號被授權不同的資料操作。

說得稍微專業一些應該是這樣, Token是服務端生成的一串字串,以作客戶端進行請求的一個令牌,當第一次登入後,伺服器生成一個Token便將此Token返回給客戶端,以後客戶端只需帶上這個Token前來請求資料即可,無需再次帶上使用者名稱和密碼。 大致的流程如下,

session儲存

聊聊這種方式相對於傳統session的優勢。

  • 無狀態、可擴充套件:在客戶端儲存的 token 是無狀態的,並且能夠被擴充套件。基於這種無狀態和不儲存Session資訊,負載均衡伺服器 能夠將使用者的請求傳遞到任何一臺伺服器上,因為伺服器與使用者資訊沒有關聯。相反在傳統方式中,我們必須將請求傳送到一臺儲存了該使用者 session 的伺服器上(稱為Session親和性),因此當使用者量大時,可能會造成 一些擁堵。使用 token 完美解決了此問題。

  • 關於安全性:請求中傳送 token 而不是 cookie,這能夠防止 CSRF(跨站請求偽造) 攻擊。即使在客戶端使用 cookie 儲存 token,cookie 也僅僅是一個儲存機制而不是用於認證。另外,由於沒有 session,讓我們少我們不必再進行基於 session 的操作。 Token 是有時效的,一段時間之後使用者需要重新驗證。我們也不一定需要等到token自動失效,token有撤回的操作,通過 token revocataion可以使一個特定的 token 或是一組有相同認證的 token 無效。

  • 可擴充套件性:使用 Tokens 能夠與其它應用共享許可權。例如,能將一個部落格帳號和自己的QQ號關聯起來。當通過一個 第三方平臺登入QQ時,我們可以將一個部落格發到QQ平臺中。

    使用 token,可以給第三方應用程式提供自定義的許可權限制。當使用者想讓一個第三方應用程式訪問它們的資料時,我們可以通過建立自己的API,給出具有特殊許可權的tokens。

  • 多平臺與跨域:當我們的應用和服務不斷擴大的時候,我們可能需要通過多種不同平臺或其他應用來接入我們的服務。可以讓我們的API只提供資料,我們也可以從CDN提供服務(Having our API just serve data, we can also make the design choice to serve assets from a CDN.)。 在為我們的應用程式做了如下簡單的配置之後,就可以消除 CORS 帶來的問題。只要使用者有一個通過了驗證的token,資料和資源就能夠在任何域上被請求到。

    Access-Control-Allow-Origin: *       
    
  • 基於標準:有幾種不同方式來建立 token。最常用的標準就是 JSON Web Tokens。很多語言都支援它。

所以綜合對比了一下,token認證的優勢也就相當明顯了,所以老貓後面的認證也將會基於token來實現。

關於JWT

其實jwt就是 Json web token 的簡寫,一般組成的形式是這樣xxx.yyy.zzz。很明顯,其實是分為三部分。第一部分稱它為頭部(header),第二部分稱其為載荷(payload, 類似於飛機上承載的物品),第三部分是簽證(signature).

  • 頭部(header):頭部一般會有兩部分資訊,第一部分宣告型別,這裡一般型別就是jwt,第二部分就是加密演算法,一般直接使用 HMAC SHA256,那麼構造基本就是如下所示。

    {
      'typ': 'JWT',
      'alg': 'HS256'
    }
    

    然後將頭部進行base64加密(該加密是可以對稱解密的),構成了第一部分xxx

  • 載荷(payload):主要是對實體(一般可以是使用者資訊)和其他資料進行宣告,關於宣告主要有三種型別:registered,public和private。

    1. Registered claims: 這裡有一組預定義的宣告,它們不是強制的,但是推薦。比如:iss (issuer), exp (expiration time), sub (subject), aud (audience)等。
    2. Public claims : 可以隨意定義。
    3. Private claims : 用於在同意使用它們的各方之間共享資訊,並且不是註冊的或公開的宣告。

    舉個官網的例子如下:

    {
      "sub": "1234567890",
      "name": "John Doe",
      "admin": true
    }
    

    對payload進行Base64編碼就得到JWT的第二部分,但是要注意的是,不要在JWT的payload或header中放置敏感資訊,除非它們是加密的。

  • 簽名(Signature):為了得到簽名部分,你必須有編碼過的header、編碼過的payload、一個祕鑰,簽名演算法是header中指定的那個,然後對它們簽名即可。

    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret)
    

    簽名是用於驗證訊息在傳遞過程中有沒有被更改,並且,對於使用私鑰簽名的token,它還可以驗證JWT的傳送方是否為它所稱的傳送方。

    放在一起進行加密之後即為如下:

    jwt加密後

    jwt.io Debugger 去decode操作之後我想大家就一目瞭然了,當然這個圖來自官網。

    解密

    以上關於JWT的相關介紹,說明:關於以下介紹均來自於官網,大家如果覺得老貓翻譯的有問題的話,可以自行去官網看一下,官網地址:https://jwt.io/introduction/

    JWTUtil的封裝實戰

    如果上述大家覺得還是比較模糊,老貓封裝了一個JWTUtil的工具類,大家可以參考著去理解,具體程式碼如下:

    /**
     * @author kdaddy@163.com
     * @date 2021/4/13 23:06
     */
    public class JwtUtil {
        // 生成簽名是所使用的祕鑰
        private final String base64EncodedSecretKey;
    
        // 生成簽名的時候所使用的加密演算法
        private final SignatureAlgorithm signatureAlgorithm;
    
        public JwtUtil(String secretKey, SignatureAlgorithm signatureAlgorithm) {
            this.base64EncodedSecretKey = Base64.encodeBase64String(secretKey.getBytes());
            this.signatureAlgorithm = signatureAlgorithm;
        }
        /**
         * 生成 JWT Token 字串
         * @param iss       簽發人名稱
         * @param ttlMillis jwt 過期時間
         * @param claims    額外新增到荷部分的資訊。
         *                  例如可以新增使用者名稱、使用者ID、使用者(加密前的)密碼等資訊
         */
        public String encode(String iss, long ttlMillis, Map<String, Object> claims) {
            if (claims == null) {
                claims = new HashMap<>();
            }
    
            // 簽發時間(iat):荷載部分的標準欄位之一
            long nowMillis = System.currentTimeMillis();
            Date now = new Date(nowMillis);
    
            // 下面就是在為payload新增各種標準宣告和私有宣告瞭
            JwtBuilder builder = Jwts.builder()
                    // 荷載部分的非標準欄位/附加欄位,一般寫在標準的欄位之前。
                    .setClaims(claims)
                    // JWT ID(jti):荷載部分的標準欄位之一,JWT 的唯一性標識,雖不強求,但儘量確保其唯一性。
                    .setId(UUID.randomUUID().toString())
                    // 簽發時間(iat):荷載部分的標準欄位之一,代表這個 JWT 的生成時間。
                    .setIssuedAt(now)
                    // 簽發人(iss):荷載部分的標準欄位之一,代表這個 JWT 的所有者。通常是 username、userid 這樣具有使用者代表性的內容。
                    .setSubject(iss)
                    // 設定生成簽名的演算法和祕鑰
                    .signWith(signatureAlgorithm, base64EncodedSecretKey);
    
            if (ttlMillis >= 0) {
                long expMillis = nowMillis + ttlMillis;
                Date exp = new Date(expMillis);
                // 過期時間(exp):荷載部分的標準欄位之一,代表這個 JWT 的有效期。
                builder.setExpiration(exp);
            }
            return builder.compact();
        }
    
        /**
         * JWT Token 由 頭部 荷載部 和 簽名部 三部分組成。簽名部分是由加密演算法生成,無法反向解密。
         * 而 頭部 和 荷載部分是由 Base64 編碼演算法生成,是可以反向反編碼回原樣的。
         * 這也是為什麼不要在 JWT Token 中放敏感資料的原因。
         * @param jwtToken 加密後的token
         * @return claims 返回荷載部分的鍵值對
         */
        public Claims decode(String jwtToken) {
    
            // 得到 DefaultJwtParser
            return Jwts.parser()
                    // 設定簽名的祕鑰
                    .setSigningKey(base64EncodedSecretKey)
                    // 設定需要解析的 jwt
                    .parseClaimsJws(jwtToken)
                    .getBody();
        }
    
        /**
         * 校驗 token
         * 在這裡可以使用官方的校驗,或,
         * 自定義校驗規則,例如在 token 中攜帶密碼,進行加密處理後和資料庫中的加密密碼比較。
         * @param jwtToken 被校驗的 jwt Token
         */
        public boolean isVerify(String jwtToken) {
            Algorithm algorithm = null;
    
            switch (signatureAlgorithm) {
                case HS256:
                    algorithm = Algorithm.HMAC256(Base64.decodeBase64(base64EncodedSecretKey));
                    break;
                default:
                    throw new RuntimeException("不支援該演算法");
            }
    
            JWTVerifier verifier = JWT.require(algorithm).build();
            verifier.verify(jwtToken);  // 校驗不通過會丟擲異常
            return true;
        }
    
        public static void main(String[] args) {
            JwtUtil util = new JwtUtil("tom", SignatureAlgorithm.HS256);
    
            Map<String, Object> map = new HashMap<>();
            map.put("username", "tom");
            map.put("password", "123456");
            map.put("age", 20);
            //測試加密生成token
            String jwtToken = util.encode("tom", 30000, map);
            System.out.println(jwtToken);
    
            //測試token合法性
            util.isVerify(jwtToken);
            System.out.println("合法");
    
            //測試拿到token之後解密
           util.decode(jwtToken).entrySet().forEach((entry) -> {
                System.out.println(entry.getKey() + ": " + entry.getValue());
           });
        }
    }
    
    

    上述程式碼我們用main函式進行測試,輸出結果如下:

    eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0b20iLCJwYXNzd29yZCI6IjEyMzQ1NiIsImV4cCI6MTYxODkyNjc3NywiaWF0IjoxNjE4OTI2NzQ3LCJhZ2UiOjIwLCJqdGkiOiIyYzkxY2I2OS1lYWEzLTRlMmYtOGViNC1iNDUzM2MxNTE1MjkiLCJ1c2VybmFtZSI6InRvbSJ9.Ws4Vw9Ll60uaFbTGBpZJh-LTMI052l4Zzx81jqKq3qY
    合法
    sub: tom
    password: 123456
    exp: 1618926777
    iat: 1618926747
    age: 20
    jti: 2c91cb69-eaa3-4e2f-8eb4-b4533c151529
    username: tom
    

    寫在最後

    以上就是相關jwt的介紹以及傳統session實現的對比,接下來,老貓會向大家演示如何通過JWT+shiro實現whale系統的一個登入鑑權功能。關於前端系統的框架,老貓決定使用的是一套網上比較流行的開源框架vue-admin-beautiful,功能相當齊全,也給大家推薦一下這位大神的作品,github地址為: https://github.com/chuzhixin/vue-admin-beautiful,大家可以自行下載試著執行一下。感謝大家持續關注老貓。

相關文章