javascript
Shiro + JWT + Spring Boot Restful 简易教程
?作者:Smith-Cruise
github.com/Smith-Cruise/Spring-Boot-Shiro
特性
完全使用了 Shiro 的注解配置,保持高度的靈活性。
放棄 Cookie ,Session ,使用JWT進行鑒權(quán),完全實現(xiàn)無狀態(tài)鑒權(quán)。
JWT 密鑰支持過期時間。
對跨域提供支持。
準備工作
在開始本教程之前,請保證已經(jīng)熟悉以下幾點。
Spring Boot 基本語法,至少要懂得 Controller 、 RestController 、 Autowired 等這些基本注釋。其實看看官方的 Getting-Start 教程就差不多了。
JWT (Json Web Token)的基本概念,并且會簡單操作JWT的 JAVA SDK。
Shiro 的基本操作,看下官方的 10 Minute Tutorial 即可。
模擬 HTTP 請求工具,我使用的是 PostMan。
簡要的說明下我們?yōu)槭裁匆?JWT ,因為我們要實現(xiàn)完全的前后端分離,所以不可能使用 session, cookie 的方式進行鑒權(quán),所以 JWT 就被派上了用場,你可以通過一個加密密鑰來進行前后端的鑒權(quán)。
程序邏輯
我們 POST 用戶名與密碼到 /login 進行登入,如果成功返回一個加密 token,失敗的話直接返回 401 錯誤。
之后用戶訪問每一個需要權(quán)限的網(wǎng)址請求必須在 header 中添加 Authorization 字段,例如 Authorization: token ,token 為密鑰。
后臺會進行 token 的校驗,如果有誤會直接返回 401。
Token加密說明
攜帶了 username 信息在 token 中。
設(shè)定了過期時間。
使用用戶登入密碼對 token 進行加密。
Token校驗流程
獲得 token 中攜帶的 username 信息。
進入數(shù)據(jù)庫搜索這個用戶,得到他的密碼。
使用用戶的密碼來檢驗 token 是否正確。
準備Maven文件
新建一個 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 本地模擬了一個數(shù)據(jù)庫,結(jié)構(gòu)如下:
| smith | smith123 | user | view |
| danny | danny123 | admin | view,edit |
這是一個最簡單的用戶權(quán)限表,如果想更加進一步了解,自行百度 RBAC。
之后再構(gòu)建一個 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
我們寫一個簡單的 JWT 加密,校驗工具,并且使用用戶自己的密碼充當加密密鑰,這樣保證了 token 即使被他人截獲也無法破解。并且我們在 token 中附帶了 username 信息,并且設(shè)置密鑰5分鐘就會過期。
public?class?JWTUtil?{//?過期時間5分鐘private?static?final?long?EXPIRE_TIME?=?5*60*1000;/***?校驗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
既然想要實現(xiàn) restful,那我們要保證每次返回的格式都是相同的,因此我建立了一個 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;} }自定義異常
為了實現(xiàn)我自己能夠手動拋出異常,我自己寫了一個 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 | 登入的用戶才可以進行訪問 |
| /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 能很好的實現(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 整合教程,有個初步的了解。不過既然我們用了 Spring-Boot,那我們肯定要爭取零配置文件。(搜索公眾號Java知音,回復(fù)“2021”,送你一份Java面試題寶典)
實現(xiàn)JWTToken
JWTToken 差不多就是 Shiro 用戶名密碼的載體。因為我們是前后端分離,服務(wù)器無需保存用戶狀態(tài),所以不需要 RememberMe 這類功能,我們簡單的實現(xiàn)下 AuthenticationToken 接口即可。因為 token 自己已經(jīng)包含了用戶名等信息,所以這里我就弄了一個字段。如果你喜歡鉆研,可以看看官方的 UsernamePasswordToken 是如何實現(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;} }實現(xiàn)Realm
realm 的用于處理用戶是否合法的這一塊,需要我們自己實現(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會報錯*/@Overridepublic?boolean?supports(AuthenticationToken?token)?{return?token?instanceof?JWTToken;}/***?只有當需要檢測用戶權(quán)限的時候才會調(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;}/***?默認使用此方法進行用戶名正確與否驗證,錯誤拋出異常即可。*/@Overrideprotected?AuthenticationInfo?doGetAuthenticationInfo(AuthenticationToken?auth)?throws?AuthenticationException?{String?token?=?(String)?auth.getCredentials();//?解密獲得username,用于和數(shù)據(jù)庫進行對比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
所有的請求都會先經(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進行登入,如果錯誤他會拋出異常并被捕獲getSubject(request,?response).login(token);//?如果沒有拋出異常則代表登入成功,返回truereturn?true;}/***?這里我們詳細說明下為什么最終返回的都是true,即允許訪問*?例如我們提供一個地址?GET?/article*?登入用戶和游客看到的內(nèi)容是不同的*?如果在這里返回了false,請求會被直接攔截,用戶看不到任何東西*?所以我們在這里返回true,Controller中可以通過?subject.isAuthenticated()?來判斷用戶是否登入*?如果有些資源只有登入用戶才能訪問,我們只需要在方法上面加上?@RequiresAuthentication?注解即可*?但是這樣做有一個缺點,就是不能夠?qū)ET,POST等請求進行分別過濾鑒權(quán)(因為我們重寫了官方的方法),但實際上對應(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"));//?跨域時會首先發(fā)送一個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 進行處理。
配置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();//?強制使用cglib,防止重復(fù)代理和可能引起代理出錯的問題//?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é)
我就說下代碼還有哪些可以進步的地方吧
沒有實現(xiàn) Shiro 的 Cache 功能。
Shiro 中鑒權(quán)失敗時不能夠直接返回 401 信息,而是通過跳轉(zhuǎn)到 /401 地址實現(xiàn)。
GitHub 項目地址:
https://github.com/Smith-Cruise/Spring-Boot-Shiro
推薦文章2021 最新版 Spring Boot 速記教程
2W 字你全面認識 Nginx
47K Star 的SpringBoot+MyBatis+docker電商項目,附帶超詳細的文檔!
寫博客能月入10K?
一款基于 Spring Boot 的現(xiàn)代化社區(qū)(論壇/問答/社交網(wǎng)絡(luò)/博客)
這或許是最美的Vue+Element開源后臺管理UI
推薦一款高顏值的 Spring Boot 快速開發(fā)框架
一款基于 Spring Boot 的現(xiàn)代化社區(qū)(論壇/問答/社交網(wǎng)絡(luò)/博客)
13K點贊都基于 Vue+Spring 前后端分離管理系統(tǒng)ELAdmin,大愛
想接私活時薪再翻一倍,建議根據(jù)這幾個開源的SpringBoot項目
總結(jié)
以上是生活随笔為你收集整理的Shiro + JWT + Spring Boot Restful 简易教程的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 我用Java写了个女朋友,甚至还能跟我聊
- 下一篇: JDK8 Stream 效率如何?