第 49 章 监控会话过期

注意

patch已经被官方接受,这部分代码需要重写。

此功能来自于官方JIRA,http://jira.springsource.org/browse/SEC-1142,如果这个功能被官方接受,应该可以在Spring Security-3.0.0.M2中提供。

49.1. 实现原理

官方给出的思路是根据HttpServletRequest的getRequestedSessionId()和isRequestedSessionIdValid()来判断当前用户的会话是否过期。

马上想到的就是直接通过isRequestedSessionIdValid()来判断当前用户的会话是否有效,如果会话失效就提示会话过期,并跳转到相应的页面。可惜这样做的结果是无法区分用户第一次访问系统与真实的会话过期,造成的结果是用户每次登陆系统首先看到的都是会话过期的的提示。

下一步考虑将getRequestedSessionId()与isRequestedSessionIdValid()结合使用,先通过getRequestedSessionId()判断用户当前的请求是否带有jsessionid,如果请求中并没有包含jsessionid,说明用户是第一次访问系统,不需要理会。如果请求中包含了jsessionid,再去判断对应的会话是否失效,这样就可以区分用户第一次访问和会话失效两种情况。

这种方式还存在的一个漏洞,就是当已经登录的用户使用logout从系统中注销时,LogoutFilter中只会执行session.invalidate()将会话销毁,浏览器的cookie中依然保存着已销毁的session的jsessionid,这样就造成用户一旦进行注销就会提示会话已失效。为了解决这个问题,我们特地编写了一个LogoutHandler用于在注销时删除浏览器端cookie中的jsessionid。

49.2. 代码实现

最主要的代码就是SessionTimeoutFilter这个过滤器,它负责判断当前用户的状态,如果当前用户不是第一次访问系统,并且他的请求所对应的会话已经失效了,那么就发出会话已失效的信息。

public class SessionTimeoutFilter extends SpringSecurityFilter {
    private PortResolver portResolver = new PortResolverImpl();
    private String sessionTimeoutUrl;

    public void doFilterHttp(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
        if (this.isSessionExpired(request)) {
            this.processRequest(request, response);
        } else {
            chain.doFilter(request, response);
        }
    }

    protected String determineSessionTimeoutUrl(HttpServletRequest request) {
        return (sessionTimeoutUrl != null) ? sessionTimeoutUrl
                                           : "/spring_security_login?login_error";
    }

    protected boolean isSessionExpired(HttpServletRequest request) {
        return (request.getRequestedSessionId() != null)
        && !request.isRequestedSessionIdValid();
    }

    protected void processRequest(HttpServletRequest request,
        HttpServletResponse response) throws IOException {
        HttpSession session = request.getSession();
        SavedRequest savedRequest = new SavedRequest(request, portResolver);
        session.setAttribute(AbstractProcessingFilter.SPRING_SECURITY_LAST_EXCEPTION_KEY,
            new SessionTimeoutException());
        session.setAttribute(AbstractProcessingFilter.SPRING_SECURITY_SAVED_REQUEST_KEY,
            savedRequest);

        String targetUrl = determineSessionTimeoutUrl(request);

        targetUrl = request.getContextPath() + targetUrl;
        response.sendRedirect(response.encodeRedirectURL(targetUrl));
    }
}
        

为了演示方便,我们设置在用户会话失效时跳转到默认的登录页面,并自定义了一个SessionTimeoutException异常,它负责在默认的登录页面中显示会话失效的提示信息。

对应配置如下所示,为了避免其他浏览器新建session,我们要将SessionTimeoutException放在过滤器链的最前面。

<beans:bean id="sessionTimeoutFilter" class="com.family168.springsecuritybook.ch217.SessionTimeoutFilter">
    <custom-filter before="CONCURRENT_SESSION_FILTER" />
</beans:bean>
        

下一步需要处理注销时浏览器cookie中的jsessionid,我们创建一个SessionIdLogoutHandler用于清空cookie中的jsessionid。

public class SessionIdLogoutHandler implements LogoutHandler {
    public void logout(HttpServletRequest request,
        HttpServletResponse response, Authentication authentication) {
        Cookie cookie = new Cookie("JSESSIONID", null);
        cookie.setMaxAge(0);
        cookie.setPath(StringUtils.hasLength(request.getContextPath())
            ? request.getContextPath() : "/");

        response.addCookie(cookie);
    }
}
        

配置的时候稍微有一点儿麻烦,因为namespace不支持添加自定义的LogoutHandler,所以只好将整个LogoutFilter的配置都提取出来重新配置一遍,再覆盖原来的<logout/>。

<beans:bean id="logoutFilter" class="org.springframework.security.ui.logout.LogoutFilter">
    <custom-filter before="LOGOUT_FILTER" />
    <beans:constructor-arg index="0" value="/"/>
    <beans:constructor-arg index="1">
        <beans:list>
            <beans:bean class="org.springframework.security.ui.logout.SecurityContextLogoutHandler"/>
            <beans:ref bean="_rememberMeServices"/>
            <beans:bean class="com.family168.springsecuritybook.ch217.SessionIdLogoutHandler"/>
        </beans:list>
    </beans:constructor-arg>
</beans:bean>
        

这样就实现了在用户会话失效时提示对应的信息,我们可以启动ch217测试一下实际的效果。

首先我们使用user/user或admin/admin登录系统,然后静待1分钟,为了演示方便我们在web.xml中将session失效时间设置为1分钟,只要登陆后1分钟以上不进行操作,session就会自动失效。如下图所示:

会话失效

图 49.1. 会话失效


49.3. 目前实现的缺陷

最大的缺陷就是不支持remember-me,因为SessionTimeoutFilter位于所有过滤器的前面,如果用户设置了RememberMe,他在遇到会话超时的情况下依然会被SessionTimeoutFilter拦截住,应该在目前实现的基础上添加对RememberMe的判断才好。

为求简便,我们这里就不做更多讨论了,请朋友们自行发挥。

实例见ch217。