更多最新文章歡迎大家通路我的個人部落格😄:豆腐别館
ps:該篇文章及源碼已重新修改整理釋出至我的個人部落格豆腐别館:
SpringBoot整合shiro+jwt+redis - 無狀态token登入(一)總覽篇
SpringBoot整合shiro+jwt+redis - 無狀态token登入(二)授權篇
SpringBoot整合shiro+jwt+redis - 無狀态token登入(三)鑒權篇
一、前言
網上關于shiro的整合文章不少,但很多并不适用于前後端分離/移動端的項目
- shiro預設的攔截跳轉都是跳轉url頁面,這在前後端分離的項目中顯然行不通
- shiro預設使用session做登入校驗,分離後當然這也是不推薦的。
強行使用也可以,但是就必須做其它大量的工作,除了需要解決剛提到的跳轉路徑問題,其它的如跨域sessionId問題、或需修改為傳遞sessionId做shiro登入校驗,以及叢集下session共享問題。emmm,還有session機制本身的安全問題等等。
這顯然是費時費力的,那麼有沒有其它的辦法可以解決呢?
答案當然是有的,我們可以在整合shiro的基礎上繼續整合jwt,或者oauth2.0等,或者自定義登入校驗,使其成為支援服務端無狀态登入,即token登入。
ps:該篇文章在實作過程中參考了不少資料,實作匆忙未記錄下來,如有侵權請與我聯系
Here we go…
二、相關說明
1. Shiro + Java-JWT實作無狀态鑒權機制
- 首先post使用者名與密碼到login進行登入,如果成功傳回一個加密的Authorization,失敗的話直接傳回10001未登入等狀态碼,以後通路都帶上這個Authorization即可。
- 鑒權流程主要是重寫了shiro的入口過濾器JwtFilter(BasicHttpAuthenticationFilter),判斷請求Header裡面是否包含Authorization字段,有就進行shiro的token登入認證授權(使用者通路每一個需要權限的請求必須在Header中添加Authorization字段存放AccessToken),沒有就以遊客直接通路(有權限管控的話,以遊客通路就會被攔截)
2. 關于Redis中儲存RefreshToken資訊(做到JWT的可控性)
- 登入認證通過後傳回AccessToken資訊(在AccessToken中儲存目前的時間戳和帳号),同時在Redis中設定一條以帳号為Key,Value為目前時間戳(登入時間)的RefreshToken,現在認證時必須AccessToken沒失效以及Redis存在所對應的RefreshToken,且RefreshToken時間戳和AccessToken資訊中時間戳一緻才算認證通過,這樣可以做到JWT的可控性,如果重新登入擷取了新的AccessToken,舊的AccessToken就認證不了,因為Redis中所存放的的RefreshToken時間戳資訊隻會和最新的AccessToken資訊中攜帶的時間戳一緻,這樣每個使用者就隻能使用最新的AccessToken認證。
- Redis的RefreshToken也可以用來判斷使用者是否線上,如果删除Redis的某個RefreshToken,那這個RefreshToken所對應的AccessToken之後也無法通過認證了,就相當于控制了使用者的登入,可以剔除使用者
3. 關于根據RefreshToken自動重新整理AccessToken
- 本身AccessToken的過期時間為5分鐘(配置檔案可配置),RefreshToken過期時間為30分鐘(配置檔案可配置),當登入後時間過了5分鐘之後,目前AccessToken便會過期失效,再次帶上AccessToken通路JWT會抛出TokenExpiredException異常說明Token過期,開始判斷是否要進行AccessToken重新整理,首先redis查詢RefreshToken是否存在,以及時間戳和過期AccessToken所攜帶的時間戳是否一緻,如果存在且一緻就進行AccessToken重新整理。
- 重新整理後新的AccessToken過期時間依舊為5分鐘(配置檔案可配置),時間戳為目前最新時間戳,同時也設定RefreshToken中的時間戳為目前最新時間戳,重新整理過期時間重新為30分鐘過期(配置檔案可配置),最終将重新整理的AccessToken存放在Response的Header中的Authorization字段傳回。
- 同時前端進行擷取替換,下次用新的AccessToken進行通路
三、主要配置
1. maven依賴
此處搭建了maven子項目,使用的spring boot版本為較新的2.1.2,主要依賴如下:
<!-- web子產品 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 核心子產品,包括自動配置支援、日志和YAML -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 浏覽子產品,包括JUnit、Hamcrest、Mockito -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- mysql連接配接器 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- ali druid連接配接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.14</version>
</dependency>
<!-- myBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
<!-- shiro -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<!-- ali json -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
2. application.properties配置
## tomcat配置 - start
# 指定服務端口
server.port=8999
## tomcat配置 - end
# 禁止對外提供Spring MBeans
# spring.jmx.enabled=false
## 資料庫配置 - start
#spring.datasource.url=jdbc:mysql://localhost:3306/springboot-test?serverTimezone=GMT%2B8
spring.datasource.url=jdbc:mysql://192.168.5.58:3306/181_saas?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
## 資料庫配置 - end
# druid連接配接池配置 - start
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.druid.filters=stat
spring.datasource.druid.max-active=20
spring.datasource.druid.initial-size=1
spring.datasource.druid.min-idle=1
spring.datasource.druid.max-wait=60000
spring.datasource.druid.time-between-eviction-runs-millis=60000
spring.datasource.druid.min-evictable-idle-time-millis=300000
spring.datasource.druid.validation-query=select 'x'
spring.datasource.druid.test-while-idle=true
spring.datasource.druid.test-on-borrow=false
spring.datasource.druid.test-on-return=false
spring.datasource.druid.pool-prepared-statements=true
spring.datasource.druid.max-open-prepared-statements=20
# druid連接配接池配置 - end
## myBatis - start
# 列印SQL語句
mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
## myBatis - end
## Redis配置 - start
# Redis資料庫索引(預設為0)
spring.redis.database=1
# Redis伺服器位址
spring.redis.host=192.168.5.58
# Redis伺服器連接配接端口
spring.redis.port=6379
# Redis伺服器連接配接密碼(預設為空)
spring.redis.password=yibayi_181~jishubu*007
# 連接配接池最大連接配接數(使用負值表示沒有限制)
spring.redis.pool.max-active=8
# 連接配接池最大阻塞等待時間(使用負值表示沒有限制)
spring.redis.pool.max-wait=-1
# 連接配接池中的最大空閑連接配接
spring.redis.pool.max-idle=8
# 連接配接池中的最小空閑連接配接
spring.redis.pool.min-idle=0
# 連接配接逾時時間(毫秒)
spring.redis.timeout=5000
## Redis配置 - end
## 其它參數配置 - start
# AES密碼加密私鑰(Base64加密)
encryptAESKey=V2FuZzkyNuYSKIuwqTQkFQSUpXVA
# JWT認證加密私鑰(Base64加密)
encryptJWTKey=U0JBUElOENhspJrzkyNjQ1NA
# AccessToken過期時間-5分鐘-5*60(秒為機關)
accessTokenExpireTime=300
# RefreshToken過期時間-30分鐘-30*60(秒為機關)
refreshTokenExpireTime=1800
# Shiro緩存過期時間-5分鐘-5*60(秒為機關)(一般設定與AccessToken過期時間一緻)
shiroCacheExpireTime=300
## 其它參數配置 - end
## 時間格式配置 - start
# spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
# spring.jackson.time-zone=GMT+8
## 時間格式配置 - end
## log配置 - start
logging.path=/data/tomcat_log/181-saas/181-saas_log_error.log
logging.level.com.favorites=DEBUG
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=ERROR
## log配置 - end
四、代碼實作
ps:關于shiro/jwt/redis等的基礎知識不在該篇文章的探讨範圍,如有不清楚的請自行百度
1. 統一Josn封裝
既然是前背景分離的項目,那麼首先傳回的對象就必須要有統一,封裝如下:
package com.yby.saas.po.vo;
import java.io.Serializable;
import com.alibaba.fastjson.JSONObject;
import com.yby.saas.po.constant.StatusCode;
/***
* JSON封裝
*
* @author lwx
*/
public class JsonVo implements Serializable {
private static final long serialVersionUID = 8178937610421199532L;
/**
* 請求辨別,預設為失敗狀态
*/
private boolean success = false;
/**
* 狀态碼,預設為失敗狀态
*/
private Integer code = StatusCode.ERROR;
/***
* 操作資訊
*/
private String msg;
/**
* 傳回資料
*/
private Object obj = new JSONObject();
/**
* 成功響應
*/
public void OK() {
this.success = true;
this.code = StatusCode.SUCCESS;
}
/**
* 請求成功,但業務邏輯處理不通過
*/
public void NO() {
this.success = true;
this.code = StatusCode.ERROR;
}
public JsonVo() {
super();
}
public JsonVo(int code, String msg) {
super();
this.success = true;
this.code = code;
this.msg = msg;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.success = true;
this.code = code;
}
public Object getObj() {
return obj;
}
public void setObj(Object obj) {
this.success = true;
this.code = StatusCode.SUCCESS;
if (obj == null) {
obj = new JSONObject();
}
this.obj = obj;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
@Override
public String toString() {
return "JsonVo [success=" + success + ", code=" + code + ", msg=" + msg + ", obj=" + obj + "]";
}
}
2. ShiroConfig
注:這裡注意下關于
JwtFilter
的配置,由于spring boot的加載順序原因,spring boot中的filter預設情況下是無法注入bean的,是以此處在
ShiroConfig
下做了配置注入。
package com.yby.saas.config.shiro;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.Filter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import com.yby.saas.config.jwt.JwtFilter;
import com.yby.saas.config.shiro.cache.CustomCacheManager;
/**
* Shiro配置
*
* @author lwx
* @date 2019/03/08
*/
@Configuration
public class ShiroConfig {
/**
* 配置使用自定義Realm,關閉Shiro自帶的session 詳情見文檔
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
@Bean("securityManager")
public DefaultWebSecurityManager getManager(UserRealm userRealm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 使用自定義Realm
manager.setRealm(userRealm);
// 關閉Shiro自帶的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
// 設定自定義Cache緩存
manager.setCacheManager(new CustomCacheManager());
return manager;
}
/**
* 添加自己的過濾器,自定義url規則 詳情見文檔 http://shiro.apache.org/web.html#urls-
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的過濾器取名為jwt
Map<String, Filter> filterMap = new HashMap<>(16);
filterMap.put("jwtFilter", jwtFilterBean());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
// 自定義url規則
Map<String, String> filterRuleMap = new HashMap<>(16);
// 所有請求通過我們自己的JWTFilter
filterRuleMap.put("/**", "jwtFilter");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* <pre>
* 注入bean,此處應注意:
*
* (1)代碼順序,應放置于shiroFilter後面,否則報錯:
* No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.
* ThreadContext or as a vm static singleton. This is an invalid application configuration.
*
* (2)如不在此注冊,在filter中将無法正常注入bean
* </pre>
*/
@Bean("jwtFilter")
public JwtFilter jwtFilterBean() {
return new JwtFilter();
}
/**
* 下面的代碼是添加注解支援
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 強制使用cglib,防止重複代理和可能引起代理出錯的問題,https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
3. JwtFilter
既然我們需要更改shiro預設的登入攔截,那首先就需得重寫shiro中的
BasicHttpAuthenticationFilter
,此處使用jwt做登入攔截
package com.yby.saas.config.jwt;
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.yby.saas.exception.CustomException;
import com.yby.saas.po.constant.JwtConstant;
import com.yby.saas.po.constant.RedisConstant;
import com.yby.saas.po.constant.StatusCode;
import com.yby.saas.po.vo.JsonVo;
import com.yby.saas.redis.RedisClient;
import com.yby.saas.util.JwtUtil;
import com.yby.saas.util.common.JsonConvertUtil;
/**
* JWT過濾
*
* @author lwx
* @date 2019/03/09
*/
public class JwtFilter extends BasicHttpAuthenticationFilter {
@Value("${refreshTokenExpireTime}")
private String refreshTokenExpireTime;
@Autowired
private RedisClient redis;
/**
* LOGGER
*/
private static final Logger LOGGER = LoggerFactory.getLogger(JwtFilter.class);
/**
* 這裡我們詳細說明下為什麼最終傳回的都是true,即允許通路 例如我們提供一個位址 GET /article 登入使用者和遊客看到的内容是不同的
* 如果在這裡傳回了false,請求會被直接攔截,使用者看不到任何東西 是以我們在這裡傳回true,Controller中可以通過
* subject.isAuthenticated() 來判斷使用者是否登入
* 如果有些資源隻有登入使用者才能通路,我們隻需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是這樣做有一個缺點,就是不能夠對GET,POST等請求進行分别過濾鑒權(因為我們重寫了官方的方法),但實際上對應用影響不大
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 判斷使用者是否想要登入
if (this.isLoginAttempt(request, response)) {
try {
// 進行Shiro的登入UserRealm
this.executeLogin(request, response);
} catch (Exception e) {
// 認證出現異常,傳遞錯誤資訊msg
String msg = e.getMessage();
// 擷取應用異常(該Cause是導緻抛出此throwable(異常)的throwable(異常))
Throwable throwable = e.getCause();
if (throwable != null && throwable instanceof SignatureVerificationException) {
// 該異常為JWT的AccessToken認證失敗(Token或者密鑰不正确)
msg = "token或者密鑰不正确(" + throwable.getMessage() + ")";
} else if (throwable != null && throwable instanceof TokenExpiredException) {
// 該異常為JWT的AccessToken已過期,判斷RefreshToken未過期就進行AccessToken重新整理
if (this.refreshToken(request, response)) {
return true;
} else {
msg = "token已過期(" + throwable.getMessage() + ")";
}
} else {
// 應用異常不為空
if (throwable != null) {
// 擷取應用異常msg
msg = throwable.getMessage();
}
}
/**
* 錯誤兩種處理方式 1. 将非法請求轉發到/401的Controller處理,抛出自定義無權通路異常被全局捕捉再傳回Response資訊 2.
* 無需轉發,直接傳回Response資訊 一般使用第二種(更友善)
*/
// 直接傳回Response資訊
this.response401(request, response, msg);
return false;
}
}
return true;
}
/**
* 這裡我們詳細說明下為什麼重寫 可以對比父類方法,隻是将executeLogin方法調用去除了
* 如果沒有去除将會循環調用doGetAuthenticationInfo方法
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
this.sendChallenge(request, response);
return false;
}
/**
* 檢測Header裡面是否包含Authorization字段,有就進行Token登入認證授權
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
// 拿到目前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已經實作)
String token = this.getAuthzHeader(request);
return token != null;
}
/**
* 進行AccessToken登入認證授權
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
// 拿到目前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已經實作)
JwtToken token = new JwtToken(this.getAuthzHeader(request));
// 送出給UserRealm進行認證,如果錯誤他會抛出異常并被捕獲
this.getSubject(request, response).login(token);
// 如果沒有抛出異常則代表登入成功,傳回true
return true;
}
/**
* 此處為AccessToken重新整理,進行判斷RefreshToken是否過期,未過期就傳回新的AccessToken且繼續正常通路
*/
private boolean refreshToken(ServletRequest request, ServletResponse response) {
// 拿到目前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已經實作)
String token = this.getAuthzHeader(request);
// 擷取目前Token的帳号資訊
String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
// 判斷Redis中RefreshToken是否存在
if (redis.hasKey(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account)) {
// Redis中RefreshToken還存在,擷取RefreshToken的時間戳
String currentTimeMillisRedis = redis.get(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account).toString();
// 擷取目前AccessToken中的時間戳,與RefreshToken的時間戳對比,如果目前時間戳一緻,進行AccessToken重新整理
if (JwtUtil.getClaim(token, JwtConstant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
// 擷取目前最新時間戳
String currentTimeMillis = String.valueOf(System.currentTimeMillis());
// 讀取配置檔案,擷取refreshTokenExpireTime屬性
// PropertiesUtil.readProperties("config.properties");
// String refreshTokenExpireTime =
// PropertiesUtil.getProperty("refreshTokenExpireTime");
// 設定RefreshToken中的時間戳為目前最新時間戳,且重新整理過期時間重新為30分鐘過期(配置檔案可配置refreshTokenExpireTime屬性)
redis.set(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account, currentTimeMillis,
Integer.parseInt(refreshTokenExpireTime));
// 重新整理AccessToken,設定時間戳為目前最新時間戳
token = JwtUtil.sign(account, currentTimeMillis);
// 将新重新整理的AccessToken再次進行Shiro的登入
JwtToken jwtToken = new JwtToken(token);
// 送出給UserRealm進行認證,如果錯誤他會抛出異常并被捕獲,如果沒有抛出異常則代表登入成功,傳回true
this.getSubject(request, response).login(jwtToken);
// 最後将重新整理的AccessToken存放在Response的Header中的Authorization字段傳回
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Authorization", token);
httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
return true;
}
}
return false;
}
/**
* 無需轉發,直接傳回Response資訊
*/
private void response401(ServletRequest req, ServletResponse resp, String msg) {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
PrintWriter out = null;
try {
out = httpServletResponse.getWriter();
String data = JsonConvertUtil.objectToJson(new JsonVo(StatusCode.NOT_LOGIN, "無權通路(Unauthorized):" + msg));
out.append(data);
} catch (IOException e) {
LOGGER.error("直接傳回Response資訊出現IOException異常:" + e.getMessage());
throw new CustomException("直接傳回Response資訊出現IOException異常:" + e.getMessage());
} finally {
if (out != null) {
out.close();
}
}
}
/**
* 對跨域提供支援
*/
@Override
protected 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"));
// 跨域時會首先發送一個OPTIONS請求,這裡我們給OPTIONS請求直接傳回正常狀态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
JwtToken對象:
package com.yby.saas.config.jwt;
import org.apache.shiro.authc.AuthenticationToken;
/**
* JwtToken
*
* @author lwx
*/
public class JwtToken implements AuthenticationToken {
private static final long serialVersionUID = 1900286977895826147L;
/**
* Token
*/
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
4. 自定義Realm
package com.yby.saas.config.shiro;
import java.util.List;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.yby.saas.config.jwt.JwtToken;
import com.yby.saas.dao.permission.PermissionCustomMapper;
import com.yby.saas.dao.role.RoleCustomMapper;
import com.yby.saas.dao.user.UserCustomMapper;
import com.yby.saas.po.Permission;
import com.yby.saas.po.Role;
import com.yby.saas.po.User;
import com.yby.saas.po.constant.JwtConstant;
import com.yby.saas.po.constant.RedisConstant;
import com.yby.saas.redis.RedisClient;
import com.yby.saas.util.JwtUtil;
import com.yby.saas.util.common.StringUtil;
/**
* 自定義Realm
*
* @author lwx
* @date 2019/03/08
*/
@Service
public class UserRealm extends AuthorizingRealm {
@Autowired
private RedisClient redis;
@Autowired
private UserCustomMapper userMapper;
@Autowired
private RoleCustomMapper roleMapper;
@Autowired
private PermissionCustomMapper permissionMapper;
/**
* 大坑,必須重寫此方法,不然Shiro會報錯
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 隻有當需要檢測使用者權限的時候才會調用此方法,例如checkRole,checkPermission之類的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
String account = JwtUtil.getClaim(principals.toString(), JwtConstant.ACCOUNT);
// 查詢使用者角色
List<Role> roles = roleMapper.getRoleByMobile(account);
for (int i = 0, roleLen = roles.size(); i < roleLen; i++) {
Role role = roles.get(i);
// 添加角色
simpleAuthorizationInfo.addRole(role.getName());
// 根據使用者角色查詢權限
List<Permission> permissions = permissionMapper.getPermissionByRoleId(role.getId());
for (int j = 0, perLen = permissions.size(); j < perLen; j++) {
Permission permission = permissions.get(j);
// 添權重限
simpleAuthorizationInfo.addStringPermission(permission.getSn());
}
}
return simpleAuthorizationInfo;
}
/**
* 預設使用此方法進行使用者名正确與否驗證,錯誤抛出異常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
// 解密獲得account,用于和資料庫進行對比
String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
// 帳号為空
if (StringUtil.isBlank(account)) {
throw new AuthenticationException("Token中帳号為空(The account in Token is empty.)");
}
// 查詢使用者是否存在
User user = userMapper.getByMobile(account);
if (user == null) {
throw new AuthenticationException("該帳号不存在(The account does not exist.)");
}
// 開始認證,要AccessToken認證通過,且Redis中存在RefreshToken,且兩個Token時間戳一緻
if (JwtUtil.verify(token) && redis.hasKey(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account)) {
// 擷取RefreshToken的時間戳
String currentTimeMillisRedis = redis.get(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account).toString();
// 擷取AccessToken時間戳,與RefreshToken的時間戳對比
if (JwtUtil.getClaim(token, JwtConstant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
return new SimpleAuthenticationInfo(token, token, "userRealm");
}
}
throw new AuthenticationException("Token已過期(Token expired or incorrect.)");
}
}
5. 重寫shiro cache
package com.yby.saas.config.shiro.cache;
import java.util.Collection;
import java.util.Set;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import com.yby.saas.po.constant.JwtConstant;
import com.yby.saas.po.constant.RedisConstant;
import com.yby.saas.redis.RedisClient;
import com.yby.saas.util.JwtUtil;
/**
* 重寫Shiro的Cache儲存讀取
*
* @author lwx
*/
public class CustomCache<K, V> implements Cache<K, V> {
@Value("${shiroCacheExpireTime}")
private String shiroCacheExpireTime;
@Autowired
private RedisClient redis;
/**
* 緩存的key名稱擷取為shiro:cache:account
*
* @param key
* @return java.lang.String
* @author Wang926454
* @date 2018/9/4 18:33
*/
private String getKey(Object key) {
return RedisConstant.PREFIX_SHIRO_CACHE + JwtUtil.getClaim(key.toString(), JwtConstant.ACCOUNT);
}
/**
* 擷取緩存
*/
@Override
public Object get(Object key) throws CacheException {
if (!redis.hasKey(this.getKey(key))) {
return null;
}
return redis.get(this.getKey(key));
}
/**
* 儲存緩存
*/
@Override
public Object put(Object key, Object value) throws CacheException {
// 讀取配置檔案,擷取Redis的Shiro緩存過期時間
// PropertiesUtil.readProperties("config.properties");
// String shiroCacheExpireTime =
// PropertiesUtil.getProperty("shiroCacheExpireTime");
// 設定Redis的Shiro緩存
return redis.set(this.getKey(key), value, Integer.parseInt(shiroCacheExpireTime));
}
/**
* 移除緩存
*/
@Override
public Object remove(Object key) throws CacheException {
if (!redis.hasKey(this.getKey(key))) {
return null;
}
redis.del(this.getKey(key));
return null;
}
/**
* 清空所有緩存
*/
@Override
public void clear() throws CacheException {
// TODO Auto-generated method stub
}
/**
* 緩存的個數
*/
@Override
public Set<K> keys() {
// TODO Auto-generated method stub
return null;
}
/**
* 擷取所有的key
*/
@Override
public int size() {
// TODO Auto-generated method stub
return 0;
}
/**
* 擷取所有的value
*/
@Override
public Collection<V> values() {
// TODO Auto-generated method stub
return null;
}
}
6. 重寫shiro CacheManager
package com.yby.saas.config.shiro.cache;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.cache.CacheManager;
/**
* 重寫Shiro緩存管理器
*
* @author lwx
*/
public class CustomCacheManager implements CacheManager {
@Override
public <K, V> Cache<K, V> getCache(String s) throws CacheException {
return new CustomCache<K, V>();
}
}
6. RedisConfig
package com.yby.saas.config.redis;
import java.lang.reflect.Method;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Redis緩存配置
*
* @author lwx
*/
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
@Bean
public KeyGenerator keyGenerator() {
return new KeyGenerator() {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getName());
sb.append(method.getName());
if (params != null && params.length > 0 && params[0] != null) {
for (Object obj : params) {
sb.append(obj.toString());
}
}
return sb.toString();
}
};
}
/**
* RedisTemplate
*/
@Bean
@SuppressWarnings("all")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
7. RedisClient工具類
package com.yby.saas.redis;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
/**
* <pre>
* RedisTemplate工具類:
* 針對所有的hash都是以h開頭的方法
* 針對所有的Set都是以s開頭的方法(不含通用方法)
* 針對所有的List都是以l開頭的方法
* </pre>
*
* @author lwx
*/
@Component
public class RedisClient {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// =============================common============================
/**
* 指定緩存失效時間
*
* @param key
* 鍵
* @param time
* 時間(秒)
* @return
*/
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 根據key 擷取過期時間
*
* @param key
* 鍵 不能為null
* @return 時間(秒) 傳回0代表為永久有效
*/
public long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
/**
* 判斷key是否存在
*
* @param key
* 鍵
* @return true 存在 false不存在
*/
public boolean hasKey(String key) {
try {
return redisTemplate.hasKey(key);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 删除緩存
*
* @param key
* 可以傳一個值 或多個
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete(CollectionUtils.arrayToList(key));
}
}
}
// ============================String=============================
/**
* 普通緩存擷取
*
* @param key
* 鍵
* @return 值
*/
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}
/**
* 普通緩存放入
*
* @param key
* 鍵
* @param value
* 值
* @return true成功 false失敗
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 普通緩存放入并設定時間
*
* @param key
* 鍵
* @param value
* 值
* @param time
* 時間(秒) time要大于0 如果time小于等于0 将設定無限期
* @return true成功 false 失敗
*/
public boolean set(String key, Object value, long time) {
try {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 遞增
*
* @param key
* 鍵
* @param by
* 要增加幾(大于0)
* @return
*/
public long incr(String key, long delta) {
if (delta < 0) {
throw new RuntimeException("遞增因子必須大于0");
}
return redisTemplate.opsForValue().increment(key, delta);
}
/**
* 遞減
*
* @param key
* 鍵
* @param by
* 要減少幾(小于0)
* @return
*/
public long decr(String key, long delta) {
if (delta < 0) {
throw new RuntimeException("遞減因子必須大于0");
}
return redisTemplate.opsForValue().increment(key, -delta);
}
// ================================Map=================================
/**
* HashGet
*
* @param key
* 鍵 不能為null
* @param item
* 項 不能為null
* @return 值
*/
public Object hget(String key, String item) {
return redisTemplate.opsForHash().get(key, item);
}
/**
* 擷取hashKey對應的所有鍵值
*
* @param key
* 鍵
* @return 對應的多個鍵值
*/
public Map<Object, Object> hmget(String key) {
return redisTemplate.opsForHash().entries(key);
}
/**
* HashSet
*
* @param key
* 鍵
* @param map
* 對應多個鍵值
* @return true 成功 false 失敗
*/
public boolean hmset(String key, Map<String, Object> map) {
try {
redisTemplate.opsForHash().putAll(key, map);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* HashSet 并設定時間
*
* @param key
* 鍵
* @param map
* 對應多個鍵值
* @param time
* 時間(秒)
* @return true成功 false失敗
*/
public boolean hmset(String key, Map<String, Object> map, long time) {
try {
redisTemplate.opsForHash().putAll(key, map);
if (time > 0) {
expire(key, time);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 向一張hash表中放入資料,如果不存在将建立
*
* @param key
* 鍵
* @param item
* 項
* @param value
* 值
* @return true 成功 false失敗
*/
public boolean hset(String key, String item, Object value) {
try {
redisTemplate.opsForHash().put(key, item, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 向一張hash表中放入資料,如果不存在将建立
*
* @param key
* 鍵
* @param item
* 項
* @param value
* 值
* @param time
* 時間(秒) 注意:如果已存在的hash表有時間,這裡将會替換原有的時間
* @return true 成功 false失敗
*/
public boolean hset(String key, String item, Object value, long time) {
try {
redisTemplate.opsForHash().put(key, item, value);
if (time > 0) {
expire(key, time);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 删除hash表中的值
*
* @param key
* 鍵 不能為null
* @param item
* 項 可以使多個 不能為null
*/
public void hdel(String key, Object... item) {
redisTemplate.opsForHash().delete(key, item);
}
/**
* 判斷hash表中是否有該項的值
*
* @param key
* 鍵 不能為null
* @param item
* 項 不能為null
* @return true 存在 false不存在
*/
public boolean hHasKey(String key, String item) {
return redisTemplate.opsForHash().hasKey(key, item);
}
/**
* hash遞增 如果不存在,就會建立一個 并把新增後的值傳回
*
* @param key
* 鍵
* @param item
* 項
* @param by
* 要增加幾(大于0)
* @return
*/
public double hincr(String key, String item, double by) {
return redisTemplate.opsForHash().increment(key, item, by);
}
/**
* hash遞減
*
* @param key
* 鍵
* @param item
* 項
* @param by
* 要減少記(小于0)
* @return
*/
public double hdecr(String key, String item, double by) {
return redisTemplate.opsForHash().increment(key, item, -by);
}
// ============================set=============================
/**
* 根據key擷取Set中的所有值
*
* @param key
* 鍵
* @return
*/
public Set<Object> sGet(String key) {
try {
return redisTemplate.opsForSet().members(key);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 根據value從一個set中查詢,是否存在
*
* @param key
* 鍵
* @param value
* 值
* @return true 存在 false不存在
*/
public boolean sHasKey(String key, Object value) {
try {
return redisTemplate.opsForSet().isMember(key, value);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 将資料放入set緩存
*
* @param key
* 鍵
* @param values
* 值 可以是多個
* @return 成功個數
*/
public long sSet(String key, Object... values) {
try {
return redisTemplate.opsForSet().add(key, values);
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
/**
* 将set資料放入緩存
*
* @param key
* 鍵
* @param time
* 時間(秒)
* @param values
* 值 可以是多個
* @return 成功個數
*/
public long sSetAndTime(String key, long time, Object... values) {
try {
Long count = redisTemplate.opsForSet().add(key, values);
if (time > 0)
expire(key, time);
return count;
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
/**
* 擷取set緩存的長度
*
* @param key
* 鍵
* @return
*/
public long sGetSetSize(String key) {
try {
return redisTemplate.opsForSet().size(key);
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
/**
* 移除值為value的
*
* @param key
* 鍵
* @param values
* 值 可以是多個
* @return 移除的個數
*/
public long setRemove(String key, Object... values) {
try {
Long count = redisTemplate.opsForSet().remove(key, values);
return count;
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
// ===============================list=================================
/**
* 擷取list緩存的内容
*
* @param key
* 鍵
* @param start
* 開始
* @param end
* 結束 0 到 -1代表所有值
* @return
*/
public List<Object> lGet(String key, long start, long end) {
try {
return redisTemplate.opsForList().range(key, start, end);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 擷取list緩存的長度
*
* @param key
* 鍵
* @return
*/
public long lGetListSize(String key) {
try {
return redisTemplate.opsForList().size(key);
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
/**
* 通過索引 擷取list中的值
*
* @param key
* 鍵
* @param index
* 索引 index>=0時, 0 表頭,1 第二個元素,依次類推;index<0時,-1,表尾,-2倒數第二個元素,依次類推
* @return
*/
public Object lGetIndex(String key, long index) {
try {
return redisTemplate.opsForList().index(key, index);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 将list放入緩存
*
* @param key
* 鍵
* @param value
* 值
* @param time
* 時間(秒)
* @return
*/
public boolean lSet(String key, Object value) {
try {
redisTemplate.opsForList().rightPush(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 将list放入緩存
*
* @param key
* 鍵
* @param value
* 值
* @param time
* 時間(秒)
* @return
*/
public boolean lSet(String key, Object value, long time) {
try {
redisTemplate.opsForList().rightPush(key, value);
if (time > 0)
expire(key, time);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 将list放入緩存
*
* @param key
* 鍵
* @param value
* 值
* @param time
* 時間(秒)
* @return
*/
public boolean lSet(String key, List<Object> value) {
try {
redisTemplate.opsForList().rightPushAll(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 将list放入緩存
*
* @param key
* 鍵
* @param value
* 值
* @param time
* 時間(秒)
* @return
*/
public boolean lSet(String key, List<Object> value, long time) {
try {
redisTemplate.opsForList().rightPushAll(key, value);
if (time > 0)
expire(key, time);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 根據索引修改list中的某條資料
*
* @param key
* 鍵
* @param index
* 索引
* @param value
* 值
* @return
*/
public boolean lUpdateIndex(String key, long index, Object value) {
try {
redisTemplate.opsForList().set(key, index, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 移除N個值為value
*
* @param key
* 鍵
* @param count
* 移除多少個
* @param value
* 值
* @return 移除的個數
*/
public long lRemove(String key, long count, Object value) {
try {
Long remove = redisTemplate.opsForList().remove(key, count, value);
return remove;
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
}
8. 自定義異常
package com.yby.saas.exception;
/**
* 自定義異常(CustomException)
*
* @author lwx
*/
public class CustomException extends RuntimeException {
private static final long serialVersionUID = -6736944294947154413L;
public CustomException(String msg) {
super(msg);
}
public CustomException() {
super();
}
}
package com.yby.saas.exception;
/**
* 自定義401無權限異常(UnauthorizedException)
*/
public class CustomUnauthorizedException extends RuntimeException {
private static final long serialVersionUID = -3993376696547776573L;
public CustomUnauthorizedException(String msg) {
super(msg);
}
public CustomUnauthorizedException() {
super();
}
}
9. 自定義異常控制處理器
package com.yby.saas.config;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.apache.shiro.ShiroException;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import com.yby.saas.exception.CustomException;
import com.yby.saas.po.constant.StatusCode;
import com.yby.saas.po.vo.JsonVo;
/**
* 異常控制處理器
*
* @author lwx
*/
@RestControllerAdvice
public class ExceptionAdvice {
/**
* 捕捉所有Shiro異常
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public JsonVo handle401(ShiroException e) {
JsonVo vo = new JsonVo();
vo.setCode(StatusCode.UNLAWFUL);
vo.setMsg("無權通路(Unauthorized):" + e.getMessage());
return vo;
}
/**
* 單獨捕捉Shiro(UnauthorizedException)異常 該異常為通路有權限管控的請求而該使用者沒有所需權限所抛出的異常
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthorizedException.class)
public JsonVo handle401(UnauthorizedException e) {
JsonVo vo = new JsonVo();
vo.setCode(StatusCode.UNLAWFUL);
vo.setMsg("無權通路(Unauthorized):目前Subject沒有此請求所需權限(" + e.getMessage() + ")");
return vo;
}
/**
* 單獨捕捉Shiro(UnauthenticatedException)異常
* 該異常為以遊客身份通路有權限管控的請求無法對匿名主體進行授權,而授權失敗所抛出的異常
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthenticatedException.class)
public JsonVo handle401(UnauthenticatedException e) {
JsonVo vo = new JsonVo();
vo.setCode(StatusCode.UNLAWFUL);
vo.setMsg("無權通路(Unauthorized):目前Subject是匿名Subject,請先登入(This subject is anonymous.)");
return vo;
}
/**
* 捕捉校驗異常(BindException)
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public JsonVo validException(BindException e) {
JsonVo vo = new JsonVo();
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
Map<String, Object> result = this.getValidError(fieldErrors);
vo.setCode(StatusCode.ERROR);
vo.setMsg(result.get("errorMsg").toString());
vo.setObj(result.get("errorList"));
return vo;
}
/**
* 捕捉校驗異常(MethodArgumentNotValidException)
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public JsonVo validException(MethodArgumentNotValidException e) {
JsonVo vo = new JsonVo();
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
Map<String, Object> result = this.getValidError(fieldErrors);
vo.setCode(StatusCode.ERROR);
vo.setMsg(result.get("errorMsg").toString());
vo.setObj(result.get("errorList"));
return vo;
}
/**
* 捕捉404異常
*/
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NoHandlerFoundException.class)
public JsonVo handle(NoHandlerFoundException e) {
JsonVo vo = new JsonVo();
vo.setCode(StatusCode.NOT_FOUND);
vo.setMsg(e.getMessage());
return vo;
}
/**
* 捕捉其他所有異常
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public JsonVo globalException(HttpServletRequest request, Throwable ex) {
// return new JsonVo(this.getStatus(request).value(), ex.toString() + ": " +
// ex.getMessage(), null);
JsonVo vo = new JsonVo();
vo.setCode(StatusCode.SERVER_ERROR);
vo.setMsg(ex.toString() + ": " + ex.getMessage());
return vo;
}
/**
* 捕捉其他所有自定義異常
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(CustomException.class)
public JsonVo handle(CustomException e) {
JsonVo vo = new JsonVo();
vo.NO();
vo.setMsg(e.getMessage());
return vo;
}
/**
* 擷取狀态碼
*/
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);
}
/**
* 擷取校驗錯誤資訊
*/
private Map<String, Object> getValidError(List<FieldError> fieldErrors) {
Map<String, Object> result = new HashMap<String, Object>(16);
List<String> errorList = new ArrayList<String>();
StringBuffer errorMsg = new StringBuffer("校驗異常(ValidException):");
for (FieldError error : fieldErrors) {
errorList.add(error.getField() + "-" + error.getDefaultMessage());
errorMsg.append(error.getField() + "-" + error.getDefaultMessage() + ".");
}
result.put("errorList", errorList);
result.put("errorMsg", errorMsg);
return result;
}
}
10. JWT工具類
package com.yby.saas.util;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.yby.saas.exception.CustomException;
import com.yby.saas.po.constant.JwtConstant;
import com.yby.saas.util.common.Base64Util;
/**
* JAVA-JWT工具類
*
* @author lwx
*/
@Component
public class JwtUtil {
/**
* LOGGER
*/
private static final Logger LOGGER = LoggerFactory.getLogger(JwtUtil.class);
/**
* 過期時間改為從配置檔案擷取
*/
private static String accessTokenExpireTime;
/**
* JWT認證加密私鑰(Base64加密)
*/
private static String encryptJWTKey;
@Value("${accessTokenExpireTime}")
public void setAccessTokenExpireTime(String accessTokenExpireTime) {
JwtUtil.accessTokenExpireTime = accessTokenExpireTime;
}
@Value("${encryptJWTKey}")
public void setEncryptJWTKey(String encryptJWTKey) {
JwtUtil.encryptJWTKey = encryptJWTKey;
}
/**
* 校驗token是否正确
*
* @param token
* Token
* @return boolean 是否正确
* @author Wang926454
* @date 2018/8/31 9:05
*/
public static boolean verify(String token) {
try {
// 帳号加JWT私鑰解密
String secret = getClaim(token, JwtConstant.ACCOUNT) + Base64Util.decodeThrowsException(encryptJWTKey);
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (UnsupportedEncodingException e) {
LOGGER.error("JWTToken認證解密出現UnsupportedEncodingException異常:" + e.getMessage());
throw new CustomException("JWTToken認證解密出現UnsupportedEncodingException異常:" + e.getMessage());
}
}
/**
* 獲得Token中的資訊無需secret解密也能獲得
*
* @param token
* @param claim
* @return java.lang.String
* @author Wang926454
* @date 2018/9/7 16:54
*/
public static String getClaim(String token, String claim) {
try {
DecodedJWT jwt = JWT.decode(token);
// 隻能輸出String類型,如果是其他類型傳回null
return jwt.getClaim(claim).asString();
} catch (JWTDecodeException e) {
LOGGER.error("解密Token中的公共資訊出現JWTDecodeException異常:" + e.getMessage());
throw new CustomException("解密Token中的公共資訊出現JWTDecodeException異常:" + e.getMessage());
}
}
/**
* 生成簽名
*
* @param account
* 帳号
* @return java.lang.String 傳回加密的Token
* @author Wang926454
* @date 2018/8/31 9:07
*/
public static String sign(String account, String currentTimeMillis) {
try {
// 帳号加JWT私鑰加密
String secret = account + Base64Util.decodeThrowsException(encryptJWTKey);
// 此處過期時間是以毫秒為機關,是以乘以1000
Date date = new Date(System.currentTimeMillis() + Long.parseLong(accessTokenExpireTime) * 1000);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附帶account帳号資訊
return JWT.create().withClaim("account", account).withClaim("currentTimeMillis", currentTimeMillis)
.withExpiresAt(date).sign(algorithm);
} catch (UnsupportedEncodingException e) {
LOGGER.error("JWTToken加密出現UnsupportedEncodingException異常:" + e.getMessage());
throw new CustomException("JWTToken加密出現UnsupportedEncodingException異常:" + e.getMessage());
}
}
}
11. AES工具類
package com.yby.saas.util;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.Security;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import com.yby.saas.exception.CustomUnauthorizedException;
import com.yby.saas.util.common.Base64Util;
import com.yby.saas.util.common.HexConvertUtil;
/**
* AES加密解密工具類
*
* @author lwx
*/
@Component
public class AesUtil {
/**
* AES密碼加密私鑰(Base64加密)
*/
private static String encryptAESKey = "V2FuZzkyNuYSKIuwqTQkFQSUpXVA";
// private static final byte[] KEY = { 1, 1, 33, 82, -32, -85, -128, -65 };
@Value("${encryptAESKey}")
public void setEncryptAESKey(String encryptAESKey) {
AesUtil.encryptAESKey = encryptAESKey;
}
/**
* LOGGER
*/
private static final Logger LOGGER = LoggerFactory.getLogger(AesUtil.class);
/**
* 加密
*/
public static String encode(String str) {
try {
Security.addProvider(new com.sun.crypto.provider.SunJCE());
// 執行個體化支援AES算法的密鑰生成器(算法名稱命名需按規定,否則抛出異常)
// KeyGenerator 提供對稱密鑰生成器的功能,支援各種算法
KeyGenerator keygen = KeyGenerator.getInstance("AES");
// 将私鑰encryptAESKey先Base64解密後轉換為byte[]數組按128位初始化
SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
secureRandom.setSeed(Base64Util.decodeThrowsException(encryptAESKey).getBytes());
keygen.init(128, secureRandom);
// SecretKey 負責儲存對稱密鑰 生成密鑰
SecretKey deskey = keygen.generateKey();
// 生成Cipher對象,指定其支援的AES算法,Cipher負責完成加密或解密工作
Cipher c = Cipher.getInstance("AES");
// 根據密鑰,對Cipher對象進行初始化,ENCRYPT_MODE表示加密模式
c.init(Cipher.ENCRYPT_MODE, deskey);
byte[] src = str.getBytes();
// 該位元組數組負責儲存加密的結果
byte[] cipherByte = c.doFinal(src);
// 先将二進制轉換成16進制,再傳回Bsae64加密後的String
return Base64Util.encodeThrowsException(HexConvertUtil.parseByte2HexStr(cipherByte));
} catch (NoSuchAlgorithmException e) {
LOGGER.error("getInstance()方法異常:" + e.getMessage());
throw new CustomUnauthorizedException("getInstance()方法異常:" + e.getMessage());
} catch (UnsupportedEncodingException e) {
LOGGER.error("Bsae64加密異常:" + e.getMessage());
throw new CustomUnauthorizedException("Bsae64加密異常:" + e.getMessage());
} catch (NoSuchPaddingException e) {
LOGGER.error("getInstance()方法異常:" + e.getMessage());
throw new CustomUnauthorizedException("getInstance()方法異常:" + e.getMessage());
} catch (InvalidKeyException e) {
LOGGER.error("初始化Cipher對象異常:" + e.getMessage());
throw new CustomUnauthorizedException("初始化Cipher對象異常:" + e.getMessage());
} catch (IllegalBlockSizeException e) {
LOGGER.error("加密異常,密鑰有誤:" + e.getMessage());
throw new CustomUnauthorizedException("加密異常,密鑰有誤:" + e.getMessage());
} catch (BadPaddingException e) {
LOGGER.error("加密異常,密鑰有誤:" + e.getMessage());
throw new CustomUnauthorizedException("加密異常,密鑰有誤:" + e.getMessage());
}
}
/**
* 解密
*/
public static String decode(String str) {
try {
Security.addProvider(new com.sun.crypto.provider.SunJCE());
// 執行個體化支援AES算法的密鑰生成器(算法名稱命名需按規定,否則抛出異常)
// KeyGenerator 提供對稱密鑰生成器的功能,支援各種算法
KeyGenerator keygen = KeyGenerator.getInstance("AES");
// 将私鑰encryptAESKey先Base64解密後轉換為byte[]數組按128位初始化
SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
secureRandom.setSeed(Base64Util.decodeThrowsException(encryptAESKey).getBytes());
keygen.init(128, secureRandom);
// SecretKey 負責儲存對稱密鑰 生成密鑰
SecretKey deskey = keygen.generateKey();
// 生成Cipher對象,指定其支援的AES算法,Cipher負責完成加密或解密工作
Cipher c = Cipher.getInstance("AES");
// 根據密鑰,對Cipher對象進行初始化,DECRYPT_MODE表示解密模式
c.init(Cipher.DECRYPT_MODE, deskey);
// 該位元組數組負責儲存加密的結果,先對str進行Bsae64解密,将16進制轉換為二進制
String base64 = Base64Util.decodeThrowsException(str);
byte[] cipherByte = c.doFinal(HexConvertUtil.parseHexStr2Byte(base64));
return new String(cipherByte);
} catch (NoSuchAlgorithmException e) {
LOGGER.error("getInstance()方法異常:" + e.getMessage());
throw new CustomUnauthorizedException("getInstance()方法異常:" + e.getMessage());
} catch (UnsupportedEncodingException e) {
LOGGER.error("Bsae64加密異常:" + e.getMessage());
throw new CustomUnauthorizedException("Bsae64加密異常:" + e.getMessage());
} catch (NoSuchPaddingException e) {
LOGGER.error("getInstance()方法異常:" + e.getMessage());
throw new CustomUnauthorizedException("getInstance()方法異常:" + e.getMessage());
} catch (InvalidKeyException e) {
LOGGER.error("初始化Cipher對象異常:" + e.getMessage());
throw new CustomUnauthorizedException("初始化Cipher對象異常:" + e.getMessage());
} catch (IllegalBlockSizeException e) {
LOGGER.error("解密異常,密鑰有誤:" + e.getMessage());
throw new CustomUnauthorizedException("解密異常,密鑰有誤:" + e.getMessage());
} catch (BadPaddingException e) {
LOGGER.error("解密異常,密鑰有誤:" + e.getMessage());
throw new CustomUnauthorizedException("解密異常,密鑰有誤:" + e.getMessage());
}
}
}
12. MD5工具類
package com.yby.saas.util.common;
import java.security.MessageDigest;
import java.util.UUID;
public class Md5Util {
public static String encode(String str) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(str.getBytes());
byte b[] = md.digest();
int i;
StringBuffer buf = new StringBuffer("");
for (int offset = 0; offset < b.length; offset++) {
i = b[offset];
if (i < 0)
i += 256;
if (i < 16)
buf.append("0");
buf.append(Integer.toHexString(i));
}
str = buf.toString();
} catch (Exception e) {
e.printStackTrace();
}
return str;
}
/**
* 帶鹽值加密
*
* @param str
* 待加密字元串
* @param salt
* 鹽值
*/
public static String encode(String str, String salt) {
return encode(str + salt);
}
public static void main(String[] args) {
String salt = UUID.randomUUID().toString().replace("-", "");
System.out.println("salt:" + salt);
System.out.println(encode("a123456" + "de93210d922540cb8b4686b7aca08d49"));
}
}
13. Base64工具類
package com.yby.saas.util.common;
import java.io.UnsupportedEncodingException;
import java.util.Base64;
/**
* Base64工具
*
* @author lwx
*/
public class Base64Util {
/**
* 加密JDK1.8
*/
public static String encode(String str) {
try {
byte[] encodeBytes = Base64.getEncoder().encode(str.getBytes("utf-8"));
return new String(encodeBytes);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
/**
* 解密JDK1.8
*/
public static String decode(String str) {
try {
byte[] decodeBytes = Base64.getDecoder().decode(str.getBytes("utf-8"));
return new String(decodeBytes);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
/**
* 加密JDK1.8
*/
public static String encodeThrowsException(String str) throws UnsupportedEncodingException {
byte[] encodeBytes = Base64.getEncoder().encode(str.getBytes("utf-8"));
return new String(encodeBytes);
}
/**
* 解密JDK1.8
*/
public static String decodeThrowsException(String str) throws UnsupportedEncodingException {
byte[] decodeBytes = Base64.getDecoder().decode(str.getBytes("utf-8"));
return new String(decodeBytes);
}
}
五、登入方法
1. LoginController
/**
* 登入
*/
@PostMapping("/login")
public JsonVo login(String account, String password, HttpServletResponse response) {
JsonVo vo = new JsonVo();
if (StringUtils.isEmpty(account) || StringUtils.isEmpty(password)) {
vo.setCode(StatusCode.PARAM_ERROR);
return vo;
}
// 查詢資料庫中的帳号資訊
User affirm = userService.getByMobile(account);
if (affirm == null) {
vo.setCode(StatusCode.NOT_FOUND);
return vo;
}
// Md5加密
if (!Md5Util.encode(password + affirm.getSalt()).equals(affirm.getPassword())) {
vo.setCode(StatusCode.PASSWORD_ERROR);
return vo;
}
// 清除可能存在的shiro權限資訊緩存
if (redis.hasKey(RedisConstant.PREFIX_SHIRO_CACHE + account)) {
redis.del(RedisConstant.PREFIX_SHIRO_CACHE + account);
}
// 設定RefreshToken,時間戳為目前時間戳,直接設定即可(不用先删後設,會覆寫已有的RefreshToken)
String currentTimeMillis = String.valueOf(System.currentTimeMillis());
redis.set(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account, currentTimeMillis,
Integer.parseInt(refreshTokenExpireTime));
// 從Header中Authorization傳回AccessToken,時間戳為目前時間戳
String token = JwtUtil.sign(account, currentTimeMillis);
response.setHeader("Authorization", token);
response.setHeader("Access-Control-Expose-Headers", "Authorization");
vo.OK();
return vo;
}
2. 擷取目前登入使用者
/**
* 擷取目前登入使用者
*/
public User getCurrent() {
try {
Subject subject = SecurityUtils.getSubject();
if (subject != null) {
String token = (String) subject.getPrincipal();
if (StringUtil.isNotBlank(token)) {
String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
if (StringUtil.isNotBlank(account)) {
return userService.getByMobile(account);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 擷取目前登入使用者ID
*/
public Long getCurrentId() {
User current = getCurrent();
if (current != null) {
return current.getId();
}
return null;
}
六、PostMan請求示例
1. 擷取token
用POST方式通路登入接口:http://localhost:8999/login?account=15813922171&password=123456
登入成功後傳回如下資訊:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIwczX0xiRGZkRGZ0Xy9GbvNGL2EzXlpXazxSP9cmTxEleNlXREJ2c4dVYw40MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnL3MDO2AjN0EjM5AzMwkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
重點在傳回的Headers,紅框部分即我們想要的token,前端請求接口時攜帶此token即可:
2. 使用token
在Headers處添加參數如下:
Content-Type:application/json
Authorization:上一步擷取到的token
好了,基本主要的相關代碼都在上面了,如有疑問請留言。
ps:該篇文章及源碼已重新修改整理釋出至我的個人部落格 ⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄:豆腐别館
SpringBoot整合shiro+jwt+redis - 無狀态token登入(一)總覽篇
SpringBoot整合shiro+jwt+redis - 無狀态token登入(二)授權篇
SpringBoot整合shiro+jwt+redis - 無狀态token登入(三)鑒權篇
The end.