Springboot實現Shiro整合JWT的示例程式碼
寫在前面
之前想嘗試把JWT和Shiro結合到一起,但是在網上查了些部落格,也沒太有看懂,所以就自己重新研究了一下Shiro的工作機制,然後自己想了個(傻逼)辦法把JWT和Shiro整合到一起了
另外接下來還會涉及到JWT相關的內容,我之前寫過一篇部落格,可以看這裡:Springboot實現JWT認證
Shiro的Session機制
由於我的方法是改變了Shiro的預設的Session機制,所以這裡先簡單講一下Shiro的機制,簡單瞭解Shiro是怎麼確定每次訪問的是哪個使用者的
Servlet的Session機制
Shiro在JavaWeb中使用到的就是預設的Servlet的Session機制,大致流程如下:
1.使用者首次發請求
2.伺服器接收到請求之後,無論你有沒有許可權訪問到資源,在返回響應的時候,伺服器都會生成一個Session用來儲存該使用者的資訊,然後生成SessionId作為對應的Key
3.伺服器會在響應中,用jsessionId這個名字,把這個SessionId以Cookie的方式發給客戶(就是Set-Cookie響應頭)
4.由於已經設定了Cookie,下次訪問的時候,伺服器會自動識別到這個SessionId然後找到你上次對應的Session
Shiro帶來的變化
而結合Shiro之後,上面的第二步和第三步會發生小變化:
2.—>伺服器不但會建立Session,還會建立一個Subject物件(就是Shiro中用來代表當前使用者的類),也用這個SessionId作為Key繫結
3.—>第二次接受到請求的時候,Shiro會從請求頭中找到SessionId,然後去尋找對應的Subject然後繫結到當前上下文,這時候Shiro就能知道來訪的是誰了
我的思路
由於這個是我自己想出來的,所以可能會存在一定的問題,還請大佬指點
主要思想是:用JWT Token來代替Shiro原本返回的Session
工作流程:
- 使用者登入
- 若成功則shiro會預設生成一個SessionId用來匹配當前Subject物件,則我們將這個SessionId放入JWT中
- 返回JWT
- 使用者第二次攜帶JWT來訪問介面
- 伺服器解析JWT,獲得SessionId
- 伺服器把SessionId交給Shiro執行相關認證
程式碼實現
匯入JWT相關包
匯入java-jwt
包:
這個包裡實現了一系列jwt操作的api(包括上面講到的怎麼校驗,怎麼生成jwt等等)
如果你是Maven玩家:
pom.xml裡寫入
<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.8.3</version> </dependency>
如果你是Gradle玩家:
build.gradle裡寫入
compile group: 'com.auth0',name: 'java-jwt',version: '3.8.3'
如果你是其他玩家:
maven中央倉庫地址點這裡
JWT工具類
JwtUtils,程式碼如下:
import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; import java.io.Serializable; import java.util.Calendar; import java.util.Date; /** * @author Lehr * @create: 2020-02-04 */ public class JwtUtils { /** 簽發物件:這個使用者的id 簽發時間:現在 有效時間:30分鐘 載荷內容:暫時設計為:這個人的名字,這個人的暱稱 加密金鑰:這個人的id加上一串字串 */ public static String createToken(String userId,String realName,String userName) { Calendar nowTime = Calendar.getInstance(); nowTime.add(Calendar.MINUTE,30); Date expiresDate = nowTime.getTime(); return JWT.create().withAudience(userId) //簽發物件 .withIssuedAt(new Date()) //發行時間 .withExpiresAt(expiresDate) //有效時間 .withClaim("userName",userName) //載荷,隨便寫幾個都可以 .withClaim("realName",realName) .sign(Algorithm.HMAC256(userId+"HelloLehr")); //加密 } /** * 檢驗合法性,其中secret引數就應該傳入的是使用者的id * @param token * @throws TokenUnavailable */ public static void verifyToken(String token,String secret) throws TokenUnavailable { DecodedJWT jwt = null; try { JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret+"HelloLehr")).build(); jwt = verifier.verify(token); } catch (Exception e) { //效驗失敗 //這裡丟擲的異常是我自定義的一個異常,你也可以寫成別的 throw new TokenUnavailable(); } } /** * 獲取簽發物件 */ public static String getAudience(String token) throws TokenUnavailable { String audience = null; try { audience = JWT.decode(token).getAudience().get(0); } catch (JWTDecodeException j) { //這裡是token解析失敗 throw new TokenUnavailable(); } return audience; } /** * 通過載荷名字獲取載荷的值 */ public static Claim getClaimByName(String token,String name){ return JWT.decode(token).getClaim(name); } }
一點小說明:
關於jwt生成時的加密和驗證方法:
jwt的驗證其實就是驗證jwt最後那一部分(簽名部分)。這裡在指定簽名的加密方式的時候,還傳入了一個字串來加密,所以驗證的時候不但需要知道加密演算法,還需要獲得這個字串才能成功解密,提高了安全性。我這裡用的是id來,比較簡單,如果你想更安全一點,可以把使用者密碼作為這個加密字串,這樣就算是這段業務程式碼洩露了,也不會引發太大的安全問題(畢竟我的id是誰都知道的,這樣令牌就可以被偽造,但是如果換成密碼,只要資料庫沒事那就沒人知道)
關於獲得載荷的方法:
可能有人會覺得奇怪,為什麼不需要解密不需要verify就能夠獲取到載荷裡的內容呢?原因是,本來載荷就只是用Base64處理了,就沒有加密性,所以能直接獲取到它的值,但是至於可不可以相信這個值的真實性,就是要看能不能通過驗證了,因為最後的簽名部分是和前面頭部和載荷的內容有關聯的,所以一旦簽名驗證過了,那就說明前面的載荷是沒有被改過的。
Controller層
登入邏輯
/** * 使用者登入 * @param userName * @param password * @param req * @return * @throws Exception */ @SneakyThrows @PostMapping(value = "/login") public AccountVO login(String userName,String password,HttpServletRequest req){ //嘗試登入 Subject subject = SecurityUtils.getSubject(); try { subject.login(new UsernamePasswordToken(userName,password)); } catch (Exception e) { throw new LoginFailed(); } AccountVO account = accountService.getAccountByUserName(userName); String id = account.getId(); //生成jwtToken String jwtToken = JwtUtils.createToken(id,account.getRealName(),account.getUserName(),subject.getSession().getId().toString()); //設定好token,後來會在全域性處理的時候放入響應裡 req.setAttribute("token",jwtToken); return account; }
主要是:在登入成功之後把這個Subject的SessionId放入JWT然後生成token:
String jwtToken = JwtUtils.createToken(id,subject.getSession().getId().toString());
以後我們就可以通過解析JWT來獲取SessionId了,而不是每次把SessionId作為Cookie返回
退出邏輯
首先,由於JWT令牌本身就會失效,所以如果JWT令牌失效,也就相當與退出了
然後我們還可以同樣實現Shiro中傳統的手動登出:
public String logout(HttpServletRequest req) { SecurityUtils.getSubject().logout(); return "使用者已經安全登出"; }
這樣的話Realm中的使用者狀態就變成未認證了,就算JWT沒過期也需要重新登入了
自定義SessionManager
先上程式碼:
package com.imlehr.internship.shiroJwt; import com.imlehr.internship.exception.TokenUnavailable; import lombok.SneakyThrows; import org.apache.shiro.session.mgt.SessionKey; import org.apache.shiro.web.servlet.ShiroHttpServletRequest; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.util.WebUtils; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.Serializable; import java.util.UUID; /** * @author Lehr * @create: 2020-02-10 */ public class CustomSessionManager extends DefaultWebSessionManager { //這裡我為了省事用了lombok的標籤 @SneakyThrows @Override protected Serializable getSessionId(ServletRequest request,ServletResponse response) { String token = WebUtils.toHttp(request).getHeader("token"); System.out.println("會話管理器得到的token是:" + token); if (token == null || token.length()<1) { return UUID.randomUUID().toString(); } //在這裡驗證一下jwt了,雖然我知道這樣不好 String userId = JwtUtils.getAudience(token); JwtUtils.verifyToken(token,userId); String sessionId = JwtUtils.getClaimByName(token,"sessionId").asString(); if (sessionId == null) { return new TokenUnavailable(); } request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,"header"); request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID,token); request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID,Boolean.TRUE); request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED,isSessionIdUrlRewritingEnabled()); return sessionId; } }
之前的Session的獲取,就是在DefaultWebSessionManager裡實現的,所以我們現在只需要重寫這個類,把我們如何獲取Session的邏輯寫進去就好了
這裡說兩個方法:
getSessionId(SessionKey key)
這個方法是在DefaultWebSessionManager的,這裡並沒有重寫,我們上面重寫的是後面第二個同名方法,只是想在這裡談談,讀者可以直接跳過這段也不影響
原始碼邏輯
在Shiro想要獲取SessionId的時候,首先會呼叫的就是這個方法,而不是那個傳入httpRequest的方法
在DefaultWebSessionManager中,他是這樣做的
@Override public Serializable getSessionId(SessionKey key) { Serializable id = super.getSessionId(key); if (id == null && WebUtils.isWeb(key)) { ServletRequest request = WebUtils.getRequest(key); ServletResponse response = WebUtils.getResponse(key); //呼叫第二個同名方法 id = getSessionId(request,response); } return id; }
- 如果沒能找到id,就呼叫第二個同名方法
- 如果有,就返回
這裡需要注意的是,這個方法會在整個驗證過程中多次被反覆呼叫,而在伺服器接受到使用者請求的時候,只會呼叫一次的方法是下面這個,也就是我們重寫的這個
getSessionId(ServletRequest request,ServletResponse response)
這個才是真正涉及到伺服器接受到請求的時候獲取Session邏輯,從使用者的請求報文中獲取SessionId
所以我們要重寫的就是這一步
原版中的邏輯是:從Cookie裡找到sessionId的值
我們只需要把邏輯該為:從Header中找出JWT(也就是從請求頭的'token'頭中找),然後解析JWT,獲取到我們存放在其中的SessionId屬性即可
ShiroConfiguration
我們只需要把自己寫的SessionManager配置進去就好了
首先配好:
public DefaultWebSessionManager sessionManager() { CustomSessionManager customSessionManager = new CustomSessionManager(); return customSessionManager; }
然後放入SecurityManager
public SecurityManager securityManager(MyRealm myRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myRealm); return securityManager; }
完成🎉
測試
登入
我們獲取到了JWT,JWT裡面就帶有SessionId
後續請求不帶token
顯然,沒過認證,我們看下後臺:
因為不能獲得token所以無法得到該使用者對應的sessionId,所以被授權攔截了
後面那個JSESSIONID是因為沒得到sessionId新生成的,所以對應了一個沒有登入的使用者,自然就會被拒絕
只有帶上之前的token,shiro才會認為我們是之前那個已經登入過的使用者
後續請求帶token
後臺:
成功!
另外,因為JWT本身就適合RESTful API服務,所以,如果把Shiro和Redis整合起來做成分散式的,那麼效果會更好
到此這篇關於Springboot實現Shiro整合JWT的示例程式碼的文章就介紹到這了,更多相關Springboot Shiro整合JWT內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!