示例代码链接: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的压力,但是使用缓存即会导致可见性问题,本文并未对可见性问题进行详细的阐述与处理,应根据自己的实际需要进行相应的处理。没有完美无缺的代码,只有在实际条件下最合适的方案。