javascript
SpringBoot中使用Shiro和JWT做认证和鉴权
最近新做的項(xiàng)目中使用了shiro和jwt來做簡單的權(quán)限驗(yàn)證,在和springboot集成的過程中碰到了不少坑。做完之后對(duì)shiro的體系架構(gòu)了解的也差不多了,現(xiàn)在把中間需要注意的點(diǎn)放出來,給大家做個(gè)參考。
相對(duì)于spring security來說,shiro出來較早,框架也相對(duì)簡單。后面會(huì)另起一篇文章對(duì)這兩個(gè)框架做一個(gè)簡單的對(duì)比。
Shiro的關(guān)注點(diǎn)
首先看一下shiro中需要關(guān)注的幾個(gè)概念。
- SecurityManager,可以理解成控制中心,所有請(qǐng)求最終基本上都通過它來代理轉(zhuǎn)發(fā),一般我們程序中不需要直接跟他打交道。
- Subject ,請(qǐng)求主體。比如登錄用戶,比如一個(gè)被授權(quán)的app。在程序中任何地方都可以通過SecurityUtils.getSubject()獲取到當(dāng)前的subject。subject中可以獲取到Principal,這個(gè)是subject的標(biāo)識(shí),比如登陸用戶的用戶名或者id等,shiro不對(duì)值做限制。但是在登錄和授權(quán)過程中,程序需要通過principal來識(shí)別唯一的用戶。
- Realm,這個(gè)實(shí)在不知道怎么翻譯合適。通俗一點(diǎn)理解就是realm可以訪問安全相關(guān)數(shù)據(jù),提供統(tǒng)一的數(shù)據(jù)封裝來給上層做數(shù)據(jù)校驗(yàn)。shiro的建議是每種數(shù)據(jù)源定義一個(gè)realm,比如用戶數(shù)據(jù)存在數(shù)據(jù)庫可以使用JdbcRealm;存在屬性配置文件可以使用PropertiesRealm。一般我們使用shiro都使用自定義的realm。
當(dāng)有多個(gè)realm存在的時(shí)候,shiro在做用戶校驗(yàn)的時(shí)候會(huì)按照定義的策略來決定認(rèn)證是否通過,shiro提供的可選策略有一個(gè)成功或者所有都成功等。
一個(gè)realm對(duì)應(yīng)了一個(gè)CredentialsMatcher,用來做用戶提交認(rèn)證信息和realm獲取得用戶信息做比對(duì),shiro已經(jīng)提供了常用的比如用戶密碼和存儲(chǔ)的Hash后的密碼的對(duì)比。
JWT的應(yīng)用場景
關(guān)于JWT是什么,請(qǐng)參考JWT官網(wǎng)。這里就不多解釋了,可理解為使用帶簽名的token來做用戶和權(quán)限驗(yàn)證,現(xiàn)在流行的公共開放接口用的OAuth 2.0協(xié)議基本也是類似的套路。這里只是說下選擇使用jwt不用session的原因。
首先,是要支持多端,一個(gè)api要支持H5, PC和APP三個(gè)前端,如果使用session的話對(duì)app不是很友好,而且session有跨域攻擊的問題。
其次,后端的服務(wù)是無狀態(tài)的,所以要支持分布式的權(quán)限校驗(yàn)。當(dāng)然這個(gè)不是主要原因了,因?yàn)閟ession持久化在spring里面也就是加一行注解就解決的問題。不過,spring通過代理httpsession來做,總歸覺得有點(diǎn)復(fù)雜。
項(xiàng)目搭建
需求
需求相對(duì)簡單,1)支持用戶首次通過用戶名和密碼登錄;2)登錄后通過http header返回token;3)每次請(qǐng)求,客戶端需通過header將token帶回,用于權(quán)限校驗(yàn);4)服務(wù)端負(fù)責(zé)token的定期刷新,刷新后新的token仍然放到header中返給客戶端
pom.xml
這里使用了shiro的web starter。jwt是用的auth0的工具包,其實(shí)自己實(shí)現(xiàn)也比較簡單,我們這里就不自己重新造輪子了。
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.github.springboot</groupId><artifactId>shiro-jwt-demo</artifactId><version>1.0-SNAPSHOT</version><packaging>jar</packaging><name>Spring Boot with Shiro and JWT Demo</name><description>Demo project for Spring Boot with Shiro and JWT</description><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.0.4.RELEASE</version></parent><properties><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding><java.version>1.8</java.version><shiro.spring.version>1.4.0</shiro.spring.version><jwt.auth0.version>3.2.0</jwt.auth0.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId></dependency><!-- 使用redis做數(shù)據(jù)緩存,如果不需要可不依賴 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-spring-boot-web-starter</artifactId><version>${shiro.spring.version}</version></dependency><dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>${jwt.auth0.version}</version></dependency><dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpclient</artifactId><version>4.5.5</version></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.7</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><configuration><source>${java.version}</source><target>${java.version}</target></configuration></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><configuration><skipTests>true</skipTests></configuration></plugin></plugins></build> </project>shiro 配置
ShiroConfiguration
首先是初始化shiro的bean,主要是初始化Realm,注冊(cè)Filter,定義filterChain。這些配置的用處后面會(huì)逐漸講到。
校驗(yàn)流程
我們使用Shiro主要做3件事情,1)用戶登錄時(shí)做用戶名密碼校驗(yàn);2)用戶登錄后收到請(qǐng)求時(shí)做JWT Token的校驗(yàn);3)用戶權(quán)限的校驗(yàn)
登錄認(rèn)證流程
登錄controller
從前面的ShiroFilterChainDefinition配置可以看出,對(duì)于登錄請(qǐng)求,Filter直接放過,進(jìn)到controller里面。Controller會(huì)調(diào)用shiro做用戶名和密碼的校驗(yàn),成功后返回token。
登錄的Realm
從上面的controller實(shí)現(xiàn)我們看到,controller只負(fù)責(zé)封裝下參數(shù),然后扔給Shiro了,這時(shí)候Shiro收到后,會(huì)到所有的realm中找能處理UsernamePasswordToken的Realm(我們這里是DbShiroRealm),然后交給Realm處理。Realm的實(shí)現(xiàn)一般直接繼承AuthorizingRealm即可,只需要實(shí)現(xiàn)兩個(gè)方法,doGetAuthenticationInfo()會(huì)在用戶驗(yàn)證時(shí)被調(diào)用,我們看下實(shí)現(xiàn)。
我們可以看到doGetAuthenticationInfo里面只判斷了用戶存不存在,其實(shí)也沒做密碼比對(duì),只是把數(shù)據(jù)庫的數(shù)據(jù)封裝一下就返回了。真正的比對(duì)邏輯在Matcher里實(shí)現(xiàn)的,這個(gè)shiro已經(jīng)替我們實(shí)現(xiàn)了。如果matcher返回false,shiro會(huì)拋出異常,這樣controller那邊就會(huì)知道驗(yàn)證失敗了。
登出
登出操作就比較簡單了,我們只需要把用戶登錄后保存的salt值清除,然后調(diào)用shiro的logout就可以了,shiro會(huì)將剩下的事情做完。
這樣整個(gè)登錄/登出就結(jié)束了,我們可以看到shiro對(duì)整個(gè)邏輯的拆解還是比較清楚的,各個(gè)模塊各司其職。
請(qǐng)求認(rèn)證流程
請(qǐng)求認(rèn)證的流程其實(shí)和登錄認(rèn)證流程是比較相似的,因?yàn)槲覀兊姆?wù)是無狀態(tài)的,所以每次請(qǐng)求帶來token,我們就是做了一次登錄操作。
JwtAuthFilter
首先我們先從入口的Filter開始。從AuthenticatingFilter繼承,重寫isAccessAllow方法,方法中調(diào)用父類executeLogin()。父類的這個(gè)方法首先會(huì)createToken(),然后調(diào)用shiro的Subject.login()方法。是不是跟LoginController中的邏輯很像。
JWT token封裝
在上面的Filter中我們創(chuàng)建了一個(gè)Token提交給了shiro,我們看下這個(gè)Token,其實(shí)很簡單,就是把jwt的token放在里面。
JWT Realm
Token有了,filter中也調(diào)用了shiro的login()方法了,下一步自然是Shiro把token提交到Realm中,獲取存儲(chǔ)的認(rèn)證信息來做比對(duì)。
JWT Matcher
跟controller登錄不一樣,shiro并沒有實(shí)現(xiàn)JWT的Matcher,需要我們自己來實(shí)現(xiàn)。代碼如下:
這樣非登錄請(qǐng)求的認(rèn)證處理邏輯也結(jié)束了,看起來是不是跟登錄邏輯差不多。其實(shí)對(duì)于無狀態(tài)服務(wù)來說,每次請(qǐng)求都相當(dāng)于做了一次登錄操作,我們用session的時(shí)候之所以不需要做,是因?yàn)槿萜鞔嫖覀儼堰@件事干掉了。
關(guān)于permissive
前面Filter里面的isAccessAllow方法,除了使用jwt token做了shiro的登錄認(rèn)證之外,如果返回false還會(huì)額外調(diào)用isPermissive()方法。這里面干了什么呢?我們看下父類的方法:
邏輯很簡單,如果filter的攔截配置那里配置了permissive參數(shù),即使登錄認(rèn)證沒通過,因?yàn)閕sPermissive返回true,還是會(huì)讓請(qǐng)求繼續(xù)下去的。細(xì)心的同學(xué)或許已經(jīng)發(fā)現(xiàn)我們之前shiroConfig里面的配置了,截取過來看一下:
chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]"); //做用戶認(rèn)證,permissive參數(shù)的作用是當(dāng)token無效時(shí)也允許請(qǐng)求訪問,不會(huì)返回鑒權(quán)未通過的錯(cuò)誤就是這么簡單直接,字符串匹配。當(dāng)然這里也可以重寫這個(gè)方法插入更復(fù)雜的邏輯。
這么做的目的是什么呢?因?yàn)橛袝r(shí)候我們對(duì)待請(qǐng)求,并不都是非黑即白,比如登出操作,如果用戶帶的token是正確的,我們會(huì)將保存的用戶信息清除;如果帶的token是錯(cuò)的,也沒關(guān)系,大不了不干啥,沒必要返回錯(cuò)誤給用戶。還有一個(gè)典型的案例,比如我們閱讀博客,匿名用戶也是可以看的。只是如果是登錄用戶,我們會(huì)顯示額外的東西,比如是不是點(diǎn)過贊等。所以認(rèn)證這里的邏輯就是token是對(duì)的,我會(huì)給把人認(rèn)出來;是錯(cuò)的,我也直接放過,留給controller來決定怎么區(qū)別對(duì)待。
JWT Token刷新
前面的Filter里面還有一個(gè)邏輯(是不是太多了??),就是如果用戶這次的token校驗(yàn)通過后,我們還會(huì)順便看看token要不要刷新,如果需要刷新則將新的token放到header里面。
這樣做的目的是防止token丟了之后,別人可以拿著一直用。我們這里是固定時(shí)間刷新。安全性要求更高的系統(tǒng)可能每次請(qǐng)求都要求刷新,或者是每次POST,PUT等修改數(shù)據(jù)的請(qǐng)求后必須刷新。判斷邏輯如下:
以上就是jwt token校驗(yàn)的所有邏輯了,是不是有點(diǎn)繞,畫一個(gè)流程圖出來,對(duì)比著看應(yīng)該更清楚一點(diǎn)。
jwt filter邏輯
角色配置
認(rèn)證講完了,下面看下訪問控制。對(duì)于角色檢查的攔截,是通過繼承一個(gè)AuthorizationFilter的Filter來實(shí)現(xiàn)的。Shiro提供了一個(gè)默認(rèn)的實(shí)現(xiàn)RolesAuthorizationFilter,比如可以這么配置:
chainDefinition.addPathDefinition("/article/edit", "authc,role[admin]");表示要做文章的edit操作,需要滿足兩個(gè)條件,首先authc表示要通過用戶認(rèn)證,這個(gè)我們上面已經(jīng)講過了;其次要具備admin的角色。shiro是怎么做的呢?就是在請(qǐng)求進(jìn)入這個(gè)filter后,shiro會(huì)調(diào)用所有配置的Realm獲取用戶的角色信息,然后和Filter中配置的角色做對(duì)比,對(duì)上了就可以通過了。
所以我們所有的Realm還要另外一個(gè)方法doGetAuthorizationInfo,不得不吐槽一下,realm里面要實(shí)現(xiàn)的這兩個(gè)方法的名字實(shí)在太像了。
在JWT Realm里面,因?yàn)闆]有存儲(chǔ)角色信息,所以直接返回空就可以了:
在DbRealm里面,實(shí)現(xiàn)如下:
@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();UserDto user = (UserDto) principals.getPrimaryPrincipal();List<String> roles = user.getRoles();if(roles == null) {roles = userService.getUserRoles(user.getUserId());user.setRoles(roles);}if (roles != null)simpleAuthorizationInfo.addRoles(roles);return simpleAuthorizationInfo;}這里需要注意一下的就是Shiro默認(rèn)不會(huì)緩存角色信息,所以這里調(diào)用service的方法獲取角色強(qiáng)烈建議從緩存中獲取。
自己實(shí)現(xiàn)RoleFilter
在實(shí)際的項(xiàng)目中,對(duì)同一個(gè)url多個(gè)角色都有訪問權(quán)限很常見,shiro默認(rèn)的RoleFilter沒有提供支持,比如上面的配置,如果我們配置成下面這樣,那用戶必須同時(shí)具備admin和manager權(quán)限才能訪問,顯然這個(gè)是不合理的。
所以自己實(shí)現(xiàn)一個(gè)role filter,只要任何一個(gè)角色符合條件就通過,只需要重寫AuthorizationFilter中兩個(gè)方法就可以了:
public class AnyRolesAuthorizationFilter extends AuthorizationFilter {@Overrideprotected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {Subject subject = getSubject(servletRequest, servletResponse);String[] rolesArray = (String[]) mappedValue;if (rolesArray == null || rolesArray.length == 0) { //沒有角色限制,有權(quán)限訪問return true;}for (String role : rolesArray) {if (subject.hasRole(role)) //若當(dāng)前用戶是rolesArray中的任何一個(gè),則有權(quán)限訪問return true;}return false;}/*** 權(quán)限校驗(yàn)失敗,錯(cuò)誤處理*/@Overrideprotected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {HttpServletResponse httpResponse = WebUtils.toHttp(response);httpResponse.setCharacterEncoding("UTF-8");httpResponse.setContentType("application/json;charset=utf-8");httpResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);return false;}}禁用session
因?yàn)橛昧薺wt的訪問認(rèn)證,所以要把默認(rèn)session支持關(guān)掉。這里要做兩件事情,一個(gè)是ShiroConfig里面的配置:
@Beanprotected SessionStorageEvaluator sessionStorageEvaluator(){DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();sessionStorageEvaluator.setSessionStorageEnabled(false);return sessionStorageEvaluator;}另外一個(gè)是在對(duì)請(qǐng)求加上noSessionCreationFilter,具體原因上面的代碼中已經(jīng)有解釋,用法如下:
chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");跨域支持
對(duì)于前后端分離的項(xiàng)目,一般都需要跨域訪問,這里需要做兩件事,一個(gè)是在JwtFilter的postHandle中在頭上加上跨域支持的選項(xiàng)(理論上應(yīng)該重新定義一個(gè)Filter的,圖省事就讓它多干點(diǎn)吧??)。
@Overrideprotected void postHandle(ServletRequest request, ServletResponse response){this.fillCorsHeader(WebUtils.toHttp(request), WebUtils.toHttp(response));}在實(shí)際使用中發(fā)現(xiàn),對(duì)于controller返回@ResponseBody的請(qǐng)求,filter中添加的header信息會(huì)丟失。對(duì)于這個(gè)問題spring已經(jīng)給出解釋,并建議實(shí)現(xiàn)ResponseBodyAdvice類,并添加@ControllerAdvice。
public interface ResponseBodyAdvice
Allows customizing the response after the execution of an @ResponseBody or a ResponseEntity controller method but >before the body is written with an HttpMessageConverter.
Implementations may be registered directly with RequestMappingHandlerAdapter and ExceptionHandlerExceptionResolver or more likely annotated with @ControllerAdvice in which case they will be auto-detected by both.
所以如果存在返回@ResponseBody的controller,需要添加一個(gè)ResponseBodyAdvice實(shí)現(xiàn)類
@ControllerAdvice public class ResponseHeaderAdvice implements ResponseBodyAdvice<Object> {@Overridepublic boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {return true;}@Overridepublic Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass,ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {ServletServerHttpRequest serverRequest = (ServletServerHttpRequest)serverHttpRequest;ServletServerHttpResponse serverResponse = (ServletServerHttpResponse)serverHttpResponse;if(serverRequest == null || serverResponse == null|| serverRequest.getServletRequest() == null || serverResponse.getServletResponse() == null) {return o;}// 對(duì)于未添加跨域消息頭的響應(yīng)進(jìn)行處理HttpServletRequest request = serverRequest.getServletRequest();HttpServletResponse response = serverResponse.getServletResponse();String originHeader = "Access-Control-Allow-Origin";if(!response.containsHeader(originHeader)) {String origin = request.getHeader("Origin");if(origin == null) {String referer = request.getHeader("Referer");if(referer != null)origin = referer.substring(0, referer.indexOf("/", 7));}response.setHeader("Access-Control-Allow-Origin", origin);}String allowHeaders = "Access-Control-Allow-Headers";if(!response.containsHeader(allowHeaders))response.setHeader(allowHeaders, request.getHeader(allowHeaders));String allowMethods = "Access-Control-Allow-Methods";if(!response.containsHeader(allowMethods))response.setHeader(allowMethods, "GET,POST,OPTIONS,HEAD");//這個(gè)很關(guān)鍵,要不然ajax調(diào)用時(shí)瀏覽器默認(rèn)不會(huì)把這個(gè)token的頭屬性返給JSString exposeHeaders = "access-control-expose-headers";if(!response.containsHeader(exposeHeaders))response.setHeader(exposeHeaders, "x-auth-token");return o;} }好了,到這里使用shiro和jwt做用戶認(rèn)證和鑒權(quán)的實(shí)現(xiàn)就結(jié)束了。
總結(jié)
以上是生活随笔為你收集整理的SpringBoot中使用Shiro和JWT做认证和鉴权的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java基础day16
- 下一篇: SpringBoot整合阿里云OSS上传