第 46 章 自定义会话管理

注意

concurrent-session存在一个问题,如果登录时选择了rememberMe,然后关闭浏览器,再打开浏览器访问应用就会出现sessionId为空的问题,暂时无法解决。

46.1. 默认策略的缺陷

默认提供的concurrent-session-control只支持两种策略:

  • 一是允许后登陆的用户将之前登陆的用户踢出系统,先登录的用户会看到“会话已失效”的提示。

    这种策略实际上没办法阻止其他人在我登陆到系统之后,再使用我的账号登陆系统。最后会形成双方争先恐后的进行登录,不断将对方踢出系统的情况。

  • 另一个是禁止用户使用已登录到系统的账户进行登录,后登陆的用户会在登录时看到“会话数量超过允许范围”的提示。

    这种策略会导致一个比较麻烦的问题,如果用户不慎关闭了浏览器,没有通过logout退出系统,那么必须等到系统中用户的会话失效之后才能再次登录。如果用户不留神关了浏览器,就要再等半个小时才能再登录系统,这显然是没有道理的。

为了保证用户使用系统时不会受到其他人的影响,同时也保证不会同时有多个人使用同一个账号登陆系统,我们系统默认提供的第二种策略上进行一些修改。

简单来说,就是当用户已经登陆到系统时,如果又有人使用同一账号尝试登录系统,系统会判断当前用户的ip地址,如果ip地址与上次登录的ip地址相同,则注销上次登录的会话,允许当前用户登录系统。如果ip地址与上次登录的ip地址不同,而抛出异常,禁止用户登录系统。

这样既保证了同一时间只能有一个用户登录系统,又可以在用户操作失误关闭浏览器后可以再次登录系统。

46.2. 记录用户名与ip

默认情况下,系统使用SessionRegistry中只保存登录的用户名,为了比对ip地址,我们需要在登录时将用户的远程ip也保存到SessionRegistry中。

为此我们创建了SmartPrincipal类,它包含username和ip两个字段,并定义了equals()和hashCode(),这样可以保证它在HashMap中的操作不会出现问题。

package com.family168.springsecuritybook.ch214;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetails;

import org.springframework.util.Assert;


public class SmartPrincipal {
    private String username;
    private String ip;

    public SmartPrincipal(String username, String ip) {
        Assert.notNull(username,
            "username cannot be null (violation of interface contract)");
        Assert.notNull(ip,
            "username cannot be null (violation of interface contract)");
        this.username = username;
        this.ip = ip;
    }

    public SmartPrincipal(Authentication authentication) {
        Assert.notNull(authentication,
            "authentication cannot be null (violation of interface contract)");

        String username = null;

        if (authentication.getPrincipal() instanceof UserDetails) {
            username = ((UserDetails) authentication.getPrincipal())
                .getUsername();
        } else {
            username = (String) authentication.getPrincipal();
        }

        String ip = ((WebAuthenticationDetails) authentication
            .getDetails()).getRemoteAddress();
        this.username = username;
        this.ip = ip;
    }

    public boolean equalsIp(SmartPrincipal smartPrincipal) {
        return this.ip.equals(smartPrincipal.ip);
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof SmartPrincipal) {
            SmartPrincipal smartPrincipal = (SmartPrincipal) obj;

            return username.equals(smartPrincipal.username);
        }

        return false;
    }

    @Override
    public int hashCode() {
        return username.hashCode();
    }

    @Override
    public String toString() {
        return "SmartPrincipal:{username=" + username + ",ip=" + ip + "}";
    }
}
        

46.3. 改造控制类

下一步要改造ConcurrentSessionControlStrategy和SessionRegistryImpl。

改造SessionRegistryImpl的原因比较复杂,因为除了ProviderManager会通过ConcurrentSessionControlStrategy调用SessoinRegistry来注册登录用户之外,AuthenticationProcessFilter和SessionFixationProtectionFilter也会在处理会话伪造时直接调用SessionRegistry。

默认情况下,这些类都会把用户名直接注册到SessionRegistry中,因为我们现在需要获得用户ip,就需要把SmartPrincipal保存到SessionRegistry中,这里需要的就是做一步转换工作。

public class SmartSessionRegistry extends SessionRegistryImpl {
    public synchronized void registerNewSession(String sessionId,
        Object principal) {
        //
        // convert for SmartPrincipal
        //
        if (!(principal instanceof SmartPrincipal)) {
            principal = new SmartPrincipal(SecurityContextHolder.getContext()
                                                                .getAuthentication());
        }

        // FIXME: rememberMe cause sessionId==null, won't success register
        if (sessionId != null) {
            super.registerNewSession(sessionId, principal);
        }
    }
}
        

对于ConcurrentSessionControlStrategy来说,就是为了实现在登录时判断用户的ip是否与之前登陆是保存的一样,本打算继承ConcurrentSessionControlStrategy,但是因为其中的exceptionIfMaximumExceeded属性未暴露给子类,所以只好重写了一个类,其中主要修改了allowableSessionsExceeded(), checkAuthenticationAllowed(), registerSuccessfulAuthentication()方法,具体代码请参考实例中的SmartConcurrentSessionControlStrategy。

46.4. 修改配置文件

因为concurrent-session-controller标签不支持自定义的ConcurrentSessionController,我们只好使用其他办法将我们自定义的组件放入Spring Security中。

<http auto-config='true'>
	<intercept-url pattern="/admin.jsp" access="ROLE_ADMIN" />
	<intercept-url pattern="/**" access="ROLE_USER" />

	<session-management session-authentication-strategy-ref="currentControllerStrategy"/>
	<custom-filter position="CONCURRENT_SESSION_FILTER" ref="concurrentSessionFilter" />
</http>

<beans:bean id="concurrentSessionFilter"
	class="org.springframework.security.web.session.ConcurrentSessionFilter">
	<beans:property name="sessionRegistry" ref="sessionRegistry"/>
</beans:bean>

<beans:bean id="sessionRegistry"
	class="com.family168.springsecuritybook.ch214.SmartSessionRegistry"/>

<beans:bean id="currentControllerStrategy"
	class="com.family168.springsecuritybook.ch214.SmartConcurrentSessionControlStrategy">
	<beans:constructor-arg ref="sessionRegistry"/>
	<beans:property name="exceptionIfMaximumExceeded" value="true"/>
</beans:bean>
        

我们在session-management中使用session-authentication-strategy-ref将自定义的currentControllerStrategy引入命名空间中,它会自动将SmartSessionRegistry交给AuthenticationProcessFilter与SessionFixationProtectionFilter使用。

完成这些工作之后,我们需要找两台电脑来测试上述的策略是否可以正常运行。当有用户登录之后,其他人是无法在另外的电脑上使用同一账号登陆系统的,但是如果是当前登录的用户关闭了浏览器,这时再次登录系统应该是被允许的。

实例在ch214中。