用户只能单设备登录

Spring Security 实现用户单设备登录控制

在许多系统中,为了安全性考虑,需要限制用户只能在一个设备上登录。Spring Security 提供了完善的会话管理机制,可以轻松实现这一需求,主要通过控制用户并发会话数量来实现。

两种单设备登录策略

1. 后来者登录,顶掉之前的登录者(默认行为)

这种策略允许新登录挤掉旧登录,适用于用户可能在不同设备间切换的场景。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .anyRequest().authenticated()
        .and()
        .csrf().disable()
        // 会话管理配置
        .sessionManagement()
            // 设置最大会话数为1,即同一用户只能有一个有效会话
            .maximumSessions(1);
}

效果:当用户在新设备登录时,系统会自动使该用户在其他设备上的旧会话失效,旧会话的用户在后续操作时会被要求重新登录。

2. 不允许后来者登录,保留当前登录

这种策略更严格,一旦用户在某个设备登录,其他设备的登录尝试会被拒绝。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .anyRequest().authenticated()
        .and()
        .csrf().disable()
        .sessionManagement()
            .maximumSessions(1)
            // 关键配置:当达到最大会话数时,阻止新的登录
            .maxSessionsPreventsLogin(true);
}

效果:如果用户已经在一个设备登录,当尝试在第二个设备登录时,会收到登录失败的提示,无法成功登录。

源码解析:并发会话控制的实现原理

Spring Security 通过 ConcurrentSessionControlAuthenticationStrategy 类实现并发会话控制,核心逻辑在 onAuthenticationallowableSessionsExceeded 方法中。

1. onAuthentication 方法:判断是否允许新登录

该方法在用户认证成功后执行,用于检查当前用户的会话数量是否超过限制:

public void onAuthentication(Authentication authentication,
      HttpServletRequest request, HttpServletResponse response) {

    // 获取当前用户的所有有效会话
    final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
          authentication.getPrincipal(), false);

    int sessionCount = sessions.size();
    // 获取允许的最大会话数(即我们配置的maximumSessions值)
    int allowedSessions = getMaximumSessionsForThisUser(authentication);

    // 情况1:当前会话数小于允许的数量,直接允许登录
    if (sessionCount < allowedSessions) {
        return;
    }

    // 情况2:允许无限会话(allowedSessions = -1),直接允许登录
    if (allowedSessions == -1) {
        return;
    }

    // 情况3:当前会话数等于允许的数量
    if (sessionCount == allowedSessions) {
        HttpSession session = request.getSession(false);
        
        // 如果当前请求已关联到一个已注册的会话,允许登录
        if (session != null) {
            for (SessionInformation si : sessions) {
                if (si.getSessionId().equals(session.getId())) {
                    return;
                }
            }
        }
    }

    // 情况4:会话数超过限制,处理超额情况
    allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
}

2. allowableSessionsExceeded 方法:处理会话超额

当会话数超过限制时,该方法决定是拒绝新登录还是使旧会话失效:

protected void allowableSessionsExceeded(List<SessionInformation> sessions,
        int allowableSessions, SessionRegistry registry)
        throws SessionAuthenticationException {
    
    // exceptionIfMaximumExceeded对应maxSessionsPreventsLogin配置
    if (exceptionIfMaximumExceeded || (sessions == null)) {
        // 策略2:拒绝新登录,抛出异常
        throw new SessionAuthenticationException(messages.getMessage(
                "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
                new Object[] { Integer.valueOf(allowableSessions) },
                "Maximum sessions of {0} for this principal exceeded"));
    }

    // 策略1:使最早的会话失效(默认行为)
    SessionInformation leastRecentlyUsed = null;

    // 找到最久未使用的会话
    for (SessionInformation session : sessions) {
        if ((leastRecentlyUsed == null)
                || session.getLastRequest()
                        .before(leastRecentlyUsed.getLastRequest())) {
            leastRecentlyUsed = session;
        }
    }

    // 标记旧会话为失效
    leastRecentlyUsed.expireNow();
}

扩展配置:会话失效后的处理

可以进一步配置会话失效后的行为,如跳转页面或返回提示信息:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .anyRequest().authenticated()
        .and()
        .csrf().disable()
        .sessionManagement()
            .maximumSessions(1)
            // 会话过期后跳转的页面
            .expiredUrl("/login?expired")
            // 当达到最大会话数时,阻止新登录
            .maxSessionsPreventsLogin(true)
            // 会话注册表,用于跟踪用户的所有会话
            .sessionRegistry(sessionRegistry());
}

// 配置会话注册表
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

前端处理建议

  1. 被顶掉的用户处理
    • 对于被顶掉的用户,在其进行下一次请求时,会收到会话过期的响应
    • 前端可以监听 401 响应,提示用户 “您的账号已在其他设备登录,请重新登录”
  2. 登录被拒绝的用户处理
    • 对于登录被拒绝的情况,后端会返回登录失败的响应
    • 前端可以显示 “您的账号已在其他设备登录,是否强制登录?”(需要额外开发强制登出功能)

实现强制登出功能

如果需要允许用户主动踢掉其他设备的登录,可以结合 SessionRegistry 实现:

@Service
public class SessionService {

    @Autowired
    private SessionRegistry sessionRegistry;

    /**
     * 强制登出用户的其他会话
     */
    public void forceLogoutOtherSessions(String username) {
        // 获取用户的所有会话
        List<Object> principals = sessionRegistry.getAllPrincipals();
        
        for (Object principal : principals) {
            if (principal instanceof UserDetails && 
                username.equals(((UserDetails) principal).getUsername())) {
                
                // 使除当前会话外的其他会话失效
                List<SessionInformation> sessions = sessionRegistry.getAllSessions(principal, false);
                HttpSession currentSession = SecurityContextHolder.getContext().getAuthentication().getDetails();
                
                for (SessionInformation session : sessions) {
                    if (!session.getSessionId().equals(currentSession.getId())) {
                        session.expireNow(); // 使会话失效
                    }
                }
            }
        }
    }
}

通过 Spring Security 提供的会话管理机制,我们可以灵活地实现单设备登录控制,既可以选择 “顶掉旧登录” 的宽松策略,也可以选择 “拒绝新登录” 的严格策略,满足不同系统的安全需求