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的壓力,但是使用緩存即會導致可見性問題,本文並未對可見性問題進行詳細的闡述與處理,應根據自己的實際需要進行相應的處理。沒有完美無缺的代碼,只有在實際條件下最合適的方案。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM