手把手教你整合shiro+jwt,2021終極版。
2021年發布shiro1.8帶來了質的飛躍,對於本文的需求來說,最利好的包括兩點:一是增加了對SpringBoot自動裝配機制的支持;二是增加了BearerHttpAuthenticationFilter這個默認過濾器,從而讓Jwt的整合獲得了原生級的適配性。以上兩項特性大大精簡了我們的配置工作,且讓當前網絡上所有的教程都落后於時代。(包括官網和英文網絡,搜到的教程基本都是舊版本的配置。)
-
首先介紹一下自己開發的集成性RESTful框架KRest,功能即為整合了常用的shiro+jwt+通信加密模塊,提供了一套極為簡便易用的一體化配置。該框架的設計初衷就是幫助大家從繁瑣的構建工作中解脫出來,讓大家再也不用再花費太多時間來跟着這篇帖子一點點學原理。
-
項目發布在gitee上 https://gitee.com/ckw1988/krest ,源碼同時也發布到了maven中央庫
<dependency> <groupId>com.chenkaiwei.krest</groupId> <artifactId>krest-core</artifactId> <version>${最新版本號}</version> </dependency>
使用以上依賴即可直接使用
如果您依然打算自己親手完成一套shiro+jwt的配置,那么請繼續往下看下去。本文在介紹配置時會多介紹一些shiro和jwt的機制原理,所以此貼同時也是一篇極好的理論教程。
話不多說,開搞。
配置文件
首先在pom里配上shiro1.8
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.8.0</version>
</dependency>
然后是配置文件,如今在config中只需配置兩個bean。代碼如下:
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
/*
* filter配置規則參考官網
* http://shiro.apache.org/web.html#urls-
* 默認過濾器對照表
* https://shiro.apache.org/web.html#default-filters
*/
Map<String, String> filterRuleMap = new HashMap<>();
filterRuleMap.put("/static/*", "anon");
filterRuleMap.put("/error", "anon");
filterRuleMap.put("/register", "anon");
filterRuleMap.put("/login", "anon");
//↑配置不參與驗證的映射路徑。
// 關鍵:jwt驗證過濾器。
//↓ 此處采用shiro1.8新增的默認過濾器:authcBearer-BearerHttpAuthenticationFilter。
filterRuleMap.put("/**", "authcBearer");
//↑ 如果有其他過濾法則配在/**上,則在第二個參數的字符串里使用逗號間隔。
factoryBean.setGlobalFilters(Arrays.asList("noSessionCreation"));
//↑ 關鍵:全局配置NoSessionCreationFilter,把整個項目切換成無狀態服務。
factoryBean.setSecurityManager(securityManager);
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
@Bean
protected Authorizer authorizer() {
ModularRealmAuthorizer authorizer = new ModularRealmAuthorizer();
return authorizer;
}
關鍵代碼的功能和含義看我注釋就行。這里重點解釋兩句:filterRuleMap.put("/**", "authcBearer")和
factoryBean.setGlobalFilters(Arrays.asList("noSessionCreation"))。前者配置了BearerHttpAuthenticationFilter過濾器,讓除了不驗證規則("anon")以外的請求都經BearerHttpAuthenticationFilter過濾器處理,該過濾器的功能是自動解析請求頭信息中的Authorization字段,並將其所攜帶的jwt token內容包裝成一個BearerToken對象,以供后續使用;后者是個更為強大的過濾器NoSessionCreationFilter,一旦配上,不再存儲任何用戶信息,徹底成為一個真正的no-session服務。
有興趣研究shiro的也可以看看其他shiro提供的默認過濾器,https://shiro.apache.org/web.html 介紹得很全面。這部分功能在文檔里寫的很清楚,就不再贅述,畢竟本文的主旨是配jwt。
如今的config部分只需要配置這么多,舊方案里那一大堆東西都不再需要了。此后你自定義的realm只需在類定義時加上@Component標簽,即可由shiro自動裝配使用(贊美SpringbBoot)。至於這個authorizer的bean,粗略研究了一下自動配置策略里是有的,但是為啥沒有自動裝配,等有空再研究了。反正自己配一下也費不了幾秒鍾的工夫。
身份驗證。
因為我們整個服務已經變成no-session狀態,所以事實上對shiro來說整個系統中已經不存在"已登錄用戶"這個概念了,這就意味着每一次獨立的請求事實上都需要一個身份驗證過程,這種身份驗證行為在shiro里都被稱為"登錄(Login)"。
大致的流程為:首次登陸時用戶提交用戶名和密碼,驗證通過后服務器生成一個初始的Jwt Token返回給客戶端。此后客戶端在任何請求時都把Jwt Token帶上,服務端驗證通過后即視為當次身份驗證通過(或者說以token的方式登陸成功)。這個流程也即jwt token的官方建議使用方法。
既然有兩種登陸方式,則需要兩個realm,我們需要一個UsernamePasswordRealm來處理用戶名和密碼登錄;一個TokenValidateAndAuthorizingRealm,處理token驗證方式的"登錄"。
我們先看處理token驗證的TokenValidateAndAuthorizingRealm,此時客戶端已經獲得了初始的Jwt Token:
@Slf4j
@Component
public class TokenValidateAndAuthorizingRealm extends AuthorizingRealm {
//權限管理部分的代碼先行略過
//......
public TokenValidateAndAuthorizingRealm() {
//CredentialsMatcher,自定義匹配策略(即驗證jwt token的策略)
super(new CredentialsMatcher() {
@Override
public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
log.info("doCredentialsMatch token合法性驗證");
BearerToken bearerToken = (BearerToken) authenticationToken;
String bearerTokenString = bearerToken.getToken();
log.debug(bearerTokenString);
boolean verified = JwtUtil.verifyTokenOfUser(bearerTokenString);
return verified;
}
});
}
@Override
public String getName() {
return "TokenValidateAndAuthorizingRealm";
}
@Override
public Class getAuthenticationTokenClass() {
//設置由本realm處理的token類型。BearerToken是在filter里自動裝配的。
return BearerToken.class;
}
@Override
public boolean supports(AuthenticationToken token) {
boolean res=super.supports(token);
log.debug("[TokenValidateRealm is supports]" + res);
return res;
}
@Override//裝配用戶信息,供Matcher調用
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException, TokenExpiredException {
log.debug("doGetAuthenticationInfo 將token裝載成用戶信息");
BearerToken bearerToken = (BearerToken) authenticationToken;
String bearerTokenString = bearerToken.getToken();
JwtUser jwtUser = JwtUtil.recreateUserFromToken(bearerTokenString);//只帶着用戶名和roles
SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, bearerTokenString, this.getName());
/*Constructor that takes in an account's identifying principal(s) and its corresponding credentials that verify the principals.*/
// 這個返回值是造Subject用的,返回值供createSubject使用
return res;
}
該realm的功能除了身份驗證還包含權限控制。為免干擾理解先行省略,后面會單獨介紹,這里先說身份驗證。
-
首先,讓客戶端在請求中帶上jwt token。按照jwt的通用規范,具體的做法是客戶端將token字符串加上"Bearer "前綴后放在頭信息的Authorization字段里。該信息會在authcBearer過濾器中自動解析,並將其所攜帶的jwt token內容包裝成一個BearerToken對象。這一部分可參考實例源碼中的postman腳本。
-
然后實現realm的代碼,覆蓋getAuthenticationTokenClass方法,shiro的默認機制是通過token的類型來確認是否由當前realm來處理當前收到的登錄請求,本類中令該方法返回BearerToken.class即可。該對象由authcBearer filter取出請求頭信息中攜帶的Authorization信息自動封裝。由此shiro就會將authcBearer filter中發起的login操作交給該realm處理。
-
接下來是實現doGetAuthenticationInfo方法,該方法依然不是真正的身份驗證過程,而是裝配登陸成功后的用戶信息(返回值的第一個參數)和供驗證的身份信息(返回值的第二個參數),第三個參數大約是用於區分本次登陸是由哪個realm通過的,不太重要,帶上即可。
-
配置一個CredentialsMatcher。該對象才是真正處理驗證登陸的步驟,我將其用匿名類創建在realm的構造器里,語法很好懂,看源碼即可。
同時,該步驟中還用到了工具類JwtUtil,代碼如下:
@Slf4j public class JwtUtil { //指定一個token過期時間(毫秒) private static final long EXPIRE_TIME = 20 * 60 * 1000; //20分鍾 private static final String JWT_TOKEN_SECRET_KEY = "yourTokenKey"; //↑ 記得換成你自己的秘鑰 public static String createJwtTokenByUser(JwtUser user) { String secret = JWT_TOKEN_SECRET_KEY; Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME); Algorithm algorithm = Algorithm.HMAC256(secret); //使用密鑰進行哈希 // 附帶username信息的token return JWT.create() .withClaim("username", user.getUsername()) .withClaim("roles", user.getRoles()) // .withClaim("permissions",permissionService.getPermissionsByUser(user)) .withExpiresAt(date) //過期時間 .sign(algorithm); //簽名算法 //r-p的映射在服務端運行時做,不放進token中 } /** * 校驗token是否正確 */ public static boolean verifyTokenOfUser(String token) throws TokenExpiredException {//user要從sercurityManager拿,確保用戶用的是自己的token log.info("verifyTokenOfUser"); String secret = JWT_TOKEN_SECRET_KEY;// //根據密鑰生成JWT效驗器 Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier verifier = JWT.require(algorithm) .withClaim("username", getUsername(token))//從不加密的消息體中取出username .build(); //生成的token會有roles的Claim,這里不加不知道行不行。 // 一個是直接從客戶端傳來的token,一個是根據鹽和用戶名等信息生成secret后再生成的token DecodedJWT jwt = verifier.verify(token); //能走到這里 return true; } /** * 在token中獲取到username信息 */ public static String getUsername(String token) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim("username").asString(); } catch (JWTDecodeException e) { return null; } } public static JwtUser recreateUserFromToken(String token) { JwtUser user = new JwtUser(); DecodedJWT jwt = JWT.decode(token); user.setUsername(jwt.getClaim("username").asString()); user.setRoles(jwt.getClaim("roles").asList(String.class)); //r-p映射在運行時去取 return user; } /** * 判斷是否過期 */ public static boolean isExpire(String token) { DecodedJWT jwt = JWT.decode(token); return jwt.getExpiresAt().getTime() < System.currentTimeMillis(); } }
因為封裝比較簡單,看看源碼和注解即可。
該類中所用到的JWT驗證框架是<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.18.2</version> </dependency>
配到pom里去。
-
至此,jwt驗證部分的功能配置完畢。DemoController中的whoami方法是這部分的使用范例。
@GetMapping("/whoami") public Map whoami(){ JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal(); Map<String,String> res=new HashMap<>(); res.put("result","you are "+jwtUser); res.put("token",JwtUtil.createJwtTokenByUser(jwtUser)); return res; }
JwtUser是攜帶在JwtToken中的用戶信息,因為no-session服務不再儲存用戶信息,所以用戶信息就得放在jwtToken中攜帶,這也是jwt的規范之一。同時這個jwtUser也即是在先前第3步驟的返回值第一個參數中配置進去的用戶信息,你可以根據需要自行設定這個對象,步驟3中傳進去啥,getSubject中取出來的就是啥。
注意返回值中還需要加上新生成的Jwt token,因為token有過期時間,所以一次成功的帶jwt的請求成功返回時,還應當把新的token帶給客戶端,供它下次請求時使用。進階些的做法是僅在token即將過期時才生成新token返回給客戶端,從而節約一些服務器資源。
-
客戶端在拿到返回信息后,將token中的內容取代步驟1中的舊token,下次請求時用同樣的規則帶上即可。如果用了即將過期時才刷新token的機制且還沒到token刷新時間,則繼續使用舊token即可。如此新token連續不斷地替換掉舊token,用戶的登錄狀態就能視為一直保持。
-
當然如果兩次請求的間隔時間超過了token中預設的過期時間(即上面JWTUtil源碼中的EXPIRE_TIME),則token驗證會不通過,提示tokne過期,此時客戶端應重新把頁面跳轉到用戶名和密碼的登錄頁要求用戶重新登錄。
接下來介紹處理用戶名密碼登陸的UsernamePasswordRealm。之所以這個后介紹,是因為這個環節其實是個可選環節。獲得初始jwt token的方式多種多樣,可以是用戶名密碼登陸,可以是手機+驗證碼登陸,可以是第三方平台登錄,可以是你自定義的隨便什么方式的登錄,甚至可以是通過其他服務登錄已經獲得了jwt token后再拿到本服務上來使用。
所以事實上最為自由的做法是,只要你認為某個登陸請求已經完成了登陸步驟,只需要在返回值中帶上一個新token
……
res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
即可視為登陸成功。之后的其他請求自然會進入你在TokenValidateAndAuthorizingRealm中定義好的驗證流程來處理。
為了進一步介紹shiro的原理機制,這里選用shiro原生的用戶名密碼登陸的realm來演示登陸步驟的驗證方式。
-
首先這種登錄方式需要顯示調用,畢竟用戶名和密碼不像jwt,怎么傳沒啥特別嚴格的規范,不方便自動處理。參考語法如下,定義在controller中
/** * 登陸 */ @PostMapping("/login") public Map login(@RequestBody User userInput) throws Exception { String username = userInput.getUsername(); String password = userInput.getPassword(); Assert.notNull(username, "username不能為空"); Assert.notNull(password, "password不能為空"); UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password); Subject subject = SecurityUtils.getSubject(); subject.login(usernamePasswordToken);//顯示調用登錄方法 //生成返回token Map<String,String> res=new HashMap<>(); JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal(); res.put("token",JwtUtil.createJwtTokenByUser(jwtUser)); res.put("result","login success or other result message"); return res; }
-
subject.login(usernamePasswordToken)的操作,事實上是就進入了由realm處理身份驗證的環節。我們先看對應代碼
//Username Password Realm,用戶名密碼登陸專用Realm @Slf4j @Component public class UsernamePasswordRealm extends AuthenticatingRealm { @Autowired private UserService userService; /*構造器里配置Matcher*/ public UsernamePasswordRealm() { super(); HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher(); hashedCredentialsMatcher.setHashAlgorithmName("md5"); hashedCredentialsMatcher.setHashIterations(2);//密碼保存策略一致,2次md5加密 this.setCredentialsMatcher(hashedCredentialsMatcher); } /** * 通過該方法來判斷是否由本realm來處理login請求 * * 調用{@code doGetAuthenticationInfo(AuthenticationToken)}之前會shiro會調用{@code supper.supports(AuthenticationToken)} * 來判斷該realm是否支持對應類型的AuthenticationToken,如果相匹配則會走此realm * * @return */ @Override public Class getAuthenticationTokenClass() { log.info("getAuthenticationTokenClass"); return UsernamePasswordToken.class; } @Override public boolean supports(AuthenticationToken token) { //繼承但啥都不做就為了打印一下info boolean res = super.supports(token);//會調用↑getAuthenticationTokenClass來判斷 log.debug("[UsernamePasswordRealm is supports]" + res); return res; } /** * 用戶名和密碼驗證,login接口專用。 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { UsernamePasswordToken usernamePasswordToken=(UsernamePasswordToken) token; User userFromDB=userService.queryUserByName(usernamePasswordToken.getUsername()); String passwordFromDB = userFromDB.getPassword(); String salt = userFromDB.getSalt(); //在使用jwt訪問時,shiro中能拿到的用戶信息只能是token中攜帶的jwtUser,所以此處保持統一。 JwtUser jwtUser=new JwtUser(userFromDB.getUsername(),userFromDB.getRoles()); SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt), getName()); return res; } }
-
首先還是覆蓋getAuthenticationTokenClass方法,此時設定返回值為UsernamePasswordToken.class。因為步驟1的login方法中傳入的token為UsernamePasswordToken類型,所以該操作會被shiro分配給本realm來處理。(jwt的"登錄"其實也有這個步驟,不過是shiro替你做了,源碼在AuthenticatingFilter.executeLogin中,感興趣的可以看看)。
-
doGetAuthenticationInfo中返回值按照new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),getName());來配,第一個參數依然是登陸成功后的用戶信息,第三個是密碼的鹽。
-
密碼驗證策略是md5哈希2次加鹽,因為這個驗證規則shiro里有現成的實現,就不用自己寫了,直接用HashedCredentialsMatcher即可。代碼一樣在構造器里。
-
這部分因為自由度很高,你完全不走shiro的realm也可以,和jwt的realm一樣自定義matcher也可以,沒啥必要非得使用shiro自帶的密碼驗證規則,平白增加學習成本,所以介紹得粗略些。懶得學也可以像jwt的realm那樣自定義matcher,用自己熟悉的加密策略和加密工具實現,反正前面jwt realm里matcher的實現就是現成的參考。
-
再次提醒一下不要遺漏@Component注解。
權限管理
首先你的用戶-權限的數據模型要符合RBAC規范,這個概念這里不再贅述。
因為服務端不存用戶信息了,所以此時role、permission和這兩級數據和user怎么關聯就是一個問題,我這里決定的方案是,roles信息跟着user一起存在jwt token里,然后permissions和role的對應因為相對固定,所以在服務端維護一份對應表即可。
代碼在TokenValidateAndAuthorizingRealm中,這里把相關部分再貼一遍方便閱讀
@Slf4j
@Component
public class TokenValidateAndAuthorizingRealm extends AuthorizingRealm {
UserService userService;
Map<String, Collection<String>> rolePermissionsMap;
@Autowired
public void setUserService(UserService userService){
this.userService=userService;
rolePermissionsMap= userService.getRolePermissionMap();
//自動注入時查詢一次存成變量,避免每次權限管理都去調用userService
}
……//身份驗證部分省略
@Override//權限管理
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
log.debug("doGetAuthorizationInfo 權限驗證");
JwtUser user = (JwtUser) SecurityUtils.getSubject().getPrincipal();
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
simpleAuthorizationInfo.addRoles(user.getRoles());//roles跟着user走,放到token里。
Set<String> stringPermissions = new HashSet<String>();
for (String role : user.getRoles()) {
stringPermissions.addAll(rolePermissionsMap.get(role));
}
simpleAuthorizationInfo.addStringPermissions(stringPermissions);
return simpleAuthorizationInfo;
}
}
rolePermissionsMap的初始化看源碼即可,權限管理部分的配置,doGetAuthorizationInfo方法,本質是返回當前用戶所擁有的角色和權限的集合,角色本身就存在token里,用user.getRoles()即可獲取;權限通過對照表,由roles查詢添加而來,代碼都不難懂。
功能試用。
controller中配一個這樣的方法
@GetMapping("/permissionDemo")
@RequiresPermissions("pd")
public Map permissionDemo(){
Map<String,String> res=new HashMap<>();
res.put("result","you have got the permission [pd]");
JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
return res;
}
@RequiresPermissions("pd")表示擁有"pd"權限的用戶才有訪問當前方法的權限。
用postman腳本測試,zhang3(擁有admin角色以及pd權限)可以正常訪問,li4(沒有pd權限)則會返回異常。
異常返回
自行閱讀GlobalExceptionController即可,與本帖主題關系不大的代碼就不在這里專門說了。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionController {
// 身份驗證錯誤
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity authenticationExceptionHandler(AuthenticationException e) {
log.error("AuthenticationException");
log.error(e.getLocalizedMessage());
Map<String,Object> body=new HashMap<String,Object>();
body.put("status", HttpStatus.FORBIDDEN.value());
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.FORBIDDEN.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.FORBIDDEN);//僅是示例,按需求定義
}
//權限驗證錯誤
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity unauthorizedExceptionHandler(UnauthorizedException e) {
log.error("unauthorizedExceptionHandler");
log.error(e.getLocalizedMessage());
Map<String,Object> body=new HashMap<String,Object>();
body.put("status", HttpStatus.UNAUTHORIZED.value());
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.UNAUTHORIZED);//僅是示例,按需求定義
}
//對應路徑不存在
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity noHandlerFoundExceptionHandler(NoHandlerFoundException e) {
log.error("noHandlerFoundExceptionHandler");
log.error(e.getLocalizedMessage());
Map<String,Object> body=new HashMap<String,Object>();
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.NOT_FOUND.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.NOT_FOUND);//僅是示例,按需求定義
}
@ExceptionHandler(Exception.class)
public ResponseEntity exceptionHandler(Exception e) {
log.error("exceptionHandler");
log.error(e.getLocalizedMessage());
log.error(e.getStackTrace().toString());
Map<String,Object> body=new HashMap<String,Object>();
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.INTERNAL_SERVER_ERROR);//僅是示例,按需求定義
}
}