示例代碼鏈接:https://github.com/Winter730/springmvc-shiro-demo
Shiro組件
- Web過濾器:shiroFilterFactoryBean
參數如下:
- securityManager
- loginUrl 登錄攔截跳轉的Url
- successUrl 登錄成功跳轉的Url
- filters authc過濾器
- filterChainDefinitions 指定過濾規則,其中:
anno:任何人都可以訪問;authc:必須是登錄之后才能進行訪問,不包含remember me;user:登錄用戶才可以訪問,包含remember me;perms:指定過濾規則,這個一般是擴展使用,不會使用原生的。
- 安全管理器:securityManager 負責對所有的subject進行安全管理。
通過SecurityManager可以完成subject的認證、授權等,實質上SecurityManager是通過Authenticator進行認證,通過Authorizer進行授權,通過SessionManager進行會話管理等。
參數如下:
- realms
- sessionManager
- rememberMeManager
-
領域:realms
相當於datasource數據源,securityManager進行安全認證需要通過Realm獲取用戶權限數據,比如:如果用戶身份數據在數據庫那么realm就需要從數據庫獲取用戶身份信息。
注意:不要把realm理解成只是從數據源取數據,在realm中還有認證授權校驗的相關的代碼。 -
自動登錄:rememberMeManager
參數如下:
- cipherKey cookie加密密鑰
- rememberMeCookie
- 自動登錄緩存cookie:rememberMeCookie
參數如下:
- httpOnly:是否暴露給客戶端
- maxAge:Cookie生效時間,-1表示關閉瀏覽器時過期Cookie
- 會話管理:sessionManager
shiro框架定義了一套會話管理,它不依賴web容器的session,所以shiro可以使用在非web應用上,也可以將分布式應用的會話集中在一點管理,此特性可使它實現單點登錄。
參數如下:
- globalSessionTimeout 全局session超時時間
- sessionDAO
- sessionIdCookieEnabled 是否將sessionId保存到Cookie中
- sessionIdCookie
sessionValidationSchedulerEnabled 是否開啟會話驗證器 - sessionListeners 會話監聽器
- sessionFactory session工廠
- cacheManager
-
SessionDAO 會話dao
是對session會話操作的一套接口,比如要將session存儲到數據庫,可以通過jdbc將會話存儲到數據庫。 -
會話Cookie:sessionIdCookie
參數如下:
- httpOnly:是否暴露給客戶端
- maxAge:Cookie生效時間,-1表示關閉瀏覽器時過期Cookie
- CacheManager:緩存管理
將用戶權限數據存儲在緩存,這樣可以提高性能
補充:
- rememberMeManager 主要針對單節點登錄
- sessionManager 針對分布式應用會話的集中式管理
以下展示shiro在SpringMVC中的使用,示例代碼中分為client1、client2、common、single、sso5個模塊,其中common屬於公共模塊,關於Shiro的封裝都在common模塊實現,single為登錄攔截及接口權限的實例代碼。client1、client2、sso為單點登錄的示例代碼。
shiro實現登錄攔截
shiro實現登錄攔截的執行流程如下:
- 用戶訪問系統的受保護資源,請求被shiroFilter攔截,shiroFilter攔截請求后,通過authc過濾器isAccessAllowed()方法進行訪問驗證。
- 若訪問驗證不通過,將執行onAccessDenied()方法,轉到登錄頁面,進行用戶登錄
- 用戶登錄驗證通過調用realms中的doGetAuthenticationInfo()方法實現,驗證用戶的用戶名、密碼是否正確;用戶是否被鎖定。
- 用戶登錄成功后,回跳登錄前地址,此時請求仍然被shiroFilter攔截,但用戶驗證已通過,故登錄成功,可順利訪問受訪問資源。
根據執行流程所述詳細實現代碼實現如下:
- 繼承AuthenticationFilter,重寫authc過濾器的isAccessAllowed、onAccessDenied方法
public class SingleAuthenticationFilter extends AuthenticationFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
Subject subject = getSubject(request, response);
return subject.isAuthenticated();
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
StringBuffer ssoServerUrl = new StringBuffer(PropertiesFileUtil.getInstance("client").get("pmi.sso.server.url"));
ssoServerUrl.append("/sso/index").append("?").append("appid").append("=").append(PropertiesFileUtil.getInstance("client").get("app.name"));
//回跳地址
HttpServletRequest httpServletRequest = WebUtils.toHttp(servletRequest);
StringBuffer backUrl = httpServletRequest.getRequestURL();
String queryString = httpServletRequest.getQueryString();
if(StringUtils.isNotBlank(queryString)) {
backUrl.append("?").append(queryString);
}
ssoServerUrl.append("&").append("backUrl").append("=").append(URLEncoder.encode(backUrl.toString(), "utf-8"));
WebUtils.toHttp(servletResponse).sendRedirect(ssoServerUrl.toString());
return false;
}
}
- 登錄被拒絕時請求轉發到登錄接口,登錄Controller類如下:
/**
* 單機登錄,非會話登錄
* Created by winter on 2021/4/24
*/
@Controller
@RequestMapping("/sso")
public class SingleController extends BaseController {
private static final Logger logger = LoggerFactory.getLogger(SingleController.class);
@RequestMapping(value = "/index", method = RequestMethod.GET)
public String index(HttpServletRequest request) throws Exception{
String appId = request.getParameter("appid");
String backUrl = request.getParameter("backUrl");
if(StringUtils.isBlank(appId)) {
throw new RuntimeException("無效訪問");
}
return "redirect:/sso/login?backUrl=" + URLEncoder.encode(backUrl, "UTF-8");
}
@RequestMapping(value = "/login", method = RequestMethod.GET)
public String login(HttpServletRequest request) {
return "/sso/login";
}
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
public Object login(HttpServletRequest request, HttpServletResponse response, ModelMap modelMap) {
Map<String, String[]> map = request.getParameterMap();
String userName = request.getParameter("username");
String password = request.getParameter("password");
String rememberMe = request.getParameter("rememberMe");
if (StringUtils.isBlank(userName)) {
return new WebResult(WebResultConstant.EMPTY_USERNAME, "帳號不能為空!");
}
if(StringUtils.isBlank(password)) {
return new WebResult(WebResultConstant.EMPTY_PASSWORD, "密碼不能為空!");
}
Subject subject = SecurityUtils.getSubject();
// 使用Shiro認證登錄
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userName, password);
try {
if(BooleanUtils.toBoolean(rememberMe)) {
usernamePasswordToken.setRememberMe(true);
} else {
usernamePasswordToken.setRememberMe(false);
}
subject.login(usernamePasswordToken);
} catch (UnknownAccountException e) {
return new WebResult(WebResultConstant.INVALID_USERNAME, "帳號不存在!");
} catch (IncorrectCredentialsException e) {
return new WebResult(WebResultConstant.INVALID_PASSWORD, "密碼錯誤!");
} catch (LockedAccountException e) {
return new WebResult(WebResultConstant.INVALID_ACCOUNT, "帳號已鎖定!");
}
//回跳登錄前地址
String backUrl = request.getParameter("backUrl");
if(StringUtils.isBlank(backUrl)) {
backUrl = request.getContextPath();
WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl);
return webResult;
} else {
WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl);
return webResult;
}
}
@RequestMapping(value = "/logout", method = RequestMethod.GET)
public String logout(HttpServletRequest request) {
//shiro退出登錄
SecurityUtils.getSubject().logout();
//跳回原地址
String redirectUrl = request.getHeader("Referer");
if(null == redirectUrl) {
redirectUrl = "/";
}
return "redirect:" + redirectUrl;
}
}
- realm實現,繼承AuthorizingRealm 重寫認證授權校驗
/**
* 領域:realms
* 相當於datasource數據源,securityManager進行安全認證需要通過Realm獲取用戶權限數據,比如:如果用戶身份數據在數據庫那么realm就需要從數據庫獲取用戶身份信息。
* 注意:不要把realm理解成只是從數據源取數據,在realm中還有認證授權校驗的相關的代碼。
*
* 此處的角色、權限理論上應該從數據庫中獲取,作為demo采用默認枚舉類
* Created by winter on 2021/4/26
*/
public class MyRealm extends AuthorizingRealm {
/**
* 授權: 驗證權限時調用
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String userName = (String) principalCollection.getPrimaryPrincipal();
User user = User.getUser(userName);
//當前用戶所有角色
Role role = Role.getRole(userName);
Set<String> roles = new HashSet<>();
roles.add(role.getName());
//當前用戶所有權限
List<Permission> permissionList = Permission.getPermission(userName);
Set<String> permissions = new HashSet<>();
for(Permission permission : permissionList){
if(StringUtils.isNotBlank(permission.getPermissionValue())) {
permissions.add(permission.getPermissionValue());
}
}
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
simpleAuthorizationInfo.setStringPermissions(permissions);
simpleAuthorizationInfo.setRoles(roles);
return simpleAuthorizationInfo;
}
/**
* 認證:登錄時調用
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String userName = (String) authenticationToken.getPrincipal();
String password = new String((char[]) authenticationToken.getCredentials());
// 查詢用戶信息
User user = User.getUser(userName);
if(null == user) {
throw new UnknownAccountException();
}
if(!user.getPassword().equals(MD5Util.md5(password + user.getSalt()))){
throw new IncorrectCredentialsException();
}
if(user.getLocked() == 1) {
throw new LockedAccountException();
}
return new SimpleAuthenticationInfo(userName, password, getName());
}
}
- 進行SpringMVC與shiro的整合(后續會提供SpringBoot的實現,從原理上是同一回事,挖坑待填)配置web.xml文件
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- 默認的Spring配置文件是在WEB-INF下的applicationContext.xml -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext*.xml</param-value>
</context-param>
<!-- SpringMVC的核心控制器 -->
<servlet>
<servlet-name>SpringMVC</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:SpringMVC-servlet.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>SpringMVC</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- 強制進行轉碼 -->
<filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>
<!-- shiroFilter : DelegatingFilterProxy作用是自動到spring容器查找名字為shiroFilter(filter-name)的bean並把所有Filter的操作委托給它。然后將shiroFilter配置到spring容器即可 -->
<filter>
<filter-name>shiroFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
<init-param>
<param-name>targetFilterLifecycle</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>shiroFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>
- 配置文件中,配置shiroWeb過濾器及securityManager(可使用@Configuration進行Bean注入,會比每次都需要寫xml文件簡單,挖坑待填)
<!-- Shiro的Web過濾器 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<property name="securityManager" ref="securityManager"/>
<property name="loginUrl" value="${pmi.sso.server.url}"/>
<property name="successUrl" value="${pmi.successUrl}"/>
<property name="filters">
<util:map>
<entry key="authc" value-ref="pmiAuthenticationFilter"/>
</util:map>
</property>
<property name="filterChainDefinitions">
<value>
/manage/** = authc
/manage/index = user
/druid/** = user
/resources/** = anon
/** = anon
</value>
</property>
</bean>
<!-- 安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="realms">
<list><ref bean="pmiRealm"/></list>
</property>
<!--<property name="sessionManager" ref="sessionManager"/>-->
<property name="rememberMeManager" ref="rememberMeManager"/>
</bean>
<!-- rememberMe管理器 -->
<bean id="rememberMeManager" class="org.apache.shiro.web.mgt.CookieRememberMeManager">
<!-- rememberMe cookie加密的密鑰 建議每個項目都不一樣 默認AES算法 密鑰長度(128 256 512 位)-->
<property name="cipherKey" value="#{T(org.apache.shiro.codec.Base64).decode('4AvVhmFLUs0KTA3Kprsdag==')}"/>
<property name="cookie" ref="rememberMeCookie"/>
</bean>
<!-- realm實現,繼承自AuthorizingRealm -->
<bean id="pmiRealm" class="com.winter.framework.shiro.realm.PMIRealm"/>
Shiro+SpringAOP實現接口權限管理
- Shiro配置文件中注入Bean
<!-- 設置SecurityUtils,相當於調用SecurityUtils.setSecurityManager(securityManager) -->
<bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
<property name="staticMethod" value="org.apache.shiro.SecurityUtils.setSecurityManager"/>
<property name="arguments" ref="securityManager"/>
</bean>
<!-- 開啟Shiro Spring AOP權限注解@RequiresPermissions的支持 -->
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" depends-on="lifecycleBeanPostProcessor"/>
<!-- aop通知器 -->
<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
<property name="securityManager" ref="securityManager"/>
</bean>
<!-- Shiro生命周期處理器 -->
<bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/>
- 在需要配置權限的接口上加入權限注解@RequiresPermissions("xxx"),例
@Controller
@RequestMapping("/manage")
public class ManageController extends BaseController {
@RequestMapping(value = "/index", method = RequestMethod.GET)
public String index(ModelMap modelMap) {
return "/manage/index";
}
@RequiresPermissions("sso:permission2:read")
@RequestMapping(value = "/permission", method = RequestMethod.GET)
public String permission(ModelMap modelMap) {
return "/manage/permission";
}
}
- Spring容器(SpringMVC-servlet)中注入AOP
<aop:aspectj-autoproxy/>
此時已經完成了使用SpringAOP+Shiro實現接口權限管理,但是存在一個優化點在於,當每次請求需要權限的接口時,都會調用MyRealm中的doGetAuthorizationInfo()方法,去數據庫查詢用戶所擁有的權限,那么,能否將該權限進行緩存,下次查詢時,直接從緩存中獲取結果而不需要每次都去查詢數據庫呢?
在single模塊中,我們采用ehcache進行數據的緩存。
- ehcache.xml配置文件配置如下:
<?xml version="1.0" encoding="UTF-8" ?>
<ehcache>
<diskStore path="../temp/single/ehcache" />
<defaultCache
maxElementsInMemory="10000"
maxElementsOnDisk="0"
eternal="true"
overflowToDisk="true"
diskPersistent="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
diskSpoolBufferSizeMB="50"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LFU"
/>
</ehcache>
注:如果是使用的tomcat啟動的SpringMVC項目,ehcache緩存的存儲路徑是在tomcat目錄下,即:apache-tomcat-8.5.56\temp\client1\ehcache 而非項目目錄.
- Spring容器中注入ehcache
<bean id="nativeEhCacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean">
<property name="configLocation" value="classpath:ehcache.xml"/>
<property name="shared" value="true"/>
</bean>
- Shiro中使用ehcache
<!--緩存管理器,使用ehCache實現 -->
<bean id="shiroEhCacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
<property name="cacheManager" ref="nativeEhCacheManager"/>
</bean>
<!-- realm實現,繼承自AuthorizingRealm -->
<bean id="pmiRealm" class="com.winter.framework.shiro.realm.MyRealm">
<property name="cacheManager" ref="shiroEhCacheManager"/>
</bean>
通過以上配置,再次請求需要權限的接口時會直接從ehcache中取緩存,不必再經過doGetAuthorizationInfo()方法。
Shiro會話管理實現單點登錄(使用redis緩存session)
什么是單點登錄
單點登錄全程是Single Sign On(SSO),是指在多系統應用群眾登錄一個系統,便可在其他所有系統中得到授權而無需再次登錄,包括單點登錄和單點注銷兩部分。(單點注銷暫不做過多處理,待填坑)
登錄
SSO需要一個獨立的認證中心,只有認證中心能接受用戶的用戶名密碼等安全信息,其他系統不提供登錄入口,只接受認證中心的間接授權。
間接授權通過令牌實現,SSO認證中心驗證用戶的用戶名密碼沒問題,創建授權令牌。
在接下來的跳轉過程中,授權令牌作為參數發送給各個子系統,子系統拿到令牌,即得到了授權,可以借此創建局部會話,局部會話登錄方式與單系統的登錄方式相同。
其過程如圖所示
上圖描述及相關代碼描述如下:
- 用戶訪問系統1的受保護資源(例/),系統1發現用戶未登錄,跳轉至SSO認證中心,並將自己的地址作為參數。
攔截用戶請求通過PMIAuthenticationFilter.isAccessAllowed()方法實現
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
Subject subject = getSubject(request, response);
Session session = subject.getSession();
//判斷請求類型
String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type");
session.setAttribute(PMIConstant.PMI_TYPE, PMIType);
if("client".equals(PMIType)) {
return validateClient(request, response);
}
if("server".equals(PMIType)) {
return subject.isAuthenticated();
}
return false;
}
判斷用戶是否登錄通過PMIAuthenticationFilter.validateClient()實現,此時各參數都不存在,故判斷為未登錄,由PMIAuthenticationFilter.onAccessDenied()方法跳轉至SSO認證中心。
PMIAuthenticationFilter.onAccessDenied()方法實現如下:
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
StringBuffer ssoServerUrl = new StringBuffer(PropertiesFileUtil.getInstance("client").get("pmi.sso.server.url"));
//server需要登錄
String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type");
if("server".equals(PMIType)) {
WebUtils.toHttp(servletResponse).sendRedirect(ssoServerUrl.append("/sso/login").toString());
return false;
}
ssoServerUrl.append("/sso/index").append("?").append("appid").append("=").append(PropertiesFileUtil.getInstance("client").get("app.name"));
//回跳地址
HttpServletRequest httpServletRequest = WebUtils.toHttp(servletRequest);
StringBuffer backUrl = httpServletRequest.getRequestURL();
String queryString = httpServletRequest.getQueryString();
if(StringUtils.isNotBlank(queryString)) {
backUrl.append("?").append(queryString);
}
ssoServerUrl.append("&").append("backUrl").append("=").append(URLEncoder.encode(backUrl.toString(), "utf-8"));
WebUtils.toHttp(servletResponse).sendRedirect(ssoServerUrl.toString());
return false;
}
- SSO認證中心發現用戶未登錄,將用戶引導至登錄頁面。
通過onAccessDenied()方法首先跳轉至sso系統下SSOController.index()方法,查詢數據庫驗證系統是否已經注冊,確保系統可用性,確保系統可用后再跳轉至SSO登錄界面。(此處省略了從數據庫驗證系統是否已經注冊的過程)
登錄通過sso系統下SSOController.login()方法實現,此時各參數為空,直接跳轉至login.jsp頁面進行登錄
@RequestMapping(value = "/index", method = RequestMethod.GET)
public String index(HttpServletRequest request) throws Exception{
String appId = request.getParameter("appid");
String backUrl = request.getParameter("backUrl");
if(StringUtils.isBlank(appId)) {
throw new RuntimeException("無效訪問");
}
return "redirect:/sso/login?backUrl=" + URLEncoder.encode(backUrl, "UTF-8");
}
@RequestMapping(value = "/login", method = RequestMethod.GET)
public String login(HttpServletRequest request) {
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();
String serverSessionId = session.getId().toString();
//判斷是否已登錄,如果已登錄,則回跳
String code = RedisUtil.get(PMI_SERVER_CODE + "-" + serverSessionId);
String userName = (String) subject.getPrincipal();
//code校驗值
if(StringUtils.isNotBlank(code)) {
//回跳
String backUrl = request.getParameter("backUrl");
if (StringUtils.isBlank(backUrl)) {
backUrl = "/";
} else {
if (backUrl.contains("?")) {
backUrl += "&pmi_code=" + code + "&pmi_username=" + userName;
} else {
backUrl += "?pmi_code=" + code + "&pmi_username=" + userName;
}
}
logger.info("認證中心賬號通過,帶code回跳: {}", backUrl);
return "redirect:" + backUrl;
}
return "/sso/login";
}
-
用戶輸入用戶名密碼提交登錄申請
-
SSO認證中心校驗用戶信息,創建用戶與SSO認證中心之間的會話,稱為全局會話,同時創建授權令牌,授權令牌取全局會話的sessionId。
登錄校驗通過sso系統下SSOController.login()方法實現
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
/**
* 此處有以下可能:
* 1.用戶首次登錄
* 2.用戶非首次登錄,來自同一台機器
* 3.用戶非首次登錄,來自不同機器
*/
public Object login(HttpServletRequest request, HttpServletResponse response, ModelMap modelMap) {
String userName = request.getParameter("username");
String password = request.getParameter("password");
String rememberMe = request.getParameter("rememberMe");
if (StringUtils.isBlank(userName)) {
return new WebResult(WebResultConstant.EMPTY_USERNAME, "帳號不能為空!");
}
if(StringUtils.isBlank(password)) {
return new WebResult(WebResultConstant.EMPTY_PASSWORD, "密碼不能為空!");
}
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();
String sessionId = session.getId().toString();
//判斷是否已登錄,如果已登錄,則回跳,防止重復登錄,同時需判斷,是否為同一IP,如果不為同一IP,需刪除原會話,重新登錄
String oldSessionId = RedisUtil.get(PMI_SHIRO_USER + "-" + userName);
if(!StringUtils.isBlank(oldSessionId) && ! sessionId.equals(oldSessionId)){
pmiSessionDao.deleteOldSession(oldSessionId);
}
if(StringUtils.isBlank(oldSessionId) || (StringUtils.isNotBlank(oldSessionId) && !sessionId.equals(oldSessionId))) {
// 使用Shiro認證登錄
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userName, password);
try {
if(BooleanUtils.toBoolean(rememberMe)) {
usernamePasswordToken.setRememberMe(true);
} else {
usernamePasswordToken.setRememberMe(false);
}
subject.login(usernamePasswordToken);
} catch (UnknownAccountException e) {
return new WebResult(WebResultConstant.INVALID_USERNAME, "帳號不存在!");
} catch (IncorrectCredentialsException e) {
return new WebResult(WebResultConstant.INVALID_PASSWORD, "密碼錯誤!");
} catch (LockedAccountException e) {
return new WebResult(WebResultConstant.INVALID_ACCOUNT, "帳號已鎖定!");
}
//更新session狀態
//全局會話sessionID列表,供會話管理
RedisUtil.set(PMI_SHIRO_USER + "-" + userName,sessionId);
//code校驗值,目前以server的sessionId作為校驗值
RedisUtil.set(PMI_SERVER_CODE + "-" + sessionId, sessionId, (int)subject.getSession().getTimeout() / 1000);
//更新會話狀態
pmiSessionDao.updateStatus(sessionId, PMISession.OnlineStatus.on_line);
}
//回跳登錄前地址
String backUrl = request.getParameter("backUrl");
if(StringUtils.isNotBlank(sessionId)) {
if (backUrl.contains("?")) {
backUrl += "&pmi_code=" + sessionId + "&pmi_username=" + userName;
} else {
backUrl += "?pmi_code=" + sessionId + "&pmi_username=" + userName;
}
}
if(StringUtils.isBlank(backUrl)) {
backUrl = request.getContextPath();
WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl);
return webResult;
} else {
WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl);
return webResult;
}
}
-
SSO認證中心帶着令牌(pmi_code)及用戶名(pmi_username)跳轉回最初的請求地址(backUrl)
-
系統1拿到令牌,去SSO認證中心校驗令牌是否有效
此時又跳轉回最初的請求地址,依舊被Shiro攔截,回到第一步的PMIAuthenticationFilter.isAccessAllowed()方法,重新通過validateClient()方法進行驗證,此時已拿到code,將會創建局部會話,返回true。
其中,validateClient()方法如下:
/**
* 認證中心登錄成功帶回code
* 只有從會話會經過這個方法
*/
private boolean validateClient(ServletRequest request, ServletResponse response) {
Subject subject = getSubject(request, response);
Session session = subject.getSession();
String sessionId = session.getId().toString();
//判斷局部會話是否登錄
try{
String cacheClientSession = RedisUtil.get(PMI_SHIRO_SESSION_CLIENT + "-" + sessionId);
if(StringUtils.isNotBlank(cacheClientSession)) {
//更新有效期
RedisUtil.set(PMI_SHIRO_SESSION_CLIENT + "-" + sessionId, cacheClientSession, (int)session.getTimeout() / 1000);
//移除url中的code參數
if(null != request.getParameter("code")){
String backUrl = RequestParameterUtil.getParameterWithOutCode(WebUtils.toHttp(request));
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
try {
httpServletResponse.sendRedirect(backUrl);
} catch (IOException e) {
logger.error("局部會話已登錄,移除code參數跳轉出錯:", e);
}
} else {
return true;
}
}
} catch (Exception e){
logger.error(e.getMessage(), e);
}
// 判斷是否有認證中心code
String code = request.getParameter("pmi_code");
// 已拿到code
if(StringUtils.isNotBlank(code)) {
//HttpPost去校驗code
try {
StringBuffer ssoServerUrl = new StringBuffer(PropertiesFileUtil.getInstance("client").get("pmi.sso.server.url"));
HttpClient httpClient = new DefaultHttpClient();
HttpPost httpPost = new HttpPost(ssoServerUrl.toString() + "/sso/code");
List<NameValuePair> nameValuePairs = new ArrayList<>();
nameValuePairs.add(new BasicNameValuePair("code", code));
httpPost.setEntity(new UrlEncodedFormEntity(nameValuePairs));
HttpResponse httpResponse = httpClient.execute(httpPost);
if(httpResponse.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
HttpEntity httpEntity = httpResponse.getEntity();
JSONObject result = JSONObject.parseObject(EntityUtils.toString(httpEntity));
if(1 == result.getIntValue("code") && result.getString("data").equals(code)){
Jedis jedis = RedisUtil.getJedis();
jedis.sadd(PMI_SHIRO_CONNECTIDS + "-" + code,PMI_SHIRO_SESSION_CLIENT + "-" + sessionId);
jedis.close();
pmiSessionDao.updateStatus(sessionId, PMISession.OnlineStatus.on_line);
jedis = RedisUtil.getJedis();
Long number = jedis.scard(PMI_SHIRO_CONNECTIDS + "-" + code);
jedis.close();
logger.info("當前code={},對應的注冊系統個數:{}個", code, number);
// 返回請求資源
try {
// 移除url中的token參數(此處會導致驗證通過后,仍然要進行一次驗證,不過如果去掉的話,將會暴露pmi_code參數,影響安全性,暫無其他方案,先擱置)
String backUrl = RequestParameterUtil.getParameterWithOutCode(WebUtils.toHttp(request));
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.sendRedirect(backUrl);
return true;
} catch (IOException e) {
logger.error("已拿到code,移除code參數跳轉出錯:", e);
}
} else {
logger.warn(result.getString("data"));
}
}
} catch (IOException e) {
logger.error("驗證token失敗:", e);
}
}
return false;
}
校驗code方法如下:
@RequestMapping(value = "/code", method = RequestMethod.POST)
@ResponseBody
public Object code(HttpServletRequest request) {
String codeParam = request.getParameter("code");
String code = RedisUtil.get(PMI_SERVER_CODE + "-" + codeParam);
if(StringUtils.isBlank(codeParam) || !codeParam.equals(code)){
new WebResult(WebResultConstant.FAILED, "無效code");
}
return new WebResult(WebResultConstant.SUCCESS, code);
}
-
sso認證中心校驗令牌,返回有效,注冊系統1
-
系統1使用該令牌創建與用戶的會話,稱為局部會話,返回受保護資源
-
用戶訪問系統2的受保護資源
-
系統2發現用戶未登錄,跳轉至SSO認證中心,並將自己的地址作為參數
同系統1,先經過PMIAuthenticationFilter.isAccessAllowed(),驗證失敗后通過onAccessDenied()跳轉到sso認證中心 -
SSO認證中心發現用戶已登錄,跳轉回系統2的地址,並附上令牌
此時redis中已全局會話已存在,返回code校驗值 -
系統2拿到令牌,去SSO認證中心校驗令牌是否有效
同系統1,通過validateClient()方法驗證令牌有效性 -
SSO認證中心校驗令牌,返回有效,注冊系統2
-
系統2使用該令牌創建與用戶的局部會話,返回受保護資源
會話持久化
當用戶請求Url時,無論用戶是否登錄,Shiro已經創建了相應的會話,而該會話在用戶尚未登錄時,屬於無效會話,不需要進行持久化,當用戶成功登錄時,會話才作為一個有效會話保存至Redis中。
當該用戶再次請求登錄時,需檢查舊sessionId與新sessionId是否一致,若sessionId不一致,說明現在不在同一台機器上,需要將原會話信息刪除,保存新會話信息。
會話管理器sessionManager詳解
- 自定義WebSessionManager,用於替代DefaultWebSessionManager
在shiro的一次認證過程中會調用10次左右的 doReadSession,如果使用內存緩存這個問題不大。
但是如果使用redis,而且在網絡情況不是特別好的情況下這就成為問題了。
針對這個問題重寫DefaultWebSessionManager,將緩存數據存放到request中,這樣可以保證每次請求(可能會多次調用doReadSession方法)只請求一次redis。
代碼過長,詳細見github鏈接 - session的創建過程
當發起一個請求時,即創建一個會話,shiro通過會話Dao中的doReadSession方法查詢會話,此時若查詢結果為空,則認為會話不存在,通過sessionFacotry中的createSession方法創建一個會話,再通過調用SessionIdGenerator中的generateId方法產生會話的sessionId。同時當會話創建時會通過SessionListener對會話創建的動作進行監聽。再通過會話dao中的doCreate()方法,對session會話進行處理(由於我們是在用戶登錄成功后,才將session進行持久化,所以在doCreate方法中沒有對session做處理)。
相關代碼如下
自定義sessionFactory:
public class PMISessionFactory implements SessionFactory {
@Override
public Session createSession(SessionContext sessionContext) {
PMISession session = new PMISession();
if(null != sessionContext && sessionContext instanceof WebSessionContext) {
WebSessionContext webSessionContext = (WebSessionContext) sessionContext;
HttpServletRequest request = (HttpServletRequest) webSessionContext.getServletRequest();
if(null != request) {
session.setHost(request.getRemoteAddr());
session.setUserAgent(request.getHeader("User-Agent"));
}
}
return session;
}
}
自定義SessionIdGenerator
public class JavaUUIDSessionIdGenerator implements SessionIdGenerator {
@Override
public Serializable generateId(Session session) {
return UUID.randomUUID().toString().replaceAll("-", "");
}
}
自定義SessionListener
public class PMISessionListener implements SessionListener {
private static final Logger logger = LoggerFactory.getLogger(PMISessionListener.class);
@Override
public void onStart(Session session) {
logger.info("會話創建:" + session.getId());
}
@Override
public void onStop(Session session) {
logger.info("會話停止:" + session.getId());
}
@Override
public void onExpiration(Session session) {
logger.info("會話過期:" + session.getId());
}
}
自定義sessionDao(重點)
/**
* Created by winter on 2021/5/13
*/
public class PMISessionDao extends EnterpriseCacheSessionDAO {
private static final Logger logger = LoggerFactory.getLogger(PMISessionDao.class);
// 會話key
private final static String PMI_SHIRO_SESSION = "pmi-shiro-session";
// sso服務器授權令牌
private final static String PMI_SERVER_CODE = "pmi-server-code";
// 以sso服務器sessionId關聯的從session列表
private final static String PMI_SHIRO_CONNECTIDS = "pmi-shiro-connectIds";
@Override
//此時會話已創建,但僅是創建狀態,並未登錄用戶,故在該步驟不需要進行會話持久化,直接保存在cookie中即可
protected Serializable doCreate(Session session) {
Serializable sessionId = super.doCreate(session);
String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type");
logger.info("doCreate >>>>> type = {}, sessionId={}", PMIType, session.getId());
return sessionId;
}
@Override
//getSession,此時session可能有以下情況:
//1.session在cache中存在,redis中不存在,取cache中存在的session即可
//2.session在cache中不存在(已過期),redis中存在,取redis中存在的session
//3.session在cache和redis中都不存在,返回null,此時會創建新會話
//4.session在cache和redis中都存在,無需查詢redis,取緩存中的即可。
protected Session doReadSession(Serializable sessionId) {
//從緩存中取Session
String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type");
Cache<Serializable,Session> sessionCache = this.getActiveSessionsCache();
PMISession session = (PMISession) sessionCache.get(sessionId);
if(session != null){
logger.info("doReadSession use cache >>>>> type = {}, sessionId={}", PMIType, sessionId);
return session;
}
session = (PMISession) SerializableUtil.deserialize(RedisUtil.get(PMI_SHIRO_SESSION + "-" + PMIType + "-" +sessionId));
logger.info("doReadSession use redis >>>>> type = {}, sessionId={}", PMIType, sessionId);
return session;
}
@Override
protected void doUpdate(Session session) {
//如果會話過期/停止 沒必要再更新了
if(session instanceof ValidatingSession && !((ValidatingSession)session).isValid()) {
return;
}
HttpServletRequest request = Servlets.getRequest();
if(request == null) {
return;
}
//更新session的最后一次訪問時間
PMISession pmiSession = (PMISession) session;
PMISession cachePMISession = (PMISession) doReadSession(session.getId());
if(null != cachePMISession) {
pmiSession.setStatus(cachePMISession.getStatus());
pmiSession.setAttribute("FORCE_LOGOUT", cachePMISession.getAttribute("FORCE_LOGOUT"));
}
//在線狀態才更新
if(pmiSession.getStatus() == PMISession.OnlineStatus.on_line){
RedisUtil.set(PMI_SHIRO_SESSION + "_" + session.getId(), SerializableUtil.serialize(session), (int) session.getTimeout() / 1000);
}
logger.info("doUpdate >>>>> sessionId={}", session.getId());
}
@Override
protected void doDelete(Session session) {
String sessionId = session.getId().toString();
String PMIType = ObjectUtils.toString(session.getAttribute(PMIConstant.PMI_TYPE));
}
/**
* 更改在線狀態
*/
public void updateStatus(Serializable sessionId, PMISession.OnlineStatus onlineStatus){
Cache<Serializable,Session> sessionCache = this.getActiveSessionsCache();
String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type");
PMISession session = (PMISession) sessionCache.get(sessionId);
if(null == session) {
return;
}
session.setStatus(onlineStatus);
try {
RedisUtil.set(PMI_SHIRO_SESSION + "-" + PMIType + "-" + session.getId(), SerializableUtil.serialize(session), (int)session.getTimeout() / 1000);
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
/**
* 刪除舊會話信息
*/
public void deleteOldSession(Serializable sessionId){
//刪除舊code校驗值
RedisUtil.remove(PMI_SERVER_CODE + "-" + sessionId);
//刪除舊會話
String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type");
RedisUtil.remove(PMI_SHIRO_SESSION + "-" + PMIType + "-" + sessionId);
//根據sessionId獲取關聯的從服務器列表
Jedis jedis = RedisUtil.getJedis();
Set<String> set = jedis.smembers(PMI_SHIRO_CONNECTIDS + "-" + sessionId);
//刪除關聯的從服務器列表會話
for(String data : set) {
RedisUtil.remove(data);
}
RedisUtil.remove(PMI_SHIRO_CONNECTIDS + "-" + sessionId);
}
}
自定義Session,將會話的登錄狀態寫入Session中
public class PMISession extends SimpleSession {
public enum OnlineStatus {
on_line("在線"),
off_line("離線"),
force_logout("強制退出");
private final String info;
OnlineStatus(String info) {
this.info = info;
}
public String getInfo() {
return info;
}
}
// 用戶瀏覽器類型
private String userAgent;
// 在線狀態
private OnlineStatus status = OnlineStatus.off_line;
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public OnlineStatus getStatus() {
return status;
}
public void setStatus(OnlineStatus status) {
this.status = status;
}
}
在shiro配置文件中(applicationContext-shiro.xml)配置會話管理器(sessionManager)
<!-- 會話管理器 -->
<bean id="sessionManager" class="com.winter.framework.shiro.sessionManager.PMIWebSessionManager">
<!-- 全局session超時時間 -->
<property name="globalSessionTimeout" value="${pmi.session.timeout}"/>
<!-- sessionDao -->
<property name="sessionDAO" ref="sessionDAO"/>
<property name="sessionIdCookieEnabled" value="true"/>
<property name="sessionIdCookie" ref="sessionIdCookie"/>
<!-- 定時清理失效會話, 清理用戶直接關閉瀏覽器造成的孤立會話 -->
<property name="sessionValidationInterval" value="${session.sessionTimeoutClean}"/>
<property name="sessionValidationSchedulerEnabled" value="false"/>
<property name="sessionListeners">
<list><ref bean="sessionListener" /></list>
</property>
<property name="sessionFactory" ref="sessionFactory"/>
</bean>
<!-- 會話DAO,可重寫,持久化session -->
<bean id="sessionDAO" class="com.winter.framework.shiro.session.PMISessionDao">
<property name="sessionIdGenerator" ref="javaUUIDSessionIdGenerator"/>
<property name="activeSessionsCacheName" value="shiroSessionCache"/>
</bean>
<bean id="javaUUIDSessionIdGenerator" class="com.winter.framework.shiro.session.JavaUUIDSessionIdGenerator"/>
<!-- 會話監聽器 -->
<bean id="sessionListener" class="com.winter.framework.shiro.listen.PMISessionListener"/>
<!-- session工廠 -->
<bean id="sessionFactory" class="com.winter.framework.shiro.session.PMISessionFactory"/>
<!-- 會話Cookie模板 -->
<bean id="sessionIdCookie" class="org.apache.shiro.web.servlet.SimpleCookie">
<!-- 不會暴露給客戶端 -->
<property name="httpOnly" value="true"/>
<!-- 設置Cookie的過期時間,秒為單位,默認-1表示關閉瀏覽器時過期Cookie -->
<property name="maxAge" value="${pmi.session.rememberMe.timeout}"/>
<!-- Cookie名稱 -->
<property name="name" value="${pmi.session.id}"/>
</bean>
那么此時,如果經歷了一次client服務器的登錄請求,且登錄成功了,應該在redis中創建多少條數據呢?
-
當請求Url時,由於當用戶在從服務器對sso服務器發起請求時,同時創建了從服務器的session及sso服務器的session,故當用戶登錄成功后,sso服務器的session及從session都需要寫入到redis中。(shiro在請求Url時就已經創建了會話,此刻的session,不包含用戶信息,同時SimpleSession類中也不包含用戶信息。)
即在登錄后,shiro中需要存在兩條數據:pmi-shiro-session-master-(sessionId)、pmi-shiro-session-client-(sessionId),value值為序列化的session。
如果sso服務器的session已存在,另外一個系統發起請求時,若為同一個用戶。將只會創建新的pmi-shiro-session-client-(sessionId),而不會創建新的sso服務器session。 -
在用戶尚未登錄時,需要建立sso服務器與從服務器的關聯關系,使用授權令牌來進行關聯,此刻用戶尚未登錄,采用sso服務器的sessionId作為授權令牌,這個授權令牌通過sso服務器創建,但是從服務器可獲取。由此需要在Redis中創建一個用於sso服務器與從服務器交互的授權令牌,同時還需要創建一個set數組,能直接通過sso服務器的sessionId查詢到所有的的從服務器session,便於刪除操作。
此處需要redis中再存儲兩條數據:pmi-server-code-(sso服務器sessionId),value值暫時以sessionId作為授權令牌。pmi-shiro-connectIds-(sso服務器sessionId),value值為以sso服務器sessionId建立的pmi-shiro-client-session-(sessionId)set數組,方便在主會話修改或刪除時,對從會話進行統一的處理。 -
對於所有的主session,也需要創建一個集合,便於會話的管理,進行會話的強制退出等操作。集合的value值應該包含用戶信息及用戶對應的sessionId,當用戶登錄時,通過該集合查找該用戶是否已登錄,pmi-shiro-master-session是否一致(不一致則說明不是同一個登錄的場景),但是如果數據量過大的話,如果用戶信息和session作為一個value值存在,勢必會極大的影響效率。此處的考慮是將用戶信息寫在key中,通過前綴檢索查找所有用戶,value僅保存sessionId。即存儲的數據為pmi-shiro-user-(username),value值為sso服務器的sessionId。
即,在SSO認證中心校驗用戶信息時,若認證通過,此時pmi-server-code-(sso服務器sessionId)、pmi-shiro-user-(username)、pmi-shiro-session-master-(sessionId)三條數據寫入Redis中。請求回到從服務器,從服務器通過返回的令牌(pmi_code)。驗證pmi_code是否有效,驗證通過后,創建pmi-shiro-connectIds-(sso服務器sessionId)、pmi-shiro-client-session-(sessionId)。
注:此處使用了Cookie做了緩存,所以可以通過getActiveSessionsCache()獲取到尚未登錄但已創建的會話,減少了對Redis的壓力,但是使用緩存即會導致可見性問題,本文並未對可見性問題進行詳細的闡述與處理,應根據自己的實際需要進行相應的處理。沒有完美無缺的代碼,只有在實際條件下最合適的方案。