Shiro 是什麼 ?
不多說了,一個java 安全認證框架 ,類似於Spring-security,兩者異同網上多了去,個人比較喜歡shiro ,簡單易懂。
JWT 是什麼 ?
java web token 的簡稱
爲什麼要 shiro + JWT ?
現在微服務架構,前後端分離架構,綜合 PC、 APP 、小程序、微信等,採用一個令牌作爲登錄用戶身份的象徵,不再用原來的瀏覽器Session作爲登錄憑證。
小程序 微信 等應該沒有 cookie 、session概念。
採用統一 jwt 一勞永逸解決所有前端過來的請求身份認證問題,減輕服務器內存存儲壓力。
JWT 的利弊網上分析的夠透徹了,這裏不多說了。
SpringBoot 自定義starter是什麼 ?
springboot 精髓之一,不多介紹。如果還不明白springboot的精髓,請務必查看下面大神的微信文章:
https://mp.weixin.qq.com/s/SY7H7EjLN5CpE33k1QKLfA
爲什麼要自定義一個starter ?
減輕新項目的配置複雜度,統一配置管理。不會自定義starter的,強烈建議查看下面大神的博客:
https://www.jianshu.com/p/4735fe7ae921
開始搞起來
建一個maven工程
工程名稱叫做 shirojwt-spring-boot-starter,先貼出 pom.xml文件內容:
<?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>
<!--================ Spring Boot ===================== -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.guzt</groupId>
<artifactId>shirojwt-spring-boot-starter</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<java.version>1.8</java.version>
<!-- Shiro JWT Begin -->
<java.jwt.version>3.10.2</java.jwt.version>
<shiro.spring.version>1.5.2</shiro.spring.version>
<!-- Shiro JWT End -->
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<!-- 用於使用Spring AOP和AspectJ實現面向切面編程 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<!-- shiro -->
<!-- https://mvnrepository.com/artifact/org.apache.shiro/shiro-spring -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro.spring.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${java.jwt.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<!-- 生成sources源碼包的插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<configuration>
<attach>true</attach>
</configuration>
<executions>
<execution>
<id>attach-sources</id>
<!--意思是在什麼階段打包源文件-->
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
<execution>
<id>install-sources</id>
<!--意思是在什麼階段打包源文件-->
<phase>install</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
上面都是要用到的jar,其實starter最重要的兩個jar依賴:
spring-boot-starter 和 spring-boot-configuration-processor (用於配置文件屬性封裝)
spring-boot-starter-aop 其實可以不用引入,但如果你在項目中要使用shiro的權限控制註解時,請務必保證你的項目裏面有 spring-boot-starter-aop 這個依賴。
spring-web 依賴主要是這個自定義的starter裏面用到了一些Request相關內容,不是主要依賴。
shiro-spring 和 java-jwt 主角不用說了
maven工程總體包目錄結構
紅框的文件包是主要的
總體設計思路
這裏說一下總體設計思路,代碼全部貼出太多了,文章最後放上 github地址,裏面有使用說明 README.md
- 相關的屬性部分有默認值,可以在application.yml裏面修改
- 認證,授權兩個方法必須讓引入starter的開發者根據自己業務重寫
- 應該有一個當認證失敗時,供開發者調用的方法,開發者在這個方法裏面實現具體認證失敗時跳轉還是輸出錯誤信息
- 必須注入自定義的過濾器
- 必須可以自定義路徑過濾規則
- 有基於註解認證的 和 基於角色權限URL認證方式的
這裏不用多說了,可以查看github上的源碼,另外附上使用說明和測試用例的github地址:
shirojwt-spring-boot-starter 源碼地址
https://github.com/dwhgygzt/shirojwt-spring-boot-starter
測試工程源碼地址
https://github.com/dwhgygzt/myshirojwt-test
使用方式
代碼下載本地,mvn clean install 之後
配置:
pom.xml 文件引入如下配置
<dependency>
<groupId>org.guzt</groupId>
<artifactId>shirojwt-spring-boot-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
引入配置後,其實 application.yml不用配置任何信息即可啓用 shiro jwt,
當然你可以根據下面的常用默認值決定是否配置
- 配置文件默認登錄路徑 /api/login
- 配置文件默認退出路徑 /api/logout
- 默認Header裏jwt的名稱 Authorization
- 其他默認值例如token超時時限,刷新時限請查看源碼,默認1個小時
如果需要配置不同信息,yml文件配置也十分簡單:
shirojwt:
login-url: /api/login
logout-url: /api/logout
jwtIssuer: yourIssuerName
token-header-key: Authorization
用法:
1. 用戶登錄後生成 token方法
下面是一個簡單的測試類
@RestController
@RequestMapping("/api")
public class UserInfoController {
// 用於查詢用戶信息的 service
@Resource
private UserInfoService userInfoService;
@PostMapping("login")
public Map<String, String> login(String userName, String password) {
// 你的登錄代碼驗證邏輯
Map<String, String> loginInfo = userInfoService.login(userName, password);
if (loginInfo == null || loginInfo.isEmpty()) {
BusinessException.create("用戶名或密碼錯誤");
}
// 登錄驗證通過後 生成token給前端
assert loginInfo != null;
loginInfo.put("token", JwtUtil.sign(userName,
loginInfo.get(UserInfoService.passwordKey),
loginInfo.get(UserInfoService.saltKey)));
return loginInfo;
}
@GetMapping("logout")
public String logout() {
Subject subject = SecurityUtils.getSubject();
if (subject != null) {
subject.logout();
}
return "退出成功";
}
}
2. 前端訪問後臺接口,http請求中 HEADER 必須帶有token
KEY | VALUE |
---|---|
Authorization | 登錄接口獲得的token值 |
3. shiro驗證token合法性
重寫 JwtBussinessService 類即可,覆蓋裏面幾個方法,
java 代碼中使用如下:
@Service
public class MyJwtBussinessService extends JwtBussinessService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
public MyJwtBussinessService() {
logger.info("MyJwtBussinessService 初始化");
}
@Override
public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals, String realmName) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
logger.debug("進入 授權 doGetAuthorizationInfo");
logger.debug("the toke is {}", principals.toString());
String userName = JwtUtil.getUserName(principals.toString());
// 模擬從數據庫中根據用戶名查詢出用戶
Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
String spit = ",";
// 該用戶具有哪些權限
for (String permission : user.get(UserInfoService.permissionsKey).split(spit)) {
authorizationInfo.addStringPermission(permission);
}
// 該用戶具有哪些角色
for (String role : user.get(UserInfoService.rolesKey).split(spit)) {
authorizationInfo.addRole(role);
}
return authorizationInfo;
}
@Override
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth, String realmName) throws AuthenticationException {
String token = (String) auth.getCredentials();
logger.debug("進入 認證 doGetAuthenticationInfo");
logger.debug("the toke is {}", token);
// token是否過期
Date expiresDate = JwtUtil.getExpiresAt(token);
if (expiresDate == null) {
throw new IncorrectCredentialsException("token 不正確");
} else if (expiresDate.before(new Date())) {
throw new ExpiredCredentialsException("token 過期了");
}
// 驗證 token是否有效
String userName = JwtUtil.getUserName(token);
if (userName == null) {
throw new IncorrectCredentialsException("token 不正確");
}
// 驗證用戶是否存在
Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
if (user == null) {
throw new UnknownAccountException("用戶不存在");
}
// 用戶最終認證
String password = user.get(UserInfoService.passwordKey);
String salt = user.get(UserInfoService.saltKey);
return new SimpleAuthenticationInfo(token, password, ByteSource.Util.bytes(salt), realmName);
}
@Override
public void onAccessDenied(HttpServletRequest request, HttpServletResponse response, boolean isTokenExists, ShiroException ex) throws IOException {
// 這裏的 ShiroException 分爲兩類 一類認證異常 一類權限檢查不通過異常
// AuthenticationException 認證異常
// AuthorizationException 權限檢查不通過異常
defaultPrintJson(response, "{\"code\":\"-1\",\"data\":{\"bussinessCode\":\"401\"},\"message\":\"" + ex.getLocalizedMessage() + "\"}");
}
@Override
public String refreshOldToken(String oldToken) {
// 刷新 token
String userName = JwtUtil.getUserName(oldToken);
Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
return JwtUtil.sign(userName, user.get(UserInfoService.passwordKey), user.get(UserInfoService.saltKey));
}
}
默認已經對swagger進行的過濾,可直接訪問swagger頁面
如果要引入其他Bean 請務必使用懶加載方式,防止自定義的AOP失效,因爲ExtraFilterRule所在的配置類會被提前初始化
@Component
public class MyExtraFilterRule extends ExtraFilterRule {
// 請務必使用懶加載方式注入bean
// yourBusinessBean 例如爲菜單查詢類,查詢出所有按鈕權限菜單
@Lazy
@Service
private YourBusinessBean yourBusinessBean;
@Override
public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
// 不檢查某些路徑
filterRuleMap.put("/api/init", "noSessionCreation,anon");
// 添加自定義過濾器配置 myTestFilter 就是自己的過濾器
filterRuleMap.put("/api/selectUserInfoByUserName", "noSessionCreation,myTestFilter,jwt,jwtPerms[dd]");
}
}
5. 添加自定義過濾器
如果要引入其他Bean 請務必使用懶加載方式,防止自定義的AOP失效,因爲ExtraFilter所在的配置類會被提前初始化
@Component
public class MyExtraFilter extends ExtraFilter {
@Override
public void setExtraFilter(LinkedHashMap<String, Filter> filterMap) {
filterMap.put("myTestFilter", new MyTestFilter());
}
}
/**
* 自定義過濾器, 請勿使用 @Bean 或 @Service
*
* admin
*/
public class MyTestFilter extends AuthorizationFilter {
protected Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
logger.info("沒有別的事情,就是表示進過了過濾器 MyTestFilter");
return Boolean.TRUE;
}
}
6. 默認已經添加的過濾器配置
名稱 | 作用 |
---|---|
jwt | jwt認證 |
myCorsFilter | 支持跨域,默認支持 |
jwtPerms | URL 上的權限認證 |
jwtRoles | URL 上的角色認證 |
7. 基於URL的權限認證
一般情況下針對基於URL的權限認證,說白了就是按鈕權限認證,也即對後臺某個Controller方法的權限認證。
所謂權限認證,就是你是否有相應的權限或角色標識才可調用該controller裏面的某個方法。
這裏做法一般兩種, 1. 基於權限註解 2. 基於URL過濾器配置
基於註解
用法如下:
/**
* 測試 shirojwt
*
* @author admin
*/
@RestController
@RequestMapping("/api")
public class UserInfoController {
// 需要 權限 admin:update 纔可訪問這個方法
@RequiresPermissions("admin:update")
@PutMapping("updateUser")
public String updateUser(@RequestBody Map<String, String> user) {
userInfoService.updateUser(user);
return "success";
}
// 需要 admin或user角色才能訪問這個方法
@RequiresRoles(value = {"admin","user"})
@GetMapping("getUserInfoByUserName")
public Map<String, String> getUserInfoByUserName(String userName) {
return userInfoService.getUserByUserName(userName);
}
}
當用戶訪問 上面controller層裏面任意一個方法時,shiro會調用上文中 doGetAuthorizationInfo
方法,該方法作用就是從數據庫或緩存中根據 JWT 取出用戶具有的角色和權限,然後Shiro框架會自動判定用戶是否具有
訪問該方法的權限,如果沒有將拋出 UnauthorizedException 異常, 用戶可使用全局異常進行捕獲反饋給前端。
這裏說明一下 在此之前用戶已經進過JWT 認證了,如果認證不通過不會到這一步的。
基於URL過濾器配置
上文已經提過,本starter已經默認註冊了 權限角色驗證的過濾器,且支持自定義URL過濾配置
名稱 | 作用 |
---|---|
jwtPerms | URL 上的權限認證 |
jwtRoles | URL 上的角色認證 |
重複上面的文章 覆寫ExtraFilterRule類即可。
默認已經對swagger進行的過濾,可直接訪問swagger頁面
如果要引入其他Bean 請務必使用懶加載方式,防止自定義的AOP失效,因爲ExtraFilterRule所在的配置類會被提前初始化
@Component
public class MyExtraFilterRule extends ExtraFilterRule {
// 請務必使用懶加載方式注入bean
// MenuRoleService 角色菜單權限關係處理service
@Lazy
@Service
private MenuRoleService menuRoleService;
@Override
public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
List<Menu> buttons = menuRoleService.listAllButtonMenu();
for( Menu item : buttons ){
// item.getPathUrl() 是按鈕對應的後端路徑
// item.getPerm() 是按鈕應的權限標識,表示這個URL需要該權限標識才可訪問
filterRuleMap.put(item.getPathUrl(), "noSessionCreation,jwt,jwtPerms["+ item.getPerm() +"]");
}
}
}
這裏如果用戶權限認證不通過時候,會調用上文中 MyJwtBussinessService 裏面的 onAccessDenied 方法。
此時 ShiroException 爲 UnauthorizedException,你可以根據具體的異常類別做出打印或跳轉信息給前端。
這裏列出 ShiroException 的具體常用的幾種子類,以便你做出具體的業務邏輯處理。
類別 | 說明 |
---|---|
NoTokenAuthenticationException | 【jwt驗證】 header裏面未攜帶jwt |
ProgramErrorAuthenticationException | 【jwt驗證】jwt驗證程序500錯誤 |
ExpiredCredentialsException | 【jwt驗證】jwt過期,這個需要你自己認證方法裏面拋出 |
ExpiredCredentialsException | 【jwt驗證】jwt過期,這個需要你自己認證方法裏面拋出 |
IncorrectCredentialsException | 【jwt驗證】jwt格式錯誤,這個需要你自己認證方法裏面拋出 |
UnauthorizedException | 【權限驗證】 權限認證不通過統一拋出該異常 |
8. 基於URL的動態權限認證
所謂動態 就是可以在管理系統裏面隨意添加一條或刪除一條URL 認證記錄,這裏暫不建議這樣做,
這裏非要做其實是要刷新Shiro裏面緩存的URL 攔截配置,說穿了就是將裏面的一個LinkHashMap清空重新
填充數據。
- 不建議原因1 現在都是分佈式部署,你要刷新全部的機器上的應用
- 不建議原因2 一般都是有新功能上線纔會有這樣的事情,建議滾動發佈即可,挨個重啓服務測試
- 不建議原因3 現在很多的微服務認證都轉向API網關層認證,當然網關認證也可結合shirojwt,網關一般也是多臺部署
一般滾動發佈即可。
9. 關於緩存管理
這裏建議開發自行 在認證 和 授權兩個方法裏面通過redis緩存進行自定義邏輯處理。
例如簡單的獲取用戶是否存在驗證邏輯:
@Service
public class CurrentUserServiceImpl implements CurrentUserService {
public CurrentUserVO getCurrentUserFromCacheAndDb(String authToken) {
if (StrUtil.isEmpty(authToken)) {
logger.debug("authToken is null");
BusinessException.create(CommonBusinessCode.AUTHTOKEN_NOTFOUND);
}
CurrentUserVO vo = null;
// 先從緩存裏面取 token
RBucket<String> tokenBucket = redissonClient.getBucket(
applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_TOKEN_KEY, SecureUtil.md5(authToken)));
if (!tokenBucket.isExists()) {
logger.debug("authToken {} not in redis", authToken);
BusinessException.create(CommonBusinessCode.AUTHTOKEN_INVALID);
}
// 然後根據token 取用戶
RBucket<CurrentUserVO> userBucket = redissonClient.getBucket(
applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_USER_KEY, JwtUtil.getUserName(authToken)));
if (userBucket.isExists()) {
vo = userBucket.get();
CurrentUserContext.remove();
CurrentUserContext.setCurrentUser(vo);
}
// 緩存不存在,從數據庫中加載用戶信息
if (ObjectUtil.isEmpty(vo)) {
logger.debug("currentUser({}) 獲取不到信息 從數據庫中查詢該用戶", JwtUtil.getUserName(authToken));
CurrentUserContext.remove();
sysUserAggregateService.getCurrentUserFromDb(JwtUtil.getUserName(authToken), ExtendNetUtil.getSpringContextRequestIp(), SecureUtil.md5(authToken));
vo = CurrentUserContext.getCurrentUser();
// 放入緩存中
userBucket.set(vo, shiroJwtProperties.getTokenExpireSeconds(), TimeUnit.SECONDS);
}
if (ObjectUtil.isEmpty(vo)) {
logger.debug("currentUser({}) 緩存和數據庫中都獲取不到用戶信息", JwtUtil.getUserName(authToken));
BusinessException.create(CommonBusinessCode.CURRENT_USER_NOTFOUND);
}
return vo;
}
}