javascript
Shiro + JWT + Spring Boot Restful 简易教程
?作者:Smith-Cruise
github.com/Smith-Cruise/Spring-Boot-Shiro
特性
完全使用了 Shiro 的注解配置,保持高度的靈活性。
放棄 Cookie ,Session ,使用JWT進(jìn)行鑒權(quán),完全實(shí)現(xiàn)無狀態(tài)鑒權(quán)。
JWT 密鑰支持過期時(shí)間。
對跨域提供支持。
準(zhǔn)備工作
在開始本教程之前,請保證已經(jīng)熟悉以下幾點(diǎn)。
Spring Boot 基本語法,至少要懂得 Controller 、 RestController 、 Autowired 等這些基本注釋。其實(shí)看看官方的 Getting-Start 教程就差不多了。
JWT (Json Web Token)的基本概念,并且會(huì)簡單操作JWT的 JAVA SDK。
Shiro 的基本操作,看下官方的 10 Minute Tutorial 即可。
模擬 HTTP 請求工具,我使用的是 PostMan。
簡要的說明下我們?yōu)槭裁匆?JWT ,因?yàn)槲覀円獙?shí)現(xiàn)完全的前后端分離,所以不可能使用 session, cookie 的方式進(jìn)行鑒權(quán),所以 JWT 就被派上了用場,你可以通過一個(gè)加密密鑰來進(jìn)行前后端的鑒權(quán)。
程序邏輯
我們 POST 用戶名與密碼到 /login 進(jìn)行登入,如果成功返回一個(gè)加密 token,失敗的話直接返回 401 錯(cuò)誤。
之后用戶訪問每一個(gè)需要權(quán)限的網(wǎng)址請求必須在 header 中添加 Authorization 字段,例如 Authorization: token ,token 為密鑰。
后臺(tái)會(huì)進(jìn)行 token 的校驗(yàn),如果有誤會(huì)直接返回 401。
Token加密說明
攜帶了 username 信息在 token 中。
設(shè)定了過期時(shí)間。
使用用戶登入密碼對 token 進(jìn)行加密。
Token校驗(yàn)流程
獲得 token 中攜帶的 username 信息。
進(jìn)入數(shù)據(jù)庫搜索這個(gè)用戶,得到他的密碼。
使用用戶的密碼來檢驗(yàn) token 是否正確。
準(zhǔn)備Maven文件
新建一個(gè) Maven 工程,添加相關(guān)的 dependencies。
<?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>org.inlighting</groupId><artifactId>shiro-study</artifactId><version>1.0-SNAPSHOT</version><dependencies><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-spring</artifactId><version>1.3.2</version></dependency><dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>3.2.0</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><version>1.5.8.RELEASE</version></dependency></dependencies><build><plugins><!--?Srping?Boot?打包工具?--><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><version>1.5.7.RELEASE</version><executions><execution><goals><goal>repackage</goal></goals></execution></executions></plugin><!--?指定JDK編譯版本?--><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><configuration><source>1.8</source><target>1.8</target><encoding>UTF-8</encoding></configuration></plugin></plugins></build> </project>注意指定JDK版本和編碼。
構(gòu)建簡易的數(shù)據(jù)源
為了縮減教程的代碼,我使用 HashMap 本地模擬了一個(gè)數(shù)據(jù)庫,結(jié)構(gòu)如下:
| smith | smith123 | user | view |
| danny | danny123 | admin | view,edit |
這是一個(gè)最簡單的用戶權(quán)限表,如果想更加進(jìn)一步了解,自行百度 RBAC。
之后再構(gòu)建一個(gè) UserService 來模擬數(shù)據(jù)庫查詢,并且把結(jié)果放到 UserBean 之中。
UserService.java
@Component public?class?UserService?{public?UserBean?getUser(String?username)?{//?沒有此用戶直接返回nullif?(!?DataSource.getData().containsKey(username))return?null;UserBean?user?=?new?UserBean();Map<String,?String>?detail?=?DataSource.getData().get(username);user.setUsername(username);user.setPassword(detail.get("password"));user.setRole(detail.get("role"));user.setPermission(detail.get("permission"));return?user;} }UserBean.java
public?class?UserBean?{private?String?username;private?String?password;private?String?role;private?String?permission;public?String?getUsername()?{return?username;}public?void?setUsername(String?username)?{this.username?=?username;}public?String?getPassword()?{return?password;}public?void?setPassword(String?password)?{this.password?=?password;}public?String?getRole()?{return?role;}public?void?setRole(String?role)?{this.role?=?role;}public?String?getPermission()?{return?permission;}public?void?setPermission(String?permission)?{this.permission?=?permission;} }配置 JWT
我們寫一個(gè)簡單的 JWT 加密,校驗(yàn)工具,并且使用用戶自己的密碼充當(dāng)加密密鑰,這樣保證了 token 即使被他人截獲也無法破解。并且我們在 token 中附帶了 username 信息,并且設(shè)置密鑰5分鐘就會(huì)過期。
public?class?JWTUtil?{//?過期時(shí)間5分鐘private?static?final?long?EXPIRE_TIME?=?5*60*1000;/***?校驗(yàn)token是否正確*?@param?token?密鑰*?@param?secret?用戶的密碼*?@return?是否正確*/public?static?boolean?verify(String?token,?String?username,?String?secret)?{try?{Algorithm?algorithm?=?Algorithm.HMAC256(secret);JWTVerifier?verifier?=?JWT.require(algorithm).withClaim("username",?username).build();DecodedJWT?jwt?=?verifier.verify(token);return?true;}?catch?(Exception?exception)?{return?false;}}/***?獲得token中的信息無需secret解密也能獲得*?@return?token中包含的用戶名*/public?static?String?getUsername(String?token)?{try?{DecodedJWT?jwt?=?JWT.decode(token);return?jwt.getClaim("username").asString();}?catch?(JWTDecodeException?e)?{return?null;}}/***?生成簽名,5min后過期*?@param?username?用戶名*?@param?secret?用戶的密碼*?@return?加密的token*/public?static?String?sign(String?username,?String?secret)?{try?{Date?date?=?new?Date(System.currentTimeMillis()+EXPIRE_TIME);Algorithm?algorithm?=?Algorithm.HMAC256(secret);//?附帶username信息return?JWT.create().withClaim("username",?username).withExpiresAt(date).sign(algorithm);}?catch?(UnsupportedEncodingException?e)?{return?null;}} }構(gòu)建URL
ResponseBean.java
既然想要實(shí)現(xiàn) restful,那我們要保證每次返回的格式都是相同的,因此我建立了一個(gè) ResponseBean 來統(tǒng)一返回的格式。(搜索公眾號Java知音,回復(fù)“2021”,送你一份Java面試題寶典)
public?class?ResponseBean?{//?http?狀態(tài)碼private?int?code;//?返回信息private?String?msg;//?返回的數(shù)據(jù)private?Object?data;public?ResponseBean(int?code,?String?msg,?Object?data)?{this.code?=?code;this.msg?=?msg;this.data?=?data;}public?int?getCode()?{return?code;}public?void?setCode(int?code)?{this.code?=?code;}public?String?getMsg()?{return?msg;}public?void?setMsg(String?msg)?{this.msg?=?msg;}public?Object?getData()?{return?data;}public?void?setData(Object?data)?{this.data?=?data;} }自定義異常
為了實(shí)現(xiàn)我自己能夠手動(dòng)拋出異常,我自己寫了一個(gè) UnauthorizedException.java
public?class?UnauthorizedException?extends?RuntimeException?{public?UnauthorizedException(String?msg)?{super(msg);}public?UnauthorizedException()?{super();} }URL結(jié)構(gòu)
| /login | 登入 |
| /article | 所有人都可以訪問,但是用戶與游客看到的內(nèi)容不同 |
| /require_auth | 登入的用戶才可以進(jìn)行訪問 |
| /require_role | admin的角色用戶才可以登入 |
| /require_permission | 擁有view和edit權(quán)限的用戶才可以訪問 |
Controller
@RestController public?class?WebController?{private?static?final?Logger?LOGGER?=?LogManager.getLogger(WebController.class);private?UserService?userService;@Autowiredpublic?void?setService(UserService?userService)?{this.userService?=?userService;}@PostMapping("/login")public?ResponseBean?login(@RequestParam("username")?String?username,@RequestParam("password")?String?password)?{UserBean?userBean?=?userService.getUser(username);if?(userBean.getPassword().equals(password))?{return?new?ResponseBean(200,?"Login?success",?JWTUtil.sign(username,?password));}?else?{throw?new?UnauthorizedException();}}@GetMapping("/article")public?ResponseBean?article()?{Subject?subject?=?SecurityUtils.getSubject();if?(subject.isAuthenticated())?{return?new?ResponseBean(200,?"You?are?already?logged?in",?null);}?else?{return?new?ResponseBean(200,?"You?are?guest",?null);}}@GetMapping("/require_auth")@RequiresAuthenticationpublic?ResponseBean?requireAuth()?{return?new?ResponseBean(200,?"You?are?authenticated",?null);}@GetMapping("/require_role")@RequiresRoles("admin")public?ResponseBean?requireRole()?{return?new?ResponseBean(200,?"You?are?visiting?require_role",?null);}@GetMapping("/require_permission")@RequiresPermissions(logical?=?Logical.AND,?value?=?{"view",?"edit"})public?ResponseBean?requirePermission()?{return?new?ResponseBean(200,?"You?are?visiting?permission?require?edit,view",?null);}@RequestMapping(path?=?"/401")@ResponseStatus(HttpStatus.UNAUTHORIZED)public?ResponseBean?unauthorized()?{return?new?ResponseBean(401,?"Unauthorized",?null);} }處理框架異常
之前說過 restful 要統(tǒng)一返回的格式,所以我們也要全局處理 Spring Boot 的拋出異常。利用 @RestControllerAdvice 能很好的實(shí)現(xiàn)。
@RestControllerAdvice public?class?ExceptionController?{//?捕捉shiro的異常@ResponseStatus(HttpStatus.UNAUTHORIZED)@ExceptionHandler(ShiroException.class)public?ResponseBean?handle401(ShiroException?e)?{return?new?ResponseBean(401,?e.getMessage(),?null);}//?捕捉UnauthorizedException@ResponseStatus(HttpStatus.UNAUTHORIZED)@ExceptionHandler(UnauthorizedException.class)public?ResponseBean?handle401()?{return?new?ResponseBean(401,?"Unauthorized",?null);}//?捕捉其他所有異常@ExceptionHandler(Exception.class)@ResponseStatus(HttpStatus.BAD_REQUEST)public?ResponseBean?globalException(HttpServletRequest?request,?Throwable?ex)?{return?new?ResponseBean(getStatus(request).value(),?ex.getMessage(),?null);}private?HttpStatus?getStatus(HttpServletRequest?request)?{Integer?statusCode?=?(Integer)?request.getAttribute("javax.servlet.error.status_code");if?(statusCode?==?null)?{return?HttpStatus.INTERNAL_SERVER_ERROR;}return?HttpStatus.valueOf(statusCode);} }配置 Shiro
大家可以先看下官方的 Spring-Shiro 整合教程,有個(gè)初步的了解。不過既然我們用了 Spring-Boot,那我們肯定要爭取零配置文件。(搜索公眾號Java知音,回復(fù)“2021”,送你一份Java面試題寶典)
實(shí)現(xiàn)JWTToken
JWTToken 差不多就是 Shiro 用戶名密碼的載體。因?yàn)槲覀兪乔昂蠖朔蛛x,服務(wù)器無需保存用戶狀態(tài),所以不需要 RememberMe 這類功能,我們簡單的實(shí)現(xiàn)下 AuthenticationToken 接口即可。因?yàn)?token 自己已經(jīng)包含了用戶名等信息,所以這里我就弄了一個(gè)字段。如果你喜歡鉆研,可以看看官方的 UsernamePasswordToken 是如何實(shí)現(xiàn)的。
public?class?JWTToken?implements?AuthenticationToken?{//?密鑰private?String?token;public?JWTToken(String?token)?{this.token?=?token;}@Overridepublic?Object?getPrincipal()?{return?token;}@Overridepublic?Object?getCredentials()?{return?token;} }實(shí)現(xiàn)Realm
realm 的用于處理用戶是否合法的這一塊,需要我們自己實(shí)現(xiàn)。
@Service public?class?MyRealm?extends?AuthorizingRealm?{private?static?final?Logger?LOGGER?=?LogManager.getLogger(MyRealm.class);private?UserService?userService;@Autowiredpublic?void?setUserService(UserService?userService)?{this.userService?=?userService;}/***?大坑!,必須重寫此方法,不然Shiro會(huì)報(bào)錯(cuò)*/@Overridepublic?boolean?supports(AuthenticationToken?token)?{return?token?instanceof?JWTToken;}/***?只有當(dāng)需要檢測用戶權(quán)限的時(shí)候才會(huì)調(diào)用此方法,例如checkRole,checkPermission之類的*/@Overrideprotected?AuthorizationInfo?doGetAuthorizationInfo(PrincipalCollection?principals)?{String?username?=?JWTUtil.getUsername(principals.toString());UserBean?user?=?userService.getUser(username);SimpleAuthorizationInfo?simpleAuthorizationInfo?=?new?SimpleAuthorizationInfo();simpleAuthorizationInfo.addRole(user.getRole());Set<String>?permission?=?new?HashSet<>(Arrays.asList(user.getPermission().split(",")));simpleAuthorizationInfo.addStringPermissions(permission);return?simpleAuthorizationInfo;}/***?默認(rèn)使用此方法進(jìn)行用戶名正確與否驗(yàn)證,錯(cuò)誤拋出異常即可。*/@Overrideprotected?AuthenticationInfo?doGetAuthenticationInfo(AuthenticationToken?auth)?throws?AuthenticationException?{String?token?=?(String)?auth.getCredentials();//?解密獲得username,用于和數(shù)據(jù)庫進(jìn)行對比String?username?=?JWTUtil.getUsername(token);if?(username?==?null)?{throw?new?AuthenticationException("token?invalid");}UserBean?userBean?=?userService.getUser(username);if?(userBean?==?null)?{throw?new?AuthenticationException("User?didn't?existed!");}if?(!?JWTUtil.verify(token,?username,?userBean.getPassword()))?{throw?new?AuthenticationException("Username?or?password?error");}return?new?SimpleAuthenticationInfo(token,?token,?"my_realm");} }在 doGetAuthenticationInfo() 中用戶可以自定義拋出很多異常,詳情見文檔。
重寫 Filter
所有的請求都會(huì)先經(jīng)過 Filter,所以我們繼承官方的 BasicHttpAuthenticationFilter ,并且重寫鑒權(quán)的方法。
代碼的執(zhí)行流程 preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin 。
public?class?JWTFilter?extends?BasicHttpAuthenticationFilter?{private?Logger?LOGGER?=?LoggerFactory.getLogger(this.getClass());/***?判斷用戶是否想要登入。*?檢測header里面是否包含Authorization字段即可*/@Overrideprotected?boolean?isLoginAttempt(ServletRequest?request,?ServletResponse?response)?{HttpServletRequest?req?=?(HttpServletRequest)?request;String?authorization?=?req.getHeader("Authorization");return?authorization?!=?null;}/****/@Overrideprotected?boolean?executeLogin(ServletRequest?request,?ServletResponse?response)?throws?Exception?{HttpServletRequest?httpServletRequest?=?(HttpServletRequest)?request;String?authorization?=?httpServletRequest.getHeader("Authorization");JWTToken?token?=?new?JWTToken(authorization);//?提交給realm進(jìn)行登入,如果錯(cuò)誤他會(huì)拋出異常并被捕獲getSubject(request,?response).login(token);//?如果沒有拋出異常則代表登入成功,返回truereturn?true;}/***?這里我們詳細(xì)說明下為什么最終返回的都是true,即允許訪問*?例如我們提供一個(gè)地址?GET?/article*?登入用戶和游客看到的內(nèi)容是不同的*?如果在這里返回了false,請求會(huì)被直接攔截,用戶看不到任何東西*?所以我們在這里返回true,Controller中可以通過?subject.isAuthenticated()?來判斷用戶是否登入*?如果有些資源只有登入用戶才能訪問,我們只需要在方法上面加上?@RequiresAuthentication?注解即可*?但是這樣做有一個(gè)缺點(diǎn),就是不能夠?qū)ET,POST等請求進(jìn)行分別過濾鑒權(quán)(因?yàn)槲覀冎貙懥斯俜降姆椒?,但實(shí)際上對應(yīng)用影響不大*/@Overrideprotected?boolean?isAccessAllowed(ServletRequest?request,?ServletResponse?response,?Object?mappedValue)?{if?(isLoginAttempt(request,?response))?{try?{executeLogin(request,?response);}?catch?(Exception?e)?{response401(request,?response);}}return?true;}/***?對跨域提供支持*/@Overrideprotected?boolean?preHandle(ServletRequest?request,?ServletResponse?response)?throws?Exception?{HttpServletRequest?httpServletRequest?=?(HttpServletRequest)?request;HttpServletResponse?httpServletResponse?=?(HttpServletResponse)?response;httpServletResponse.setHeader("Access-control-Allow-Origin",?httpServletRequest.getHeader("Origin"));httpServletResponse.setHeader("Access-Control-Allow-Methods",?"GET,POST,OPTIONS,PUT,DELETE");httpServletResponse.setHeader("Access-Control-Allow-Headers",?httpServletRequest.getHeader("Access-Control-Request-Headers"));//?跨域時(shí)會(huì)首先發(fā)送一個(gè)option請求,這里我們給option請求直接返回正常狀態(tài)if?(httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name()))?{httpServletResponse.setStatus(HttpStatus.OK.value());return?false;}return?super.preHandle(request,?response);}/***?將非法請求跳轉(zhuǎn)到?/401*/private?void?response401(ServletRequest?req,?ServletResponse?resp)?{try?{HttpServletResponse?httpServletResponse?=?(HttpServletResponse)?resp;httpServletResponse.sendRedirect("/401");}?catch?(IOException?e)?{LOGGER.error(e.getMessage());}} }getSubject(request, response).login(token); 這一步就是提交給了 realm 進(jìn)行處理。
配置Shiro
@Configuration public?class?ShiroConfig?{@Bean("securityManager")public?DefaultWebSecurityManager?getManager(MyRealm?realm)?{DefaultWebSecurityManager?manager?=?new?DefaultWebSecurityManager();//?使用自己的realmmanager.setRealm(realm);/**?關(guān)閉shiro自帶的session,詳情見文檔*?http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29*/DefaultSubjectDAO?subjectDAO?=?new?DefaultSubjectDAO();DefaultSessionStorageEvaluator?defaultSessionStorageEvaluator?=?new?DefaultSessionStorageEvaluator();defaultSessionStorageEvaluator.setSessionStorageEnabled(false);subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);manager.setSubjectDAO(subjectDAO);return?manager;}@Bean("shiroFilter")public?ShiroFilterFactoryBean?factory(DefaultWebSecurityManager?securityManager)?{ShiroFilterFactoryBean?factoryBean?=?new?ShiroFilterFactoryBean();//?添加自己的過濾器并且取名為jwtMap<String,?Filter>?filterMap?=?new?HashMap<>();filterMap.put("jwt",?new?JWTFilter());factoryBean.setFilters(filterMap);factoryBean.setSecurityManager(securityManager);factoryBean.setUnauthorizedUrl("/401");/**?自定義url規(guī)則*?http://shiro.apache.org/web.html#urls-*/Map<String,?String>?filterRuleMap?=?new?HashMap<>();//?所有請求通過我們自己的JWT?FilterfilterRuleMap.put("/**",?"jwt");//?訪問401和404頁面不通過我們的FilterfilterRuleMap.put("/401",?"anon");factoryBean.setFilterChainDefinitionMap(filterRuleMap);return?factoryBean;}/***?下面的代碼是添加注解支持*/@Bean@DependsOn("lifecycleBeanPostProcessor")public?DefaultAdvisorAutoProxyCreator?defaultAdvisorAutoProxyCreator()?{DefaultAdvisorAutoProxyCreator?defaultAdvisorAutoProxyCreator?=?new?DefaultAdvisorAutoProxyCreator();//?強(qiáng)制使用cglib,防止重復(fù)代理和可能引起代理出錯(cuò)的問題//?https://zhuanlan.zhihu.com/p/29161098defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);return?defaultAdvisorAutoProxyCreator;}@Beanpublic?LifecycleBeanPostProcessor?lifecycleBeanPostProcessor()?{return?new?LifecycleBeanPostProcessor();}@Beanpublic?AuthorizationAttributeSourceAdvisor?authorizationAttributeSourceAdvisor(DefaultWebSecurityManager?securityManager)?{AuthorizationAttributeSourceAdvisor?advisor?=?new?AuthorizationAttributeSourceAdvisor();advisor.setSecurityManager(securityManager);return?advisor;} }里面 URL 規(guī)則自己參考文檔即可 http://shiro.apache.org/web.html 。
總結(jié)
我就說下代碼還有哪些可以進(jìn)步的地方吧
沒有實(shí)現(xiàn) Shiro 的 Cache 功能。
Shiro 中鑒權(quán)失敗時(shí)不能夠直接返回 401 信息,而是通過跳轉(zhuǎn)到 /401 地址實(shí)現(xiàn)。
GitHub 項(xiàng)目地址:
https://github.com/Smith-Cruise/Spring-Boot-Shiro
推薦文章2021 最新版 Spring Boot 速記教程
2W 字你全面認(rèn)識(shí) Nginx
47K Star 的SpringBoot+MyBatis+docker電商項(xiàng)目,附帶超詳細(xì)的文檔!
寫博客能月入10K?
一款基于 Spring Boot 的現(xiàn)代化社區(qū)(論壇/問答/社交網(wǎng)絡(luò)/博客)
這或許是最美的Vue+Element開源后臺(tái)管理UI
推薦一款高顏值的 Spring Boot 快速開發(fā)框架
一款基于 Spring Boot 的現(xiàn)代化社區(qū)(論壇/問答/社交網(wǎng)絡(luò)/博客)
13K點(diǎn)贊都基于 Vue+Spring 前后端分離管理系統(tǒng)ELAdmin,大愛
想接私活時(shí)薪再翻一倍,建議根據(jù)這幾個(gè)開源的SpringBoot項(xiàng)目
總結(jié)
以上是生活随笔為你收集整理的Shiro + JWT + Spring Boot Restful 简易教程的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 我用Java写了个女朋友,甚至还能跟我聊
- 下一篇: JDK8 Stream 效率如何?