寫在前面
在上一篇文章《SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理(上)----築基中期》當中,我們初步實現了SpringBoot整合Shiro實現認證和授權。
在這篇文章當中,我將帶領大家一起完善這個Demo。當然,在這之前我們需要了解一些知識點。
本片文章與上一篇《SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理(上)----築基中期》 緊密相連,建議您先閱讀上一篇文章,再閱讀本文。
知識點補充
Shiro快取
流程分析
在原來的專案當中,由於沒有配置快取,因此每次需要驗證當前主體有沒有訪問許可權時,都會去查詢資料庫。由於許可權資料是典型的讀多寫少的資料,因此,我們應該要對其加入快取的支援。
當我們加入快取後,shiro在做鑑權時先去快取裡查詢相關資料,快取裡沒有,則查詢資料庫並將查到的資料寫入快取,下次再查時就能從快取當中獲取資料,而不是從資料庫中獲取。這樣就能改善我們的應用的效能。
接下來,我們去實現shiro的快取管理部分。
Shiro會話機制
Shiro 提供了完整的企業級會話管理功能,不依賴於底層容器(如 web 容器 tomcat),不管 JavaSE 還是 JavaEE 環境都可以使用,提供了會話管理、會話事件監聽、會話儲存 / 持久化、容器無關的叢集、失效 / 過期支援、對 Web 的透明支援、SSO 單點登入的支援等特性。
我們將使用 Shiro 的會話管理來接管我們應用的web會話,並通過Redis來儲存會話資訊。
整合步驟
新增快取
CacheManager
在Shiro當中,它提供了CacheManager這個類來做快取管理。
使用Shiro預設的EhCache實現
在shiro當中,預設使用的是EhCache快取框架。EhCache 是一個純Java的程式內快取框架,具有快速、精幹等特點。關於更多EhCache的內容,同學們可以自行百度瞭解,這裡不做過多介紹。
引入shiro-EhCache依賴
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.4.0</version>
</dependency>
在SpringBoot整合Redis的過程中,還要注意版本匹配的問題,不然有可能報方法未找到的異常。
在ShiroConfig中新增快取配置
private void enableCache(MySQLRealm realm){
//開啟全域性快取配置
realm.setCachingEnabled(true);
//開啟認證快取配置
realm.setAuthenticationCachingEnabled(true);
//開啟授權快取配置
realm.setAuthorizationCachingEnabled(true);
//為了方便操作,我們給快取起個名字
realm.setAuthenticationCacheName("authcCache");
realm.setAuthorizationCacheName("authzCache");
//注入快取實現
realm.setCacheManager(new EhCacheManager());
}
然後再在getRealm中呼叫這個方法即可。
提示:在這個實現當中,只是實現了本地的快取。也就是說快取的資料同應用一樣共用一臺機器的記憶體。如果伺服器發生當機或意外停電,那麼快取資料也將不復存在。當然你也可通過cacheManager.setCacheManagerConfigFile()方法給予快取更多的配置。
接下來我們將通過Redis快取我們的許可權資料
使用Redis實現
新增依賴
<!--shiro-redis相關依賴-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.1.0</version>
<!-- 裡面這個shiro-core版本較低,會引發一個異常
ClassNotFoundException: org.apache.shiro.event.EventBus
需要排除,直接使用上面的shiro
shiro1.3 加入了時間匯流排。-->
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</exclusion>
</exclusions>
</dependency>
配置redis
在application.yml中新增redis的相關配置
spring:
redis:
host: 127.0.0.1
port: 6379
password: hewenping
timeout: 3000
jedis:
pool:
min-idle: 5
max-active: 20
max-idle: 15
修改ShiroConfig配置類,新增shiro-redis外掛配置
/**shiro配置類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/10/6 9:11
*/
@Configuration
public class ShiroConfig {
private static final String CACHE_KEY = "shiro:cache:";
private static final String SESSION_KEY = "shiro:session:";
private static final int EXPIRE = 18000;
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.timeout}")
private int timeout;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.jedis.pool.min-idle}")
private int minIdle;
@Value("${spring.redis.jedis.pool.max-idle}")
private int maxIdle;
@Value("${spring.redis.jedis.pool.max-active}")
private int maxActive;
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* 建立ShiroFilter攔截器
* @return ShiroFilterFactoryBean
*/
@Bean(name = "shiroFilterFactoryBean")
public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
//配置不攔截路徑和攔截路徑,順序不能反
HashMap<String, String> map = new HashMap<>(5);
map.put("/authc/**","anon");
map.put("/login.html","anon");
map.put("/js/**","anon");
map.put("/css/**","anon");
map.put("/**","authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
//覆蓋預設的登入url
shiroFilterFactoryBean.setLoginUrl("/authc/unauthc");
return shiroFilterFactoryBean;
}
@Bean
public Realm getRealm(){
//設定憑證匹配器,修改為hash憑證匹配器
HashedCredentialsMatcher myCredentialsMatcher = new HashedCredentialsMatcher();
//設定演算法
myCredentialsMatcher.setHashAlgorithmName("md5");
//雜湊次數
myCredentialsMatcher.setHashIterations(1024);
MySQLRealm realm = new MySQLRealm();
realm.setCredentialsMatcher(myCredentialsMatcher);
//開啟快取
realm.setCachingEnabled(true);
realm.setAuthenticationCachingEnabled(true);
realm.setAuthorizationCachingEnabled(true);
return realm;
}
/**
* 建立shiro web應用下的安全管理器
* @return DefaultWebSecurityManager
*/
@Bean
public DefaultWebSecurityManager getSecurityManager( Realm realm){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(realm);
securityManager.setCacheManager(cacheManager());
SecurityUtils.setSecurityManager(securityManager);
return securityManager;
}
/**
* 配置Redis管理器
* @Attention 使用的是shiro-redis開源外掛
* @return
*/
@Bean
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
redisManager.setTimeout(timeout);
redisManager.setPassword(password);
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(maxIdle+maxActive);
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMinIdle(minIdle);
redisManager.setJedisPoolConfig(jedisPoolConfig);
return redisManager;
}
@Bean
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
redisCacheManager.setKeyPrefix(CACHE_KEY);
// shiro-redis要求放在session裡面的實體類必須有個id標識
//這是組成redis中所儲存資料的key的一部分
redisCacheManager.setPrincipalIdFieldName("username");
return redisCacheManager;
}
}
修改MySQLRealm
中的doGetAuthenticationInfo
方法,將User
物件整體作為SimpleAuthenticationInfo
的第一個引數。shiro-redis將根據RedisCacheManager
的principalIdFieldName
屬性值從第一個引數中獲取id值作為redis中資料的key的一部分。
/**
* 認證
* @param token
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
if(token==null){
return null;
}
String principal = (String) token.getPrincipal();
User user = userService.findByUsername(principal);
SimpleAuthenticationInfo simpleAuthenticationInfo = new MyAuthcInfo(
//由於shiro-redis外掛需要從這個屬性中獲取id作為redis的key
//所有這裡傳的是user而不是username
user,
//憑證資訊
user.getPassword(),
//加密鹽值
new CurrentSalt(user.getSalt()),
getName());
return simpleAuthenticationInfo;
}
並修改MySQLRealm
中的doGetAuthorizationInfo
方法,從User物件中獲取主身份資訊。
/**
* 授權
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
User user = (User) principals.getPrimaryPrincipal();
String username = user.getUsername();
List<Role> roleList = roleService.findByUsername(username);
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
for (Role role : roleList) {
authorizationInfo.addRole(role.getRoleName());
}
List<Long> roleIdList = new ArrayList<>();
for (Role role : roleList) {
roleIdList.add(role.getRoleId());
}
List<Resource> resourceList = resourceService.findByRoleIds(roleIdList);
for (Resource resource : resourceList) {
authorizationInfo.addStringPermission(resource.getResourcePermissionTag());
}
return authorizationInfo;
}
自定義Salt
由於Shiro裡面預設的SimpleByteSource
沒有實現序列化介面,導致ByteSource.Util.bytes()生成的salt在序列化時出錯,因此需要自定義Salt類並實現序列化介面。並在自定義的Realm的認證方法使用new CurrentSalt(user.getSalt())
傳入鹽值。
/**由於shiro當中的ByteSource沒有實現序列化介面,快取時會發生錯誤
* 因此,我們需要通過自定義ByteSource的方式實現這個介面
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/10/8 16:17
*/
public class CurrentSalt extends SimpleByteSource implements Serializable {
public CurrentSalt(String string) {
super(string);
}
public CurrentSalt(byte[] bytes) {
super(bytes);
}
public CurrentSalt(char[] chars) {
super(chars);
}
public CurrentSalt(ByteSource source) {
super(source);
}
public CurrentSalt(File file) {
super(file);
}
public CurrentSalt(InputStream stream) {
super(stream);
}
}
新增Shiro自定義會話
新增自定義會話ID生成器
/**SessionId生成器
* <p>@author 賴柄灃 laibingf_dev@outlook.com</p>
* <p>@date 2020/8/15 15:19</p>
*/
public class ShiroSessionIdGenerator implements SessionIdGenerator {
/**
*實現SessionId生成
* @param session
* @return
*/
@Override
public Serializable generateId(Session session) {
Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session);
return String.format("login_token_%s", sessionId);
}
}
新增自定義會話管理器
/**
* <p>@author 賴柄灃 laibingf_dev@outlook.com</p>
* <p>@date 2020/8/15 15:40</p>
*/
public class ShiroSessionManager extends DefaultWebSessionManager {
//定義常量
private static final String AUTHORIZATION = "Authorization";
private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
//重寫構造器
public ShiroSessionManager() {
super();
this.setDeleteInvalidSessions(true);
}
/**
* 重寫方法實現從請求頭獲取Token便於介面統一
* * 每次請求進來,
* Shiro會去從請求頭找Authorization這個key對應的Value(Token)
* @param request
* @param response
* @return
*/
@Override
public Serializable getSessionId(ServletRequest request, ServletResponse response) {
String token = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
//如果請求頭中存在token 則從請求頭中獲取token
if (!StringUtils.isEmpty(token)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return token;
} else {
// 這裡禁用掉Cookie獲取方式
return null;
}
}
}
配置自定義會話管理器
在ShiroConfig中新增對會話管理器的配置
/**
* SessionID生成器
*
*/
@Bean
public ShiroSessionIdGenerator sessionIdGenerator(){
return new ShiroSessionIdGenerator();
}
/**
* 配置RedisSessionDAO
*/
@Bean
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
redisSessionDAO.setKeyPrefix(SESSION_KEY);
redisSessionDAO.setExpire(EXPIRE);
return redisSessionDAO;
}
/**
* 配置Session管理器
* @Author Sans
*
*/
@Bean
public SessionManager sessionManager() {
ShiroSessionManager shiroSessionManager = new ShiroSessionManager();
shiroSessionManager.setSessionDAO(redisSessionDAO());
//禁用cookie
shiroSessionManager.setSessionIdCookieEnabled(false);
//禁用會話id重寫
shiroSessionManager.setSessionIdUrlRewritingEnabled(false);
return shiroSessionManager;
}
目前最新版本(1.6.0)中,session管理器的setSessionIdUrlRewritingEnabled(false)配置沒有生效,導致沒有認證直接訪問受保護資源出現多次重定向的錯誤。將shiro版本切換為1.5.0後就解決了這個bug。
本來這篇文章應該是昨晚發的,因為這個原因搞了好久,所有今天才發。。。
修改自定義Realm的doGetAuthenticationInfo認證方法
在認證資訊返回前,我們需要做一個判斷:如果當前使用者已在舊裝置上登入,則需要將舊裝置上的會話id刪掉,使其下線。
/**
* 認證
* @param token
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
if(token==null){
return null;
}
String principal = (String) token.getPrincipal();
User user = userService.findByUsername(principal);
SimpleAuthenticationInfo simpleAuthenticationInfo = new MyAuthcInfo(
//由於shiro-redis外掛需要從這個屬性中獲取id作為redis的key
//所有這裡傳的是user而不是username
user,
//憑證資訊
user.getPassword(),
//加密鹽值
new CurrentSalt(user.getSalt()),
getName());
//清除當前主體舊的會話,相當於你在新電腦上登入系統,把你之前在舊電腦上登入的會話擠下去
ShiroUtils.deleteCache(user.getUsername(),true);
return simpleAuthenticationInfo;
}
修改login介面
我們將會話資訊儲存在redis中,並在使用者認證通過後將會話Id以token的形式返回給使用者。使用者請求受保護資源時帶上這個token,我們根據token資訊去redis中獲取使用者的許可權資訊,從而做訪問控制。
@PostMapping("/login")
public HashMap<Object, Object> login(@RequestBody LoginVO loginVO) throws AuthenticationException {
boolean flags = authcService.login(loginVO);
HashMap<Object, Object> map = new HashMap<>(3);
if (flags){
Serializable id = SecurityUtils.getSubject().getSession().getId();
map.put("msg","登入成功");
map.put("token",id);
return map;
}else {
return null;
}
}
新增全域性異常處理
/**shiro異常處理
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/10/7 18:01
*/
@ControllerAdvice(basePackages = "pers.lbf.springbootshiro")
public class AuthExceptionHandler {
//==================認證異常====================//
@ExceptionHandler(ExpiredCredentialsException.class)
@ResponseBody
public String expiredCredentialsExceptionHandlerMethod(ExpiredCredentialsException e) {
return "憑證已過期";
}
@ExceptionHandler(IncorrectCredentialsException.class)
@ResponseBody
public String incorrectCredentialsExceptionHandlerMethod(IncorrectCredentialsException e) {
return "使用者名稱或密碼錯誤";
}
@ExceptionHandler(UnknownAccountException.class)
@ResponseBody
public String unknownAccountExceptionHandlerMethod(IncorrectCredentialsException e) {
return "使用者名稱或密碼錯誤";
}
@ExceptionHandler(LockedAccountException.class)
@ResponseBody
public String lockedAccountExceptionHandlerMethod(IncorrectCredentialsException e) {
return "賬戶被鎖定";
}
//=================授權異常=====================//
@ExceptionHandler(UnauthorizedException.class)
@ResponseBody
public String unauthorizedExceptionHandlerMethod(UnauthorizedException e){
return "未授權!請聯絡管理員授權";
}
}
實際開發中,應該對返回結果統一化,並給出業務錯誤碼。這已經超出了本文的範疇,如有需要,請根據自身系統特點考量。
進行測試
認證
登入成功的情況
使用者名稱或密碼錯誤的情況
為了安全起見,不要暴露具體是使用者名稱錯誤還是密碼錯誤。
訪問受保護資源
認證後訪問有許可權的資源
認證後訪問無許可權的資源
未認證直接訪問的情況
檢視redis
三個鍵值分別對應認證資訊快取、授權資訊快取和會話資訊快取。
寫在最後
目前基本上把shiro的入門知識點學完了。國慶中秋小長假也結束了。後面有時間再補充shiro標籤內容的使用。
最後貼出shiro的入門修仙功法連結,方便檢視:
- 《走進shiro,構建安全的應用程式---shiro修仙序章》
- 《shiro認證流程原始碼分析--練氣初期》
- 《Shiro入門學習---使用自定義Realm完成認證|練氣中期》
- 《shiro入門學習--使用MD5和salt進行加密|練氣後期》
- 《shiro入門學習--授權(Authorization)|築基初期|》
- 《SpringBoot整合Shiro+MD5+Salt+Redis實現認證和動態許可權管理(上)----築基中期》
如果您覺得這篇文章能給您帶來幫助,那麼可以點贊鼓勵一下。如有錯誤之處,還請不吝賜教。在此,謝過各位鄉親父老!
程式碼及sql下載方式:微信搜尋【Java開發實踐】,加關注並回復20201009
即可獲取下載連結。