寫在前面
上次老貓和大家說過想要開發一個系統,從簡單的權限開始做起,有的網友表示還是挺支持的,但是有的網友嗤之以鼻,認為太簡單了,不過也沒事,簡單歸簡單,主要的還是個人技術的一個整合和實戰。
沒錯,系統的名稱老貓也已經定義好了叫做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,大家可以自行下載試着運行一下。感謝大家持續關注老貓。

