一、前言
在微服務中我們一般采用的是無狀態登錄,而傳統的session方式,在前后端分離的微服務架構下,如繼續使用則必將要解決跨域sessionId問題、集群session共享問題等等。這顯然是費力不討好的,而整合shiro,卻很不恰巧的與我們的期望有所違背:
- shiro默認的攔截跳轉都是跳轉url頁面,而前后端分離后,后端並無權干涉頁面跳轉。
- shiro默認使用的登錄攔截校驗機制恰恰就是使用的session。
這當然不是我們想要的,因此如需使用shiro,我們就需要對其進行改造,那么要如何改造呢?我們可以在整合shiro的基礎上自定義登錄校驗,繼續整合JWT,或者oauth2.0等,使其成為支持服務端無狀態登錄,即token登錄。
二、需求
- 首次通過post請求將用戶名與密碼到login進行登入;
- 登錄成功后返回token;
- 每次請求,客戶端需通過header將token帶回服務器做JWT Token的校驗;
- 服務端負責token生命周期的刷新
- 用戶權限的校驗;
三、實現
pom.xml
<!--shiro-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>
<!--JWT-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
ShiroConfig
/**
* shiro 配置類
*/
@Configuration
public class ShiroConfig {
/**
* Filter Chain定義說明
* 1、一個URL可以配置多個Filter,使用逗號分隔
* 2、當設置多個過濾器時,全部驗證通過,才視為通過
* 3、部分過濾器可指定參數,如perms,roles
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 攔截器
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// 配置不會被攔截的鏈接 順序判斷
filterChainDefinitionMap.put("/sys/login", "anon"); //登錄接口排除
filterChainDefinitionMap.put("/sys/logout", "anon"); //登出接口排除
filterChainDefinitionMap.put("/", "anon");
filterChainDefinitionMap.put("/**/*.js", "anon");
filterChainDefinitionMap.put("/**/*.css", "anon");
filterChainDefinitionMap.put("/**/*.html", "anon");
filterChainDefinitionMap.put("/**/*.jpg", "anon");
filterChainDefinitionMap.put("/**/*.png", "anon");
filterChainDefinitionMap.put("/**/*.ico", "anon");
filterChainDefinitionMap.put("/druid/**", "anon");
filterChainDefinitionMap.put("/user/test", "anon"); //測試
// 添加自己的過濾器並且取名為jwt
Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
filterMap.put("jwt", new JwtFilter());
shiroFilterFactoryBean.setFilters(filterMap);
// 過濾鏈定義,從上向下順序執行,一般將放在最為下邊
filterChainDefinitionMap.put("/**", "jwt");
//未授權界面返回JSON
shiroFilterFactoryBean.setUnauthorizedUrl("/sys/common/403");
shiroFilterFactoryBean.setLoginUrl("/sys/common/403");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
@Bean("securityManager")
public DefaultWebSecurityManager securityManager(ShiroRealm myRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(myRealm);
/*
* 關閉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);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/**
* 下面的代碼是添加注解支持
*
* @return
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
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;
}
}
ShiroRealm
/**
* 用戶登錄鑒權和獲取用戶授權
*/
@Component
@Slf4j
public class ShiroRealm extends AuthorizingRealm {
@Autowired
@Lazy
private ISysUserService sysUserService;
@Autowired
@Lazy
private RedisUtil redisUtil;
/**
* 必須重寫此方法,不然Shiro會報錯
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 功能: 獲取用戶權限信息,包括角色以及權限。只有當觸發檢測用戶權限時才會調用此方法,例如checkRole,checkPermission
*
* @param principals token
* @return AuthorizationInfo 權限信息
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("————權限認證 [ roles、permissions]————");
SysUser sysUser = null;
String username = null;
if (principals != null) {
sysUser = (SysUser) principals.getPrimaryPrincipal();
username = sysUser.getUserName();
}
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
// 設置用戶擁有的角色集合,比如“admin,test”
Set<String> roleSet = sysUserService.getUserRolesSet(username);
info.setRoles(roleSet);
// 設置用戶擁有的權限集合,比如“sys:role:add,sys:user:add”
Set<String> permissionSet = sysUserService.getUserPermissionsSet(username);
info.addStringPermissions(permissionSet);
return info;
}
/**
* 功能: 用來進行身份認證,也就是說驗證用戶輸入的賬號和密碼是否正確,獲取身份驗證信息,錯誤拋出異常
*
* @param auth 用戶身份信息 token
* @return 返回封裝了用戶信息的 AuthenticationInfo 實例
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
if (token == null) {
log.info("————————身份認證失敗——————————IP地址: " + CommonUtils.getIpAddrByRequest(SpringContextUtils.getHttpServletRequest()));
throw new AuthenticationException("token為空!");
}
// 校驗token有效性
SysUser loginUser = this.checkUserTokenIsEffect(token);
return new SimpleAuthenticationInfo(loginUser, token, getName());
}
/**
* 校驗token的有效性
*
* @param token
*/
public SysUser checkUserTokenIsEffect(String token) throws AuthenticationException {
// 解密獲得username,用於和數據庫進行對比
String username = JwtUtil.getUsername(token);
if (username == null) {
throw new AuthenticationException("token非法無效!");
}
// 查詢用戶信息
SysUser loginUser = new SysUser();
SysUser sysUser = sysUserService.getUserByName(username);
if (sysUser == null) {
throw new AuthenticationException("用戶不存在!");
}
// 校驗token是否超時失效 & 或者賬號密碼是否錯誤
if (!jwtTokenRefresh(token, username, sysUser.getPassWord())) {
throw new AuthenticationException("Token失效請重新登錄!");
}
// 判斷用戶狀態
if (!"0".equals(sysUser.getDelFlag())) {
throw new AuthenticationException("賬號已被刪除,請聯系管理員!");
}
BeanUtils.copyProperties(sysUser, loginUser);
return loginUser;
}
/**
* JWTToken刷新生命周期 (解決用戶一直在線操作,提供Token失效問題)
* 1、登錄成功后將用戶的JWT生成的Token作為k、v存儲到cache緩存里面(這時候k、v值一樣)
* 2、當該用戶再次請求時,通過JWTFilter層層校驗之后會進入到doGetAuthenticationInfo進行身份驗證
* 3、當該用戶這次請求JWTToken值還在生命周期內,則會通過重新PUT的方式k、v都為Token值,緩存中的token值生命周期時間重新計算(這時候k、v值一樣)
* 4、當該用戶這次請求jwt生成的token值已經超時,但該token對應cache中的k還是存在,則表示該用戶一直在操作只是JWT的token失效了,程序會給token對應的k映射的v值重新生成JWTToken並覆蓋v值,該緩存生命周期重新計算
* 5、當該用戶這次請求jwt在生成的token值已經超時,並在cache中不存在對應的k,則表示該用戶賬戶空閑超時,返回用戶信息已失效,請重新登錄。
* 6、每次當返回為true情況下,都會給Response的Header中設置Authorization,該Authorization映射的v為cache對應的v值。
* 7、注:當前端接收到Response的Header中的Authorization值會存儲起來,作為以后請求token使用
* 參考方案:https://blog.csdn.net/qq394829044/article/details/82763936
*
* @param userName
* @param passWord
* @return
*/
public boolean jwtTokenRefresh(String token, String userName, String passWord) {
String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token));
if (CommonUtils.isNotEmpty(cacheToken)) {
// 校驗token有效性
if (!JwtUtil.verify(cacheToken, userName, passWord)) {
String newAuthorization = JwtUtil.sign(userName, passWord);
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization);
// 設置超時時間
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME / 1000);
} else {
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, cacheToken);
// 設置超時時間
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME / 1000);
}
return true;
}
return false;
}
}
JwtFilter
/**
* 鑒權登錄攔截器
**/
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* 執行登錄認證
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
throw new AuthenticationException("Token失效請重新登錄");
}
}
/**
*
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader(CommonConstant.ACCESS_TOKEN);
JwtToken jwtToken = new JwtToken(token);
// 提交給realm進行登入,如果錯誤他會拋出異常並被捕獲
getSubject(request, response).login(jwtToken);
// 如果沒有拋出異常則代表登入成功,返回true
return true;
}
/**
* 對跨域提供支持
*/
@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"));
// 跨域時會首先發送一個option請求,這里我們給option請求直接返回正常狀態
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
JwtToken
package cn.gathub.entity;
import org.apache.shiro.authc.AuthenticationToken;
public class JwtToken implements AuthenticationToken {
private static final long serialVersionUID = 1L;
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JwtUtils
/**
* JWT工具類
**/
public class JwtUtil {
// 過期時間30分鍾
public static final long EXPIRE_TIME = 30 * 60 * 1000;
/**
* 校驗token是否正確
*
* @param token 密鑰
* @param secret 用戶的密碼
* @return 是否正確
*/
public static boolean verify(String token, String username, String secret) {
try {
// 根據密碼生成JWT效驗器
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).withClaim("username", username).build();
// 效驗TOKEN
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) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附帶username信息
return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);
}
/**
* 根據request中的token獲取用戶賬號
*
* @param request
* @return
* @throws Exception
*/
public static String getUserNameByToken(HttpServletRequest request) throws Exception {
String accessToken = request.getHeader(CommonConstant.ACCESS_TOKEN);
String username = getUsername(accessToken);
if (CommonUtils.isEmpty(username)) {
throw new Exception("未獲取到用戶");
}
return username;
}
}
LoginController
@RestController
@RequestMapping("/sys")
@Slf4j
public class LoginController {
@Autowired
private ISysUserService sysUserService;
@Autowired
private RedisUtil redisUtil;
@RequestMapping(value = "/login", method = RequestMethod.POST)
public Result<JSONObject> login(@RequestBody SysUser loginUser) throws Exception {
Result<JSONObject> result = new Result<JSONObject>();
String username = loginUser.getUserName();
String password = loginUser.getPassWord();
//1. 校驗用戶是否有效
SysUser sysUser = sysUserService.getUserByName(username);
result = sysUserService.checkUserIsEffective(sysUser);
if (!result.isSuccess()) {
return result;
}
//2. 校驗用戶名或密碼是否正確
String userpassword = PasswordUtil.encrypt(username, password, sysUser.getSalt());
String syspassword = sysUser.getPassWord();
if (!syspassword.equals(userpassword)) {
result.error500("用戶名或密碼錯誤");
return result;
}
//用戶登錄信息
userInfo(sysUser, result);
return result;
}
/**
* 退出登錄
*
* @param request
* @param response
* @return
*/
@RequestMapping(value = "/logout")
public Result<Object> logout(HttpServletRequest request, HttpServletResponse response) {
//用戶退出邏輯
String token = request.getHeader(CommonConstant.ACCESS_TOKEN);
if (CommonUtils.isEmpty(token)) {
return Result.error("退出登錄失敗!");
}
String username = JwtUtil.getUsername(token);
SysUser sysUser = sysUserService.getUserByName(username);
if (sysUser != null) {
log.info(" 用戶名: " + sysUser.getRealName() + ",退出成功! ");
//清空用戶Token緩存
redisUtil.del(CommonConstant.PREFIX_USER_TOKEN + token);
//清空用戶權限緩存:權限Perms和角色集合
redisUtil.del(CommonConstant.LOGIN_USER_CACHERULES_ROLE + username);
redisUtil.del(CommonConstant.LOGIN_USER_CACHERULES_PERMISSION + username);
return Result.ok("退出登錄成功!");
} else {
return Result.error("無效的token");
}
}
/**
* 用戶信息
*
* @param sysUser
* @param result
* @return
*/
private Result<JSONObject> userInfo(SysUser sysUser, Result<JSONObject> result) {
String syspassword = sysUser.getPassWord();
String username = sysUser.getUserName();
// 生成token
String token = JwtUtil.sign(username, syspassword);
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token);
// 設置超時時間
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME / 1000);
// 獲取用戶部門信息
JSONObject obj = new JSONObject();
obj.put("token", token);
obj.put("userInfo", sysUser);
result.setResult(obj);
result.success("登錄成功");
return result;
}
}
四、演示
使用正確的用戶名密碼進行登陸,登陸成功后返回token
使用錯誤的用戶名密碼進行登陸,登陸失敗
headers中攜帶正確的token訪問接口
headers中不攜帶token或者攜帶錯誤的token訪問接口
無權限的用戶訪問接口
無需登陸token也可以訪問的接口(在過濾器中將接口或者資源文件放開)
五、github源碼地址
地址:https://github.com/it-wwh/sping-boot-shiro-jwt-redis.git
今天的更新到這里就結束了,拜拜!!!
感謝一路支持我的人,您的關注是我堅持更新的動力,有問題可以在下面評論留言或隨時與我聯系。。。。。。
QQ:850434439
微信:w850434439
EMAIL:gathub@qq.com
如果有興趣和本博客交換友鏈的話,請按照下面的格式在評論區進行評論,我會盡快添加上你的鏈接。
網站名稱:GatHub-HongHui'S Blog
網站地址:https://gathub.cn
網站描述:不習慣的事越來越多,但我仍在前進…就算步伐很小,我也在一步一步的前進。
網站Logo/頭像:頭像地址
我的微信公眾號,歡迎大家來撩!