簡介
Apache Shiro是一個強大且易用的Java安全架構,執行身份驗證、授權、密碼學和會話管理。使用Shiro的易于了解的API,您可以快速、輕松地獲得任何應用程式,從最小的移動應用程式到最大的網絡和企業應用程式。
demo位址在最下方給出。
本文主要實作shiro的以下幾個功能:
1.當使用者沒有登陸時隻能通路登入接口,通路其他接口會傳回無效的授權碼
2.當使用者登陸成功後,隻能通路該使用者權限下的接口,其他接口會傳回無權限通路
3.一個使用者不能兩個人同時線上,後登入的會自動踢出先登入的使用者
本文使用架構如下:
- 核心架構:spring boot 2.0.0, spring
- mvc架構:spring mvc
- 持久層架構:mybatis
- 資料庫連接配接池:alibaba druid
- 安全架構:apache shiro
- 緩存架構:redis
- 日志架構:logback
資料庫設計:
資料庫主要分為5個表,分别是:使用者表,角色表,權限表,角色權限表,使用者角色表
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIwczX0xiRGZkRGZ0Xy9GbvNGL2EzXlpXazxSP9cnT4t2Va1WNXpVeWNjYvB3MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnL0UDN5QTN1UTMwMDMxgTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
由于資料表結構我直接拷貝的以前的項目,是以上表中很多字段該文不會用到,各位請根據自己的實際情況修改。
引入依賴:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.alex</groupId>
<artifactId>springboot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>springboot</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<shiro.version>1.4.0</shiro.version>
<shiro-redis.version>3.1.0</shiro-redis.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 通路靜态資源 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 分頁插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.2.5</version>
</dependency>
<!-- alibaba的druid資料庫連接配接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.0</version>
</dependency>
<[email protected]自動化日志對象-log-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.16</version>
</dependency>
<!-- shiro spring. -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>${shiro.version}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro.version}</version>
</dependency>
<!-- shiro ehcache -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>${shiro.version}</version>
</dependency>
<!-- shiro+redis緩存插件 -->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>${shiro-redis.version}</version>
</dependency>
<!--工具類-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>
<!-- fastjson阿裡巴巴jSON處理器 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.13</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
編輯application.yml
server:
port: 8080
spring:
datasource:
name: test
url: jdbc:mysql://127.0.0.1:3306/springboot
username: admin
password: 123456
# 使用druid資料源
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
#初始化大小,最小,最大
initialSize: 5
minIdle: 5
maxActive: 20
# 配置擷取連接配接等待逾時的時間
maxWait: 60000
# 配置間隔多久才進行一次檢測,檢測需要關閉的空閑連接配接,機關是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一個連接配接在池中最小生存的時間,機關是毫秒
minEvictableIdleTimeMillis: 300000
# 校驗SQL,Oracle配置 spring.datasource.validationQuery=SELECT 1 FROM DUAL,如果不配validationQuery項,則下面三項配置無用
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
# 打開PSCache,并且指定每個連接配接上PSCache的大小
poolPreparedStatements: true
maxPoolPreparedStatementPerConnectionSize : 20
# 配置監控統計攔截的filters,去掉後監控界面sql無法統計,'wall'用于防火牆
filters: stat, wall, logback
connectionProperties : druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
redis:
host: 127.0.0.1
port: 6379
password : 123456
timeout: 0
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
## 該配置節點為獨立的節點,有很多同學容易将這個配置放在spring的節點下,導緻配置無法被識别
mybatis:
mapper-locations: classpath:mapping/*.xml #注意:一定要對應mapper映射xml檔案的所在路徑
type-aliases-package: com.alex.springboot.model # 注意:對應實體類的路徑
#pagehelper分頁插件
pagehelper:
helperDialect: mysql
reasonable: true
supportMethodsArguments: true
params: count=countSql
建立ShiroConfig
package com.alex.springboot.system.config;
import com.alex.springboot.system.shiro.CredentialsMatcher;
import com.alex.springboot.system.shiro.SessionControlFilter;
import com.alex.springboot.system.shiro.SessionManager;
import com.alex.springboot.system.shiro.ShiroRealm;
import org.apache.shiro.mgt.SecurityManager;
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.apache.shiro.web.servlet.SimpleCookie;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Value("${spring.redis.password}")
private String redisPassword;
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 沒有登陸的使用者隻能通路登陸頁面,前後端分離中登入界面跳轉應由前端路由控制,背景僅傳回json資料
shiroFilterFactoryBean.setLoginUrl("/common/unauth");
// 登入成功後要跳轉的連結
//shiroFilterFactoryBean.setSuccessUrl("/auth/index");
// 未授權界面;
shiroFilterFactoryBean.setUnauthorizedUrl("common/unauth");
//自定義攔截器
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
//限制同一帳号同時線上的個數。
filtersMap.put("kickout", kickoutSessionControlFilter());
shiroFilterFactoryBean.setFilters(filtersMap);
// 權限控制map.
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// 公共請求
filterChainDefinitionMap.put("/common/**", "anon");
// 靜态資源
filterChainDefinitionMap.put("/static/**", "anon");
// 登入方法
filterChainDefinitionMap.put("/admin/login*", "anon"); // 表示可以匿名通路
//此處需要添加一個kickout,上面添加的自定義攔截器才能生效
filterChainDefinitionMap.put("/admin/**", "authc,kickout");// 表示需要認證才可以通路
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 設定realm.
securityManager.setRealm(myShiroRealm());
// 自定義緩存實作 使用redis
securityManager.setCacheManager(cacheManager());
// 自定義session管理 使用redis
securityManager.setSessionManager(sessionManager());
return securityManager;
}
/**
* 身份認證realm; (這個需要自己寫,賬号密碼校驗;權限等)
*
* @return
*/
@Bean
public ShiroRealm myShiroRealm() {
ShiroRealm myShiroRealm = new ShiroRealm();
myShiroRealm.setCredentialsMatcher(credentialsMatcher());
return myShiroRealm;
}
@Bean
public CredentialsMatcher credentialsMatcher() {
return new CredentialsMatcher();
}
/**
* cacheManager 緩存 redis實作
* 使用的是shiro-redis開源插件
*
* @return
*/
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
redisCacheManager.setKeyPrefix("SPRINGBOOT_CACHE:"); //設定字首
return redisCacheManager;
}
/**
* RedisSessionDAO shiro sessionDao層的實作 通過redis
* 使用的是shiro-redis開源插件
*/
@Bean
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setKeyPrefix("SPRINGBOOT_SESSION:");
return redisSessionDAO;
}
/**
* Session Manager
* 使用的是shiro-redis開源插件
*/
@Bean
public SessionManager sessionManager() {
SimpleCookie simpleCookie = new SimpleCookie("Token");
simpleCookie.setPath("/");
simpleCookie.setHttpOnly(false);
SessionManager sessionManager = new SessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
sessionManager.setSessionIdCookieEnabled(false);
sessionManager.setSessionIdUrlRewritingEnabled(false);
sessionManager.setDeleteInvalidSessions(true);
sessionManager.setSessionIdCookie(simpleCookie);
return sessionManager;
}
/**
* 配置shiro redisManager
* 使用的是shiro-redis開源插件
*
* @return
*/
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(redisHost);
redisManager.setPort(redisPort);
redisManager.setTimeout(1800); //設定過期時間
redisManager.setPassword(redisPassword);
return redisManager;
}
/**
* 限制同一賬号登入同時登入人數控制
*
* @return
*/
// 這裡的@Bean不要啟用了,自定義的filter不要交由Spring建立,否則會出現被标記為anon的url仍然會執行該自定義過濾器。 updated 2020.06.05
//@Bean
public SessionControlFilter kickoutSessionControlFilter() {
SessionControlFilter kickoutSessionControlFilter = new SessionControlFilter();
kickoutSessionControlFilter.setCache(cacheManager());
kickoutSessionControlFilter.setSessionManager(sessionManager());
kickoutSessionControlFilter.setKickoutAfter(false);
kickoutSessionControlFilter.setMaxSession(1);
kickoutSessionControlFilter.setKickoutUrl("/common/kickout");
return kickoutSessionControlFilter;
}
/***
* 授權所用配置
*
* @return
*/
@Bean
public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
/***
* 使授權注解起作用不如不想配置可以在pom檔案中加入
* <dependency>
*<groupId>org.springframework.boot</groupId>
*<artifactId>spring-boot-starter-aop</artifactId>
*</dependency>
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* Shiro生命周期處理器
* 此方法需要用static作為修飾詞,否則無法通過@Value()注解的方式擷取配置檔案的值
*
*/
@Bean
public static LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
}
自定義Realm
package com.alex.springboot.system.shiro;
import com.alex.springboot.model.Menu;
import com.alex.springboot.model.Role;
import com.alex.springboot.model.User;
import com.alex.springboot.service.MenuService;
import com.alex.springboot.service.RoleService;
import com.alex.springboot.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
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.List;
@Slf4j
public class ShiroRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private MenuService menuService;
/**
* 認證資訊.(身份驗證) : Authentication 是用來驗證使用者身份
*
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) throws AuthenticationException {
log.info("---------------- 執行 Shiro 憑證認證 ----------------------");
UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
String name = token.getUsername();
// 從資料庫擷取對應使用者名密碼的使用者
User user = userService.getUserByName(name);
if (user != null) {
// 使用者為禁用狀态
if (!user.getLoginFlag().equals("1")) {
throw new DisabledAccountException();
}
log.info("---------------- Shiro 憑證認證成功 ----------------------");
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
user, //使用者
user.getPassword(), //密碼
getName() //realm name
);
return authenticationInfo;
}
throw new UnknownAccountException();
}
/**
* 授權
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("---------------- 執行 Shiro 權限擷取 ---------------------");
Object principal = principals.getPrimaryPrincipal();
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
if (principal instanceof User) {
User userLogin = (User) principal;
if(userLogin != null){
List<Role> roleList = roleService.findByUserid(userLogin.getId());
if(CollectionUtils.isNotEmpty(roleList)){
for(Role role : roleList){
info.addRole(role.getEnname());
List<Menu> menuList = menuService.getAllMenuByRoleId(role.getId());
if(CollectionUtils.isNotEmpty(menuList)){
for (Menu menu : menuList){
if(StringUtils.isNoneBlank(menu.getPermission())){
info.addStringPermission(menu.getPermission());
}
}
}
}
}
}
}
log.info("---------------- 擷取到以下權限 ----------------");
log.info(info.getStringPermissions().toString());
log.info("---------------- Shiro 權限擷取成功 ----------------------");
return info;
}
}
自定義密碼校驗器
package com.alex.springboot.system.shiro;
import com.alex.springboot.utils.MD5Util;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.SimpleCredentialsMatcher;
public class CredentialsMatcher extends SimpleCredentialsMatcher {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
UsernamePasswordToken utoken = (UsernamePasswordToken) token;
// 獲得使用者輸入的密碼:(可以采用加鹽(salt)的方式去檢驗)
String inPassword = new String(utoken.getPassword());
// 獲得資料庫中的密碼
String dbPassword = (String) info.getCredentials();
// 進行密碼的比對
return this.equals(MD5Util.encrypt(inPassword), dbPassword);
}
}
自定義session容器,用于實作前後端分離,前端請求接口時将Token放在請求Header中,即可擷取到使用者的session資訊(建議前端是将Token放在Header中,而不是放到body請求參數中,這樣可以統一做封裝處理,下面的代碼中是擷取Header中或者body中Token,建議直接擷取Header中Token即可)
package com.alex.springboot.system.shiro;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.SessionKey;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.session.mgt.WebSessionKey;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;
public class SessionManager extends DefaultWebSessionManager {
private static final String AUTHORIZATION = "Token";
private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
public SessionManager() {
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
//擷取請求頭,或者請求參數中的Token
String id = StringUtils.isEmpty(WebUtils.toHttp(request).getHeader(AUTHORIZATION))
? request.getParameter(AUTHORIZATION) : WebUtils.toHttp(request).getHeader(AUTHORIZATION);
// 如果請求頭中有 Token 則其值為sessionId
if (StringUtils.isNotEmpty(id)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return id;
} else {
// 否則按預設規則從cookie取sessionId
return super.getSessionId(request, response);
}
}
/**
* 擷取session 優化單次請求需要多次通路redis的問題
*
* @param sessionKey
* @return
* @throws UnknownSessionException
*/
@Override
protected Session retrieveSession(SessionKey sessionKey) throws UnknownSessionException {
Serializable sessionId = getSessionId(sessionKey);
ServletRequest request = null;
if (sessionKey instanceof WebSessionKey) {
request = ((WebSessionKey) sessionKey).getServletRequest();
}
if (request != null && null != sessionId) {
Object sessionObj = request.getAttribute(sessionId.toString());
if (sessionObj != null) {
return (Session) sessionObj;
}
}
Session session = super.retrieveSession(sessionKey);
if (request != null && null != sessionId) {
request.setAttribute(sessionId.toString(), session);
}
return session;
}
}
自定義攔截器,用于限制使用者登入人數
package com.alex.springboot.system.shiro;
import com.alex.springboot.model.User;
import com.alibaba.fastjson.JSON;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
public class SessionControlFilter extends AccessControlFilter {
private String kickoutUrl; //踢出後到的位址
private boolean kickoutAfter = false; //踢出之前登入的/之後登入的使用者 預設踢出之前登入的使用者
private int maxSession = 1; //同一個帳号最大會話數 預設1
private SessionManager sessionManager;
private Cache<String, Deque<Serializable>> cache;
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request, response);
if(!subject.isAuthenticated() && !subject.isRemembered()) {
//如果沒有登入,直接進行之後的流程
return true;
}
Session session = subject.getSession();
User user = (User) subject.getPrincipal();
String username = user.getLoginName();
Serializable sessionId = session.getId();
//讀取緩存 沒有就存入
Deque<Serializable> deque = cache.get(username);
//如果此使用者沒有session隊列,也就是還沒有登入過,緩存中沒有
//就new一個空隊列,不然deque對象為空,會報空指針
if(deque == null){
deque = new LinkedList<Serializable>();
}
//如果隊列裡沒有此sessionId,且使用者沒有被踢出;放入隊列
if(!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
//将sessionId存入隊列
deque.push(sessionId);
//将使用者的sessionId隊列緩存
cache.put(username, deque);
}
//如果隊列裡的sessionId數超出最大會話數,開始踢人
while(deque.size() > maxSession) {
Serializable kickoutSessionId = null;
if(kickoutAfter) { //如果踢出後者
kickoutSessionId = deque.removeFirst();
//踢出後再更新下緩存隊列
cache.put(username, deque);
} else { //否則踢出前者
kickoutSessionId = deque.removeLast();
//踢出後再更新下緩存隊列
cache.put(username, deque);
}
try {
//擷取被踢出的sessionId的session對象
Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
if(kickoutSession != null) {
//設定會話的kickout屬性表示踢出了
kickoutSession.setAttribute("kickout", true);
}
} catch (Exception e) {//ignore exception
}
}
//如果被踢出了,直接退出,重定向到踢出後的位址
if (session.getAttribute("kickout") != null) {
//會話被踢出了
try {
//登出
subject.logout();
} catch (Exception e) { //ignore
}
saveRequest(request);
Map<String, String> resultMap = new HashMap<String, String>();
//判斷是不是Ajax請求
if ("XMLHttpRequest".equalsIgnoreCase(((HttpServletRequest) request).getHeader("X-Requested-With"))) {
resultMap.put("user_status", "300");
resultMap.put("message", "您已經在其他地方登入,請重新登入!");
//輸出json串
out(response, resultMap);
}else{
//重定向
WebUtils.issueRedirect(request, response, kickoutUrl);
}
return false;
}
return true;
}
private void out(ServletResponse hresponse, Map<String, String> resultMap)
throws IOException {
try {
hresponse.setCharacterEncoding("UTF-8");
PrintWriter out = hresponse.getWriter();
out.println(JSON.toJSONString(resultMap));
out.flush();
out.close();
} catch (Exception e) {
System.err.println("KickoutSessionFilter.class 輸出JSON異常,可以忽略。");
}
}
public String getKickoutUrl() {
return kickoutUrl;
}
public void setKickoutUrl(String kickoutUrl) {
this.kickoutUrl = kickoutUrl;
}
public boolean isKickoutAfter() {
return kickoutAfter;
}
public void setKickoutAfter(boolean kickoutAfter) {
this.kickoutAfter = kickoutAfter;
}
public int getMaxSession() {
return maxSession;
}
public void setMaxSession(int maxSession) {
this.maxSession = maxSession;
}
public SessionManager getSessionManager() {
return sessionManager;
}
public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
public Cache<String, Deque<Serializable>> getCache() {
return cache;
}
public void setCache(CacheManager cacheManager) {
this.cache = cacheManager.getCache("shiro_redis_cache");
}
}
全局異常攔截
package com.alex.springboot.system.handler;
import com.alex.springboot.system.enums.ResultStatusCode;
import com.alex.springboot.system.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import javax.validation.ConstraintViolationException;
@Slf4j
@ControllerAdvice
@ResponseBody
public class ExceptionAdvice {
/**
* 400 - Bad Request
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class, BindException.class,
ServletRequestBindingException.class, MethodArgumentNotValidException.class, ConstraintViolationException.class})
public Result handleHttpMessageNotReadableException(Exception e) {
log.error("參數解析失敗", e);
if (e instanceof BindException){
return new Result(ResultStatusCode.BAD_REQUEST.getCode(), ((BindException)e).getAllErrors().get(0).getDefaultMessage());
}
return new Result(ResultStatusCode.BAD_REQUEST.getCode(), e.getMessage());
}
/**
* 405 - Method Not Allowed
*/
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public Result handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.error("不支援目前請求方法", e);
return new Result(ResultStatusCode.METHOD_NOT_ALLOWED, null);
}
/**
* shiro權限異常處理
* @return
*/
@ExceptionHandler(UnauthorizedException.class)
public Result unauthorizedException(UnauthorizedException e){
log.error(e.getMessage(), e);
return new Result(ResultStatusCode.UNAUTHO_ERROR);
}
/**
* 500
* @param e
* @return
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public Result handleException(Exception e) {
e.printStackTrace();
log.error("服務運作異常", e);
return new Result(ResultStatusCode.SYSTEM_ERR, null);
}
}
未授權和被踢出後跳轉方法
package com.alex.springboot.controller;
import com.alex.springboot.system.enums.ResultStatusCode;
import com.alex.springboot.system.vo.Result;
import org.apache.shiro.SecurityUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/common")
@RestController
public class CommonController {
/**
* 未授權跳轉方法
* @return
*/
@RequestMapping("/unauth")
public Result unauth(){
SecurityUtils.getSubject().logout();
return new Result(ResultStatusCode.UNAUTHO_ERROR);
}
/**
* 被踢出後跳轉方法
* @return
*/
@RequestMapping("/kickout")
public Result kickout(){
return new Result(ResultStatusCode.INVALID_TOKEN);
}
}
登入和登出
package com.alex.springboot.controller;
import com.alex.springboot.system.enums.ResultStatusCode;
import com.alex.springboot.system.vo.Result;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin")
public class LoginController {
@RequestMapping("/login")
public Result login(String loginName, String pwd){
try {
UsernamePasswordToken token = new UsernamePasswordToken(loginName, pwd);
//登入不在該處處理,交由shiro處理
Subject subject = SecurityUtils.getSubject();
subject.login(token);
if (subject.isAuthenticated()) {
JSON json = new JSONObject();
((JSONObject) json).put("token", subject.getSession().getId());
return new Result(ResultStatusCode.OK, json);
}else{
return new Result(ResultStatusCode.SHIRO_ERROR);
}
}catch (IncorrectCredentialsException | UnknownAccountException e){
return new Result(ResultStatusCode.NOT_EXIST_USER_OR_ERROR_PWD);
}catch (LockedAccountException e){
return new Result(ResultStatusCode.USER_FROZEN);
}catch (Exception e){
return new Result(ResultStatusCode.SYSTEM_ERR);
}
}
/**
* 登出
* @return
*/
@RequestMapping("/logout")
public Result logout(){
SecurityUtils.getSubject().logout();
return new Result(ResultStatusCode.OK);
}
}
測試接口方法
package com.alex.springboot.controller;
import com.alex.springboot.service.UserService;
import com.alex.springboot.system.vo.Grid;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin/user")
public class UserController {
@Autowired
private UserService userService;
@RequiresPermissions("sys:user:view")
@RequestMapping("findList")
public Grid findList(){
return userService.findList();
}
}
測試結果
未攜帶Token時通路findList接口
登入,等之後擷取到token
攜帶Token再次請求findList接口
再次調用登入接口,擷取到新的Token後,使用舊Token通路findList接口
關于無權限通路時,redis會産生session的問題請參考:spring boot 2.x + shiro + redis前後端分離,無權限通路時session會存入redis的問題解決
Demo位址:源碼位址
相關參考連結:springboot+shiro+redis項目整合