1. 程式人生 > >基於java config的springSecurity(五)--session併發控制

基於java config的springSecurity(五)--session併發控制

參考資料:spring-security-reference.pdf的Session Management.特別是Concurrency Control小節.
管理session可以做到:
a.跟蹤活躍的session,統計線上人數,顯示線上使用者.
b.控制併發,即一個使用者最多可以使用多少個session登入,比如設為1,結果就為,同一個時間裡,第二處登入要麼不能登入,要麼使前一個登入失效.

1.註冊自定義的SessionRegistry(通過它可以做到上面的a點)

@Bean  

public SessionRegistry sessionRegistry(){  
    return new SessionRegistryImpl();  
}  
2.使用session併發管理,並注入上面自定義的SessionRegistry
@Override  

protected void configure(HttpSecurity http) throws Exception {  
    http.authorizeRequests().anyRequest().authenticated()  
        .and().formLogin().loginPage("/login").failureUrl("/login?error").usernameParameter("username").passwordParameter("password").permitAll()  
        .and().logout().logoutUrl("/logout").logoutSuccessUrl("/login?logout").permitAll()  
        .and().rememberMe().key("9D119EE5A2B7DAF6B4DC1EF871D0AC3C")  
        .and().exceptionHandling().accessDeniedPage("/exception/403")  
        .and().sessionManagement().maximumSessions(2).expiredUrl("/login?expired").sessionRegistry(sessionRegistry());  
}  
3.監聽session建立和銷燬的HttpSessionListener.讓spring security更新有關會話的生命週期,實現上建立的監聽只使用銷燬事件,至於session建立,security是呼叫org.springframework.security.core.session.SessionRegistry#registerNewSession
針對servlet管理的session,應使用org.springframework.security.web.session.HttpSessionEventPublisher,方法有多種:
a.重寫org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer#enableHttpSessionEventPublisher

public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
    @Override
    protected boolean enableHttpSessionEventPublisher() {
        return true;
    }
}
b.在AbstractAnnotationConfigDispatcherServletInitializer的子類DispatcherServletInitializer新增
@Override  
public void onStartup(ServletContext servletContext) throws ServletException {  
    super.onStartup(servletContext);  
    FilterRegistration.Dynamic encodingFilter = servletContext.addFilter("encoding-filter", CharacterEncodingFilter.class);  
    encodingFilter.setInitParameter("encoding", "UTF-8");  
    encodingFilter.setInitParameter("forceEncoding", "true");  
    encodingFilter.setAsyncSupported(true);  
    encodingFilter.addMappingForUrlPatterns(null, false, "/*");  
    servletContext.addListener(new HttpSessionEventPublisher());  
} 
使用springSession,直接向servletContext新增的session銷燬監聽是沒用的,看springSession的文件http://docs.spring.io/spring-session/docs/current/reference/html5/#httpsession-httpsessionlistener,將org.springframework.security.web.session.HttpSessionEventPublisher註冊成Bean就可以了.它的底層是對springSession的建立和銷燬進行監聽,不一樣的.
還要注意的是,新增對HttpSessionListener的支援是從spring Session 1.1.0開始的,寫這博文的時候,這版本還沒出來.所以,以前的原始碼有問題.
@Configuration
@EnableRedisHttpSession
@PropertySource("classpath:config.properties")
public class HttpSessionConfig {
    @Resource
    private Environment env;
    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        JedisConnectionFactory connectionFactory = new JedisConnectionFactory();
        connectionFactory.setHostName(env.getProperty("redis.host"));
        connectionFactory.setPort(env.getProperty("redis.port",Integer.class));
        return connectionFactory;
    }
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}
4.在spring controller注入SessionRegistry,測試.

附加session的建立與銷燬分析:
至於session的建立比較簡單,認證成功後,security直接呼叫
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter{
    sessionStrategy.onAuthentication(authResult, request, response);
}
org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy#onAuthentication{
    sessionRegistry.registerNewSession(request.getSession().getId(), uthentication.getPrincipal());
}
session的銷燬.沒有特殊修改,org.springframework.security.web.authentication.logout.LogoutFilter#handlers只有一個元素org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler,如果主動logout,就會觸發org.springframework.security.web.authentication.logout.LogoutFilter#doFilter,進而呼叫org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler#logout,從這個方法可以看出別人是怎麼處理失效的session的
public void logout(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) {
    Assert.notNull(request, "HttpServletRequest required");
    if (invalidateHttpSession) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            logger.debug("Invalidating session: " + session.getId());
            session.invalidate();
        }
    }

    if (clearAuthentication) {
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(null);
    }

    SecurityContextHolder.clearContext();
}
這裡可以看到使session失效,呼叫SecurityContextHolder.getContext().setAuthentication(null),清理SecurityContext
spring security登出操作和session過期都會引起session被銷燬.就會觸發org.springframework.security.web.session.HttpSessionEventPublisher#sessionDestroyed事件.原始碼如下
public void sessionDestroyed(HttpSessionEvent event) {
    HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
    Log log = LogFactory.getLog(LOGGER_NAME);
    if (log.isDebugEnabled()) {
        log.debug("Publishing event: " + e);
    }
    getContext(event.getSession().getServletContext()).publishEvent(e);
}
getContext(event.getSession().getServletContext())得到的是Root ApplicationContext,所以要把SessionRegistryImpl Bean註冊到Root ApplicationContext,這樣SessionRegistryImpl的onApplicationEvent方法才能接收上面釋出的HttpSessionDestroyedEvent事件.
public void onApplicationEvent(SessionDestroyedEvent event) {
    String sessionId = event.getId();
    removeSessionInformation(sessionId);
}
這裡就看removeSessionInformation(sessionId);這裡就會對SessionRegistryImpl相關資訊進會更新.進而通過SessionRegistryImpl獲得那些使用者登入了,一個使用者有多少個SessionInformation都進行了同步.

再來討論getContext(event.getSession().getServletContext())
ApplicationContext getContext(ServletContext servletContext) {
    return SecurityWebApplicationContextUtils.findRequiredWebApplicationContext(servletContext);
}
public static WebApplicationContext findRequiredWebApplicationContext(ServletContext servletContext) {
    WebApplicationContext wac = _findWebApplicationContext(servletContext);
    if (wac == null) {
        throw new IllegalStateException("No WebApplicationContext found: no ContextLoaderListener registered?");
    }
    return wac;
}
private static WebApplicationContext _findWebApplicationContext(ServletContext sc) {
    //從下面呼叫看,得到的是Root ApplicationContext,而不是Servlet ApplicationContext
    WebApplicationContext wac = getWebApplicationContext(sc);
    if (wac == null) {
        Enumeration<String> attrNames = sc.getAttributeNames();
        while (attrNames.hasMoreElements()) {
            String attrName = attrNames.nextElement();
            Object attrValue = sc.getAttribute(attrName);
            if (attrValue instanceof WebApplicationContext) {
                if (wac != null) {
                    throw new IllegalStateException("No unique WebApplicationContext found: more than one " +
                            "DispatcherServlet registered with publishContext=true?");
                }
                wac = (WebApplicationContext) attrValue;
            }
        }
    }
    return wac;
}
public static WebApplicationContext getWebApplicationContext(ServletContext sc) {
    return getWebApplicationContext(sc, WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE);
}
再假設得到的Servlet ApplicationContext,它還有parent(Root ApplicationContext),那麼它也會通知Root ApplicationContext下監聽SessionDestroyedEvent事件的Bean,(哈哈,但是沒有那麼多的如果);
但我還要如果使用者就想在servlet註冊SessionRegistryImpl,我覺得你可以繼承HttpSessionEventPublisher,重寫getContext方法了

針對於servlet容器的session,至於session過期,如果想測試,可以去改一下session的有效期短一點,然後等待觀察.下面是我的測試web.xml全部內容
<?xml version="1.0" encoding="UTF-8"?>
<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_3_1.xsd"
        metadata-complete="true"
        version="3.1">
    <session-config>
        <session-timeout>3</session-timeout>
    </session-config>
</web-app>
對於使用者主動關閉瀏覽器,服務端是沒有馬上觸發sessionDestroyed的,等待session過期應該是大多數開發者的需求.

關於踢下線功能:使用org.springframework.security.core.session.SessionRegistry#getAllSessions就可以得到某個使用者的所有SessionInformation,SessionInformation當然包括sessionId,剩下的問題就是根據sessionId獲取session,再呼叫session.invalidate()就可以完成需求了.但是javax.servlet.http.HttpSessionContext#getSession已過期,並且因為安全原因沒有替代方案,所以從servlet api2.1以後的版本,此路是不通的.
spring security提供了org.springframework.security.core.session.SessionInformation#expireNow,它只是標誌了一下過期,直到下次使用者請求被org.springframework.security.web.session.ConcurrentSessionFilter#doFilter攔截,
HttpSession session = request.getSession(false);
if (session != null) {
    SessionInformation info = sessionRegistry.getSessionInformation(session.getId());
    if (info != null) {
        if (info.isExpired()) {
            // Expired - abort processing
            doLogout(request, response);
            //其它程式碼忽略
        }
    }
}
這裡就會觸發了使用者登出.還有一種思路,session儲存在redis,直接從redis刪除某個session資料,詳細看org.springframework.session.SessionRepository,不太推薦這麼幹.

還有SessionRegistryImpl實現的併發控制靠以下兩個變數實現的使用者線上列表,重啟應用這兩個例項肯定會銷燬,
/** <principal:Object,SessionIdSet> */
private final ConcurrentMap<Object, Set<String>> principals = new ConcurrentHashMap<Object, Set<String>>();
/** <sessionId:Object,SessionInformation> */
private final Map<String, SessionInformation> sessionIds = new ConcurrentHashMap<String, SessionInformation>();

既然分散式應用也會有問題,這時就要實現自己的SessionRegistry,將session的資訊應儲存到一個集中的地方進行管理.