0%

用户只能单设备登录

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

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

两种单设备登录策略

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
@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 方法:判断是否允许新登录

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 方法:处理会话超额

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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();
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@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 提供的会话管理机制,我们可以灵活地实现单设备登录控制,既可以选择 “顶掉旧登录” 的宽松策略,也可以选择 “拒绝新登录” 的严格策略,满足不同系统的安全需求

欢迎关注我的其它发布渠道

表情 | 预览
快来做第一个评论的人吧~
Powered By Valine
v1.3.10