如何整合Shiro+Jwt+Redis,以及為什么要這么做
我個人認為
①為什么用shiro:“Shiro+Jwt+Redis”模式和“單純的shiro”模式相比,主要用的是shiro里面的登錄認證和權限控制功能
②為什么用jwt:“Shiro+Jwt”模式和“Shiro+Cookie”模式相比,后者的用戶登錄信息是存儲在服務器的會話里面的,也就是后端服務器的緩存里面,這樣的話就沒辦法分布式(多個后端),解決辦法是把登錄信息以及過期時間直接存儲在一段字符串中,然后由前端保存,后端只需根據生成token時定義的秘鑰去驗證jwt是否正確即可,如果正確就允許接下來的操作。
③為什么用Redis:“Shiro+Jwt+Redis”模式和“Shiro+Jwt”模式相比,前者可以實現分布式環境下的會話共享,這么說有點抽象,通俗一點就是:在分布式系統中,用戶的會話信息需要在多個服務器之間共享,而我可以把用戶的一些前端經常請求的用戶信息或者其他信息存儲到redis里面,這樣就不用去經常查詢數據庫信息了。
所以綜上所述,我們使用Shiro+Jwt+Redis的模式。
Jwt
? 需要了解一門技術,首先從為什么產生開始說起是最好的。JWT 主要用于用戶登錄鑒權,所以我們從最傳統的 session 認證開始說起。
前置知識
**會話:**每個用戶的一次登錄到登出之間叫做一個會話。
登錄狀態:“無狀態”和“有狀態”是指對于服務器而言的兩種不同的處理方式:
- 無狀態(Stateless):在無狀態的認證機制中,服務器不需要保存任何關于客戶端的狀態信息。每次客戶端發送請求時,服務器只需要對請求進行處理,而無需考慮之前的請求狀態。這意味著服務器可以更容易地進行水平擴展,因為不需要擔心請求會被路由到特定的服務器上。
- 有狀態(Stateful):相比之下,在有狀態的認證機制中,服務器需要保存客戶端的狀態信息,通常通過會話對象或其他方式來記錄客戶端的狀態。這意味著服務器需要在多個請求之間共享狀態信息,可能需要使用特定的機制來保證狀態的一致性和可靠性。
session認證
? 眾所周知,http 協議本身是無狀態的協議(http 是一種無狀態協議,就是說每次用戶進行用戶名和密碼認證之后,http 不會留下記錄,下一次請求還需要進行認證。因為http 不知道每次請求是哪一個用戶發出的)。
? session認證就是說用戶登錄后把將此用戶的登錄狀態存儲到服務器的內存中。
? session 認證的缺點其實很明顯,由于 session 是保存在服務器里,所以如果分布式部署應用的話,會出現session不能共享的問題,很難擴展。
token認證
? token認證的過程就是在用戶第一次登錄的時候根據秘鑰(一般秘鑰中會包括此用戶的唯一標志,比如賬號)生成此次會話的token,然后之后前端每次訪問后端都攜帶token,后端再根據秘鑰解析,如果解析成功就說明token有效,進而可以信任此次請求進行接下來的操作。
? 基于 token 的認證方式是一種服務端無狀態的認證方式,服務端不存儲 token 數據,適合分布式系統。
什么是JWT
? 而JWT(全稱:Json Web Token)是一種特殊的Token,它采用了JSON格式來對Token進行編碼和解碼,并攜帶了更多的信息,例如用戶ID、角色、權限等。它包含了三部分:頭部(Header)、數據(Payload)和簽名(Signature)。其中,頭部和數據都是經過Base64編碼的JSON字符串,而簽名是對頭部和數據進行簽名后得到的字符串。
Springboot使用JWT實現登錄認證以及請求攔截
主要是兩步:配置攔截器、配置要攔截哪些接口
<!-- jwt工具--><dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>3.10.3</version></dependency>
package com.hebut.demo.common.utils;import cn.hutool.core.codec.Base64;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;import java.util.HashMap;
import java.util.Map;// JWT工具類
@Configuration
public class JwtUtil {@Value("${shiro.jwt.secret}")private static String secret;@Value("${shiro.jwt.expire}")private static Long expire;@Value("${shiro.jwt.header.alg}")private static String headerAlg;@Value("${shiro.jwt.header.typ}")private static String headerTyp;/*** 生成token*/public static String getToken(String account) {// 設置秘鑰StringBuilder stringBuilder = new StringBuilder();stringBuilder.append(account).append(secret);// 設置jwt頭headerMap<String, Object> headerClaims = new HashMap<>();headerClaims.put("alg", headerAlg); // 簽名算法headerClaims.put("typ", headerTyp); // token 類型// 設置jwt的header,負載paload以及加密算法String token = JWT.create().withHeader(headerClaims).withClaim("account" ,account).withClaim("expire", System.currentTimeMillis()+expire).sign(Algorithm.HMAC256(stringBuilder.toString()));return token;}/*** 無需秘鑰就能獲取其中的信息* 解析token.* {* "account": "account",* "timeStamp": "134143214"* }*/public static Map<String, String> parseToken(String token) {HashMap<String, String> map = new HashMap<String, String>();// 解碼 JWTDecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");Claim expire = decodedJwt.getClaim("expire");map.put("account", account.asString());map.put("expire", expire.asLong().toString());return map;}/*** 解析token獲取賬號.*/public static String getAccount(String token) {HashMap<String, String> map = new HashMap<String, String>();DecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");return account.asString();}/*** 校驗token是否正確* @param token Token* @return boolean 是否正確*/public static boolean verify(String token) {StringBuilder stringBuilder = new StringBuilder();stringBuilder.append(getAccount(token)).append(secret);// 帳號加JWT私鑰解密Algorithm algorithm = Algorithm.HMAC256(stringBuilder.toString());JWTVerifier verifier = JWT.require(algorithm).build();try {verifier.verify(token);return true; // 驗證成功} catch (JWTVerificationException e) {return false; // 驗證失敗}}}
import com.demo.util.JWTUtils;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;// 配置攔截器
@Slf4j
public class JWTInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String token = request.getHeader("token");if(StringUtils.isEmpty(token)){throw new Exception("token不能為空");}try {//在這里調用了 JWTUtils工具類的方法 驗證傳入token的合法性,你可以傳token:111 試試JWTUtils.verify(token);} catch (SignatureVerificationException e) {log.error("無效簽名! 錯誤 ->", e);return false;} catch (TokenExpiredException e) {log.error("token過期! 錯誤 ->", e);return false;} catch (AlgorithmMismatchException e) {log.error("token算法不一致! 錯誤 ->", e);return false;} catch (Exception e) {log.error("token無效! 錯誤 ->", e);return false;}return true;}
}
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
// 配置要攔截哪些接口
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new JWTInterceptor())//攔截的路徑.addPathPatterns("/**")//排除登錄接口 /test/login 表示你給控制器起的名稱/控制器下的方法,如login.excludePathPatterns("/test/login");}
}
Shiro
? Shiro提供了哪些功能呢?
- **登錄認證(Authentication):**Shiro可以對用戶進行身份驗證,確保用戶是合法的。它支持多種認證方式,包括用戶名/密碼、基于證書的認證、第三方登錄等。
- **訪問授權(Authorization):**Shiro可以對用戶進行授權,確定用戶是否有權限執行某個操作或訪問某個資源。它支持基于角色的訪問控制和基于權限的訪問控制,可以定義細粒度的權限規則。
- **會話管理(Session Management):**Shiro可以管理用戶的會話,包括跟蹤用戶的登錄狀態、管理會話的生命周期、實現單點登錄等功能。
- **密碼加密(Password Encryption):**Shiro可以幫助應用程序安全地存儲和驗證用戶密碼,它提供了多種加密算法和技術,如哈希算法、加鹽、散列迭代等。
- **RememberMe功能:**Shiro提供了RememberMe功能,可以在用戶登錄后記住用戶的身份,下次訪問時自動登錄。
- **Web支持:**Shiro提供了與Web應用程序集成的支持,可以輕松地保護Web資源、處理表單登錄、實現注銷等功能。
- **緩存支持:**Shiro支持將重要數據(如用戶信息、權限信息)緩存在內存中,提高系統的性能和響應速度。
? 不用害怕,因為我們就用到了**“登錄認證”和"訪問授權"**。本篇文章主要講解“認證”、“授權”的功能。
主要模塊講解
①Realm用于獲取用戶信息,在這里可以給登錄認證以及訪問授權這兩個事務查詢用戶相關數據,查詢完用戶數據之后,返回一個SimpleAuthorizationInfo類型的對象,交給SecurityManager管理。
②SecurityManager將從Realm得到的信息賦值給對應的subject用于進行登錄認證或者訪問授權。
③在一個用戶登錄到退出的整個過程,SecurityManager會一直為此用戶保持一個會話session,會話信息存儲在內存中,以便期間各種訪問。
登錄認證
? shiro提供了方便的登錄認證,可以通過subject.login(token)進行登錄操作。
整體流程
①前端用戶輸入賬號密碼
②后端通過賬號密碼生成Shiro提供的UsernamePasswordToken類型的token
③調用shiro用戶對象的登錄方法subject.login(token)
④subject.login(token)會去調用多個方法,其中兩個是public boolean supports(AuthenticationToken authenticationToken)
和protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
,前者判斷所傳入的token是不是shiro所支持的token也就是是不是UsernamePasswordToken類型的,后者用于獲取用戶信息。
⑤protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
方法講解,在這個方法里面有兩步,一步是根據token解析出來用戶principal(也就是賬號),然后使用principal去數據庫或者其他數據源拿此用戶對應的唯一憑證credentials(也就是密碼),拿到之后創建SimpleAuthenticationInfo(Object principal, Object credentials, String realmName)
對象,傳入三個參數,前兩個是賬號密碼,最后一個是你自定義的realm類的名字。
⑥subject.login(token)就會比對realm返回的用戶賬號密碼是否一致。
⑦除此之外,shiro還提供加密功能,比如用戶的密碼使用了md5加密,那么在配置類里面就可以聲明加密的算法,之后用戶調用subject.login(token)方法的時候就會自動給前端傳給后端的密碼加密,進而直接和realm中獲取的數據進行比對。
代碼實現
需要寫兩個類,一個shiroconfig配置類,一個realm的方法重寫。
UserRealm
// 需要重寫兩個方法
public class UserRealm extends AuthorizingRealm
{/*** 授權:這里先不實現*/@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0){return null;}/*** 登錄認證,自定義登錄認證方式:賬號是否存在,token是否過期* 重寫之后,如果需要登錄認證的接口就會自動調用此接口進行登錄認證*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException{UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken)authenticationToken;String account = usernamePasswordToken.getUsername();// 然后根據賬號從數據庫或者其他數據源查新密碼String password = select(account)// 這一行是偽代碼,select換成自己的查詢邏輯就行return new SimpleAuthenticationInfo(account, password, getName());// getName是AuthorizingRealm實現的方法,將會返回此類的名字,比如在這里就是“UserRealm”}}
ShiroConfig
@Configuration
public class ShiroConfig {// 初始化SecurityManager,把自定義的Realm交給SecurityManager管理@Bean(value = "defaultWebSecurityManager")public DefaultWebSecurityManager getDefaultWebSecurityManager(){DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();UserRealm userRealm = new UserRealm();defaultWebSecurityManager.setRealm(userRealm);return defaultWebSecurityManager;}
}
訪問授權
? 訪問授權就是通過配置shiro的攔截器攔截前端訪問請求,然后再通過Realm獲取用戶的權限信息,如果訪問用戶有此權限就通過攔截器。
整體流程
①設置要攔截哪些路徑或者接口,大概有兩個方法:注解形式和攔截器中配置
②用戶攜帶token訪問后端接口
③shiro攔截器攔截,然后調用Realm的protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0)
方法通過查詢數據庫或者其他數據源獲得此用戶的權限信息,將權限信息添加到SimpleAuthorizationInfo info = new SimpleAuthorizationInfo()
實例中并返回。
代碼實現
UserRealm
// 需要重寫兩個方法
public class UserRealm extends AuthorizingRealm
{/*** 授權:這里先不實現*/@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0){return null;}/*** 登錄認證,自定義登錄認證方式:賬號是否存在,token是否過期* 重寫之后,如果需要登錄認證的接口就會自動調用此接口進行登錄認證*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException{UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken)authenticationToken;String account = usernamePasswordToken.getUsername();// 然后根據賬號從數據庫或者其他數據源查新密碼String password = select(account)// 這一行是偽代碼,select換成自己的查詢邏輯就行return new SimpleAuthenticationInfo(account, password, getName());// getName是AuthorizingRealm實現的方法,將會返回此類的名字,比如在這里就是“UserRealm”}}
ShiroConfig
@Configuration
public class ShiroConfig {// 初始化SecurityManager,把自定義的Realm交給SecurityManager管理@Bean(value = "defaultWebSecurityManager")public DefaultWebSecurityManager getDefaultWebSecurityManager(){DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();UserRealm userRealm = new UserRealm();defaultWebSecurityManager.setRealm(userRealm);return defaultWebSecurityManager;}/*** 添加自己的過濾器,自定義url規則,* Filter工廠,設置對應的過濾條件和跳轉條件* Shiro自帶攔截器配置規則* 詳情見文檔 http://shiro.apache.org/web.html#urls-** @date 2018/8/31 10:57*/@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 添加自己的過濾器并且取名為jwtMap<String, Filter> filterMap = new HashMap<>();filterMap.put("jwt", new JwtFilter());shiroFilterFactoryBean.setFilters(filterMap);Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// // 登出
// filterChainDefinitionMap.put("/logout", "logout");// 登錄頁面可以匿名訪問filterChainDefinitionMap.put("/sys/login", "anon");
// // 首頁需要身份驗證后才能訪問
// filterChainDefinitionMap.put("/index", "authc");
// // 錯誤頁面,認證不通過跳轉
// filterChainDefinitionMap.put("/error", "authc");
// // 其他頁面需要具有 admin 角色才能訪問
// filterChainDefinitionMap.put("/admin/**", "roles[admin]");
// // 其他頁面需要具有 user:create 權限才能訪問
// filterChainDefinitionMap.put("/sys/login", "perms[sys:login]");
// // 其他頁面需要具有 user:update 和 user:delete 權限才能訪問
// filterChainDefinitionMap.put("/user/manage", "perms[\"user:update,user:delete\"]");filterChainDefinitionMap.put("/**", "jwt"); // /**,一般放在最下,表示對所有資源起作用,使用JwtFiltershiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}/*開啟注解的權限控制@RequiresAuthentication(標識用戶必須在當前會話中進行了身份驗證(登錄)才能訪問被注解的方法或類)@RequiresUser(標識用戶必須在應用程序中進行了身份驗證(不一定是當前會話)才能訪問被注解的方法或類)@RequiresGuest(標識用戶必須是一個“guest”(未經身份驗證)才能訪問被注解的方法或類)@RequiresRoles(標識用戶必須具有指定的角色才能訪問被注解的方法或類。可以指定一個或多個角色,如 @RequiresRoles("admin") 或 @RequiresRoles({"admin", "user"}))@RequiresPermissions(標識用戶必須具有指定的權限才能訪問被注解的方法或類。可以指定一個或多個權限,如 @RequiresPermissions("user:create") 或 @RequiresPermissions({"user:create", "user:update"}))@RequiresGuest(標識用戶必須是一個“guest”(未經身份驗證)才能訪問被注解的方法或類)*/@Beanpublic AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();advisor.setSecurityManager(securityManager);return advisor;}
}
Redis
? 那為什么還要用Redis呢,從Shiro章節可以得知,如果要實現每次訪問后端接口進行登錄認證攔截的話,都要調用Realm中的登錄認證方法,這樣的話每次都要查詢數據庫,數據庫壓力太大,所以我們使用Redis來存儲用戶登錄信息來解決這個問題,除此之外Redis里面還能存儲更多前端經常要訪問到的用戶信息,省的經常去數據庫里面查詢了。
Springboot整合Shiro+Jwt+Redis
數據流向和項目結構
數據流向
項目結構
Redis配置
配置文件,配置redis地址
spring:redis:host: 10.1.40.83port: 6379password:database: 0timeout: 5000lettuce:pool:max-idle: 16max-active: 32min-idle: 8
配置Redis的常量
/*** 常量* @author dolyw.com* @date 2018/9/3 16:03*/
public class RedisConstant {private RedisConstant() {}public static final String PREFIX_REFRESH_TOKEN = "refresh_token";public static final String PREFIX_ACCESS_TOKEN = "access_token";public static final String PREFIX_SHIRO_EXPIRE = "access_token";public static final String PREFIX_SHIRO_JWT = "shiro:jwt:";}
config自動注入
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;@Configuration
public class RedisConfig {@Bean(name = "redisTemplate")public RedisTemplate<String, Object> getRedisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {// 設置序列化Jackson2JsonRedisSerializer<Object> 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);// 配置redisTemplateRedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();redisTemplate.setConnectionFactory(lettuceConnectionFactory);RedisSerializer<?> stringSerializer = new StringRedisSerializer();// key序列化redisTemplate.setKeySerializer(stringSerializer);// value序列化redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);// Hash key序列化redisTemplate.setHashKeySerializer(stringSerializer);// Hash value序列化redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);redisTemplate.afterPropertiesSet();return redisTemplate;}
}
import com.alibaba.excel.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;import java.util.concurrent.TimeUnit;/*** @className: RedisUtil* @description:* @author: sh.Liu* @date: 2022-03-09 14:07*/
// TODO: 2023/7/25 此工具類可以進一步優化
@Component
public class RedisUtil {// 使用jwt的過期時間毫秒private final long defaultTimeout = 1*24*60*60*1000;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;/*** 是否存在指定的key** @param key* @return*/public boolean hasKey(String key) {return Boolean.TRUE.equals(redisTemplate.hasKey(key));}/*** 刪除指定的key** @param key* @return*/public boolean delete(String key) {return Boolean.TRUE.equals(redisTemplate.delete(key));}//- - - - - - - - - - - - - - - - - - - - - String類型 - - - - - - - - - - - - - - - - - - - -/*** 根據key獲取值** @param key 鍵* @return 值*/public Object get(String key) {return key == null ? null : redisTemplate.opsForValue().get(key);}/*** 將值放入緩存** @param key 鍵* @param value 值* @return true成功 false 失敗*/public void set(String key, String value) {set(key, value, defaultTimeout);}/*** 將值放入緩存并設置時間** @param key 鍵* @param value 值* @param time 時間(秒) -1為無期限* @return true成功 false 失敗*/public void set(String key, String value, long time) {if (time > 0) {redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);} else {redisTemplate.opsForValue().set(key, value, defaultTimeout, TimeUnit.SECONDS);}}//- - - - - - - - - - - - - - - - - - - - - object類型 - - - - - - - - - - - - - - - - - - - -/*** 根據key讀取數據*/public Object getObject(final String key) {if (StringUtils.isBlank(key)) {return null;}try {return redisTemplate.opsForValue().get(key);} catch (Exception e) {e.printStackTrace();}return null;}/*** 寫入數據*/public boolean setObject(final String key, Object value) {if (StringUtils.isBlank(key)) {return false;}try {setObject(key, value , defaultTimeout);return true;} catch (Exception e) {e.printStackTrace();}return false;}public boolean setObject(final String key, Object value, long time) {if (StringUtils.isBlank(key)) {return false;}if (time > 0) {redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);} else {redisTemplate.opsForValue().set(key, value, defaultTimeout, TimeUnit.SECONDS);}return true;}
}
JWT配置
依賴
<!--jwt--><dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>3.19.0</version></dependency>
配置類
shiro:jwt:# 加密秘鑰secret: f4e2e52034348f86b67cde581c0f9eb5# token有效時長,7天,單位毫秒expire: 604800000header:# 加密算法alg: HS256# token類型typ: JWT
JWT工具類
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;import java.util.HashMap;
import java.util.Map;@Component
public class JwtUtil {@Value("${shiro.jwt.secret}")private String secret;@Value("${shiro.jwt.expire}")private Long expire;@Value("${shiro.jwt.header.alg}")private String headerAlg;@Value("${shiro.jwt.header.typ}")private String headerTyp;/*** 生成token*/public String getToken(String account, long currentTimeMillis) {// 設置秘鑰StringBuilder stringBuilder = new StringBuilder();stringBuilder.append(account).append(secret);// 設置jwt頭headerMap<String, Object> headerClaims = new HashMap<>();headerClaims.put("alg", headerAlg); // 簽名算法headerClaims.put("typ", headerTyp); // token 類型// 設置jwt的header,負載paload以及加密算法String token = JWT.create().withHeader(headerClaims).withClaim("account" ,account).withClaim("expire", currentTimeMillis + expire).sign(Algorithm.HMAC256(stringBuilder.toString()));return token;}/*** 無需秘鑰就能獲取其中的信息* 解析token.* {* "account": "account",* "timeStamp": "134143214"* }*/public Map<String, String> parseToken(String token) {HashMap<String, String> map = new HashMap<String, String>();// 解碼 JWTDecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");Claim expire = decodedJwt.getClaim("expire");map.put("account", account.asString());map.put("expire", expire.asLong().toString());return map;}/*** 解析token獲取賬號.*/public String getAccount(String token) {HashMap<String, String> map = new HashMap<String, String>();DecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");return account.asString();}/*** 校驗token是否正確* @param token Token* @return boolean 是否正確*/public boolean verify(String token) {DecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");Claim expire = decodedJwt.getClaim("expire");StringBuilder stringBuilder = new StringBuilder();stringBuilder.append(account.asString()).append(secret);// 驗證JWT的簽名和有效性Algorithm algorithm = Algorithm.HMAC256(stringBuilder.toString());JWTVerifier verifier = JWT.require(algorithm).build();try {verifier.verify(token);return true; // 驗證通過} catch (JWTVerificationException e) {return false; // 驗證失敗}}/*** 校驗token是否過期* @param token Token* @return boolean 是否正確*/public boolean isExpired(String token) {DecodedJWT decodedJwt = JWT.decode(token);Claim expire = decodedJwt.getClaim("expire");// 驗證過期時間Long expireTime = expire.asLong();if (System.currentTimeMillis() > expireTime) {return true;}return false;}/*** 獲取token過期時間* @param token Token* @return boolean 是否正確*/public long getExpiredTime(String token) {DecodedJWT decodedJwt = JWT.decode(token);Claim expire = decodedJwt.getClaim("expire");return expire.asLong();}}
Shiro配置
ShiroConfig
@Configuration
public class ShiroConfig {/*** 添加自己的過濾器,自定義url規則,* Filter工廠,設置對應的過濾條件和跳轉條件* Shiro自帶攔截器配置規則* 詳情見文檔 http://shiro.apache.org/web.html#urls-** @date 2018/8/31 10:57*/@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 添加自己的過濾器并且取名為jwtMap<String, Filter> filterMap = new HashMap<>();filterMap.put("jwt", new JwtFilter());shiroFilterFactoryBean.setFilters(filterMap);Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// // 登出
// filterChainDefinitionMap.put("/logout", "logout");// 登錄頁面可以匿名訪問filterChainDefinitionMap.put("/sys/login", "anon");
// // 首頁需要身份驗證后才能訪問
// filterChainDefinitionMap.put("/index", "authc");
// // 錯誤頁面,認證不通過跳轉
// filterChainDefinitionMap.put("/error", "authc");
// // 其他頁面需要具有 admin 角色才能訪問
// filterChainDefinitionMap.put("/admin/**", "roles[admin]");
// // 其他頁面需要具有 user:create 權限才能訪問
// filterChainDefinitionMap.put("/sys/login", "perms[sys:login]");
// // 其他頁面需要具有 user:update 和 user:delete 權限才能訪問
// filterChainDefinitionMap.put("/user/manage", "perms[\"user:update,user:delete\"]");filterChainDefinitionMap.put("/**", "jwt"); // /**,一般放在最下,表示對所有資源起作用,使用JwtFiltershiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}@Bean(value = "defaultWebSecurityManager")public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("userRealm") UserRealm userRealm){DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();defaultWebSecurityManager.setRealm(userRealm);//關閉shiro的session(無狀態的方式使用shiro)DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();defaultSessionStorageEvaluator.setSessionStorageEnabled(false);subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);defaultWebSecurityManager.setSubjectDAO(subjectDAO);return defaultWebSecurityManager;}// 將自己的驗證方式加入容器@Beanpublic UserRealm userRealm() {return new UserRealm();}/*開啟注解的權限控制@RequiresAuthentication(標識用戶必須在當前會話中進行了身份驗證(登錄)才能訪問被注解的方法或類)@RequiresUser(標識用戶必須在應用程序中進行了身份驗證(不一定是當前會話)才能訪問被注解的方法或類)@RequiresGuest(標識用戶必須是一個“guest”(未經身份驗證)才能訪問被注解的方法或類)@RequiresRoles(標識用戶必須具有指定的角色才能訪問被注解的方法或類。可以指定一個或多個角色,如 @RequiresRoles("admin") 或 @RequiresRoles({"admin", "user"}))@RequiresPermissions(標識用戶必須具有指定的權限才能訪問被注解的方法或類。可以指定一個或多個權限,如 @RequiresPermissions("user:create") 或 @RequiresPermissions({"user:create", "user:update"}))@RequiresGuest(標識用戶必須是一個“guest”(未經身份驗證)才能訪問被注解的方法或類)*/@Beanpublic AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();advisor.setSecurityManager(securityManager);return advisor;}/*** 由Spring管理 Shiro的生命周期*/@Beanpublic LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {return new LifecycleBeanPostProcessor();}@Bean@DependsOn("lifecycleBeanPostProcessor")public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();// 強制使用cglib,防止重復代理和可能引起代理出錯的問題defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);return defaultAdvisorAutoProxyCreator;}
}
Realm配置
import com.hebut.demo.common.constant.RedisConstant;
import com.hebut.demo.common.utils.JwtUtil;
import com.hebut.demo.common.utils.RedisUtil;
import org.apache.shiro.authc.*;
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 java.util.HashSet;
import java.util.Set;/*** 自定義Realm 處理登錄 權限* * @author ruoyi*/
public class UserRealm extends AuthorizingRealm
{@Autowiredprivate RedisUtil redisUtil;@Autowiredprivate JwtUtil jwtUtil;// 這個方法要重寫,debug源碼得知shiro會判斷token的類型是不是自己支持的類型,不重寫的話會報錯@Overridepublic boolean supports(AuthenticationToken authenticationToken) {return authenticationToken instanceof JwtToken;}/*** 授權*/@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0){// 獲取第一個身份(用戶信息),由于在doGetAuthenticationInfo方法中返回的對象中principal參數傳入的是token,所以這里獲得的也是tokenString token = (String) arg0.getPrimaryPrincipal();String account = jwtUtil.getAccount(token);// 角色列表Set<String> roles = new HashSet<String>();// 根據賬號從數據庫或者其他數據源獲取角色信息(這個操作省略不寫了)// 比如查詢到用戶有一個admin的角色,在這里添加roles.add("admin");// 功能權限Set<String> menus = new HashSet<String>();// 根據賬號從數據庫或者其他數據源獲取權限信息(這個操作省略不寫了)// 比如查詢到用戶有一個sys:select的權限,在這里添加menus.add("sys:select");SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();// 管理員擁有所有權限info.setRoles(roles);info.setStringPermissions(menus);return info;}/*** 登錄認證,自定義登錄認證方式:賬號是否存在,token是否過期* 重寫之后,如果需要登錄認證的接口就會自動調用此接口進行登錄認證*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException{System.out.println("登錄認證");// JwtToken中重寫了這個方法了String token = (String) authenticationToken.getCredentials();// 判斷token是否有效(這里只是驗證了簽名是否有效)if (!jwtUtil.verify(token)){return null;}String account = jwtUtil.getAccount(token);if (jwtUtil.isExpired(token)){// 如果過期了,去redis里面查看refreshtime(這里沒有實現這個步驟,直接過期了就退出)return null;}else {// 沒有過期則判斷token是否和redis里面存儲的相等// 獲取accessTokenString accessToken = redisUtil.getObject(RedisConstant.PREFIX_SHIRO_JWT + account + RedisConstant.PREFIX_ACCESS_TOKEN).toString();if(token.equals(accessToken)){// 認證通過則返回認證信息return new SimpleAuthenticationInfo(token, token, getName());}return null;}}}
重寫AuthenticationToken
import org.apache.shiro.authc.AuthenticationToken;/*** @author: lhy* 自定義的shiro接口token,可以通過這個類將string的token轉型成AuthenticationToken,可供shiro使用* 注意:需要重寫getPrincipal和getCredentials方法,因為是進行三件套處理的,沒有特殊配置shiro無法通過這兩個 方法獲取到用戶名和密碼,需要直接返回token,之后交給JwtUtil去解析獲取。(當然了,可以對realm進行配 置HashedCredentialsMatcher,這里就不這么處理了)*/
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;}
}
繼承BasicHttpAuthenticationFilter并重寫,用于每次訪問后端的時候做登錄認證
import com.hebut.demo.common.shiro.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.stereotype.Component;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;/*** @author: lhy* jwt過濾器,作為shiro的過濾器,對請求進行攔截并處理跨域配置不在這里配了,我在另外的配置類進行配置了,這里把重心放在驗證上*/
@Slf4j
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter{/*** 過濾器攔截請求的入口方法*/@Overrideprotected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {try {return executeLogin(request, response); //token驗證} catch (Exception e) {e.printStackTrace();return false;}}/*** 進行token的驗證*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {//在請求頭中獲取tokenHttpServletRequest httpServletRequest = (HttpServletRequest) request;String token = httpServletRequest.getHeader("Authorization"); //前端命名Authorization//token不存在if(token == null || "".equals(token)){return false;}//token存在,進行驗證JwtToken jwtToken = new JwtToken(token);getSubject(request, response).login(jwtToken); //通過subject,提交給myRealm進行登錄驗證return true;}/*** isAccessAllowed()方法返回false,即認證不通過時進入onAccessDenied方法*/
// @Override
// protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// return super.onAccessDenied(request, response);
// }/*** token認證executeLogin成功后,進入此方法,可以進行token更新過期時間*/
// @Override
// protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {// }
}
controller層異常攔截
import org.apache.shiro.authz.AuthorizationException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;// 用于攔截controller中拋出的異常
// 使用樣例:@ControllerAdvice(basePackages="org.my.pkg")掃描此包下面所有的controller
@ControllerAdvice
public class GlobalExceptionHandler {// 攔截AuthorizationException異常@ExceptionHandler(AuthorizationException.class)@ResponseBodypublic String handleAuthorizationException(AuthorizationException e) {return "沒有通過權限驗證!";}
}