Shiro的登录拦截及单点登录实现

示例代码链接:https://github.com/Winter730/springmvc-shiro-demo

Shiro组件

  1. Web过滤器:shiroFilterFactoryBean
    参数如下:
  • securityManager
  • loginUrl 登录拦截跳转的Url
  • successUrl 登录成功跳转的Url
  • filters authc过滤器
  • filterChainDefinitions 指定过滤规则,其中:
    anno:任何人都可以访问;authc:必须是登录之后才能进行访问,不包含remember me;user:登录用户才可以访问,包含remember me;perms:指定过滤规则,这个一般是扩展使用,不会使用原生的。
  1. 安全管理器:securityManager 负责对所有的subject进行安全管理。
    通过SecurityManager可以完成subject的认证、授权等,实质上SecurityManager是通过Authenticator进行认证,通过Authorizer进行授权,通过SessionManager进行会话管理等。
    参数如下:
  • realms
  • sessionManager
  • rememberMeManager
  1. 领域:realms
    相当于datasource数据源,securityManager进行安全认证需要通过Realm获取用户权限数据,比如:如果用户身份数据在数据库那么realm就需要从数据库获取用户身份信息。
    注意:不要把realm理解成只是从数据源取数据,在realm中还有认证授权校验的相关的代码。

  2. 自动登录:rememberMeManager
    参数如下:

  • cipherKey cookie加密密钥
  • rememberMeCookie
  1. 自动登录缓存cookie:rememberMeCookie
    参数如下:
  • httpOnly:是否暴露给客户端
  • maxAge:Cookie生效时间,-1表示关闭浏览器时过期Cookie
  1. 会话管理:sessionManager
    shiro框架定义了一套会话管理,它不依赖web容器的session,所以shiro可以使用在非web应用上,也可以将分布式应用的会话集中在一点管理,此特性可使它实现单点登录
    参数如下:
  • globalSessionTimeout 全局session超时时间
  • sessionDAO
  • sessionIdCookieEnabled 是否将sessionId保存到Cookie中
  • sessionIdCookie
    sessionValidationSchedulerEnabled 是否开启会话验证器
  • sessionListeners 会话监听器
  • sessionFactory session工厂
  • cacheManager
  1. SessionDAO 会话dao
    是对session会话操作的一套接口,比如要将session存储到数据库,可以通过jdbc将会话存储到数据库。

  2. 会话Cookie:sessionIdCookie
    参数如下:

  • httpOnly:是否暴露给客户端
  • maxAge:Cookie生效时间,-1表示关闭浏览器时过期Cookie
  1. 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拦截,但用户验证已通过,故登录成功,可顺利访问受访问资源。

根据执行流程所述详细实现代码实现如下:

  1. 继承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;
    }
}
  1. 登录被拒绝时请求转发到登录接口,登录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;
    }
}
  1. 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());
    }
}
  1. 进行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>
  1. 配置文件中,配置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实现接口权限管理

  1. 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"/>
  1. 在需要配置权限的接口上加入权限注解@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";
    }


}
  1. 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认证中心验证用户的用户名密码没问题,创建授权令牌。
在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。
其过程如图所示
image
上图描述及相关代码描述如下:

  1. 用户访问系统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;
    }
  1. 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";
    }
  1. 用户输入用户名密码提交登录申请

  2. 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;
        }
    }
  1. SSO认证中心带着令牌(pmi_code)及用户名(pmi_username)跳转回最初的请求地址(backUrl)

  2. 系统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);
    }
  1. sso认证中心校验令牌,返回有效,注册系统1

  2. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源

  3. 用户访问系统2的受保护资源

  4. 系统2发现用户未登录,跳转至SSO认证中心,并将自己的地址作为参数
    同系统1,先经过PMIAuthenticationFilter.isAccessAllowed(),验证失败后通过onAccessDenied()跳转到sso认证中心

  5. SSO认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
    此时redis中已全局会话已存在,返回code校验值

  6. 系统2拿到令牌,去SSO认证中心校验令牌是否有效
    同系统1,通过validateClient()方法验证令牌有效性

  7. SSO认证中心校验令牌,返回有效,注册系统2

  8. 系统2使用该令牌创建与用户的局部会话,返回受保护资源

会话持久化

当用户请求Url时,无论用户是否登录,Shiro已经创建了相应的会话,而该会话在用户尚未登录时,属于无效会话,不需要进行持久化,当用户成功登录时,会话才作为一个有效会话保存至Redis中。
当该用户再次请求登录时,需检查旧sessionId与新sessionId是否一致,若sessionId不一致,说明现在不在同一台机器上,需要将原会话信息删除,保存新会话信息。

会话管理器sessionManager详解
  1. 自定义WebSessionManager,用于替代DefaultWebSessionManager
    在shiro的一次认证过程中会调用10次左右的 doReadSession,如果使用内存缓存这个问题不大。
    但是如果使用redis,而且在网络情况不是特别好的情况下这就成为问题了。
    针对这个问题重写DefaultWebSessionManager,将缓存数据存放到request中,这样可以保证每次请求(可能会多次调用doReadSession方法)只请求一次redis。
    代码过长,详细见github链接
  2. 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中创建多少条数据呢?

  1. 当请求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。

  2. 在用户尚未登录时,需要建立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数组,方便在主会话修改或删除时,对从会话进行统一的处理。

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

posted @ 2021-05-20 23:03  cos晓风残月  阅读(3641)  评论(1编辑  收藏  举报
*