寫在前面
上次老貓和大家說過想要開發一個系統,從簡單的許可權開始做起,有的網友表示還是挺支援的,但是有的網友嗤之以鼻,認為太簡單了,不過也沒事,簡單歸簡單,主要的還是個人技術的一個整合和實戰。
沒錯,系統的名稱老貓也已經定義好了叫做whale,whale是鯨魚的意思。其實沒有別的意思,也是老貓拍腦袋想出來的,可能是受到docker圖示的影響。另外的真要說有點啥麼,那就是老貓希望這個系統是成長的,是演變的,能從簡單的小魚系統成長為遨遊海洋的鯨魚,當然貓也喜歡吃魚,扯遠了......本篇起,老貓正式開始養魚。
使用者認證
開篇我們當然從使用者的登入認證開始說起,關於使用者認證,老貓不曉得大家對此是否熟悉,有些同學可能有所研究,這裡老貓還是得詳細和大家聊聊。說起使用者認證,大家比較有所耳聞的應該就是session認證以及token認證。
傳統的Session認證
我們說http請求是無狀態的。這句話什麼意思?所謂無狀態,就是指使用者向伺服器端發起多個請求,伺服器並不會知道多次請求都是來源於同一個使用者,這就是無狀態。
那麼如何讓伺服器知道我們來自是哪一個使用者的請求呢?所以我們只能在伺服器中儲存一份使用者的登入資訊,這份登入資訊會在響應的時候傳給瀏覽器,並且告訴其儲存為cookie,以便下次請求的時候傳送給我們的應用,這樣我們的應用就識別了請求來源於哪個使用者,這也就是傳統的session認證。簡單地畫了一下原理圖,大概就長下面這樣。
當然,傳統的單體架構中的儲存是session記憶體的儲存,隨著使用者的增多,伺服器開銷增加,為了擴充套件,逐漸將session資訊可以存入到redis中介軟體中。這也是擴充套件的一種方式。
聊聊這種方式的短板。
- 每次認證使用者發起請求時,伺服器需要去建立一個記錄來儲存資訊。當越來越多的使用者發請求時,記憶體的開銷也會不斷增加。 雖然說中介軟體可以緩解這個問題。
- 在服務端的記憶體中使用Seesion儲存登入資訊,可擴充套件性會比較差。
- 開放平臺的商業理念開始走向主流,顯然cookie以及session都無法很好的處理授權管理。
- 跨域資源共享問題,當我們需要讓資料跨多臺移動裝置上使用時,跨域資源的共享會是一個讓人頭疼的問題。在使用Ajax抓取另一個域的資源,就可以會出現禁止請求的情況。
相對的,我們再來看看相關的token認證。
Token認證
我們直接說個大白話,什麼叫做token認證,說白了其實就是暗號。 在一些資料傳輸之前,要先進行暗號的核對,不同的暗號被授權不同的資料操作。
說得稍微專業一些應該是這樣, Token是服務端生成的一串字串,以作客戶端進行請求的一個令牌,當第一次登入後,伺服器生成一個Token便將此Token返回給客戶端,以後客戶端只需帶上這個Token前來請求資料即可,無需再次帶上使用者名稱和密碼。 大致的流程如下,
聊聊這種方式相對於傳統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。
- Registered claims: 這裡有一組預定義的宣告,它們不是強制的,但是推薦。比如:iss (issuer), exp (expiration time), sub (subject), aud (audience)等。
- Public claims : 可以隨意定義。
- 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.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,大家可以自行下載試著執行一下。感謝大家持續關注老貓。