单设备登录限制

需求背景

对于有会员系统或付费机制的项目,为了保证单个用户的账号权益不会被滥用,并且提升系统的安全性,我们通常会限制 单个账号在同一时间只能在一台设备上登录。为此,我们需要给系统添加共享账号的检测能力

系统设计

该需求建立在使用 Cookie/Session 登录的情况下,使用 token 登录不完全适用

业务流程分析

假设在已有的项目中,我们已经集成了 Spring Session 进行登录。
让我们先来梳理一下现在的流程:
1.前端发送登录请求给后端,传递用户名、密码等参数。
2.后端校验登录参数是否合法,如果没问题,则将用户登录态保存到 Redis(可以使用 spring-session-data-redis 库自动实现),并且将 cookie 返回给前端。
3.之后的请求会携带 Cookie,后端可以根据 Cookie(Session)从 Redis 获取到已保存的用户登录态,从而判断当前用户是否登录、已登录用户的信息等。

如图所示:

image-20250116152239561

核心实现思路

就上述的登录逻辑而言,我们没有采取任何措施去限制同一账号只能在单个设备登录,理论上一个账号就可以被任意个用户同时共享使用。这可能会导致资源访问冲突、权限管理不便、数据隐私泄露、影响系统所有者收入等等一系列严重的问题。
那如何实现账号的单设备登录限制呢?
一种很简单的逻辑是,最后登录的人会把前一个登录的人“顶掉”。用计算机术语来解释,就是:在登录时,删除掉该账号在其他设备上的登录态。

目前登录态是保存在后端的 Session 存储里的,而 Session 跟客户端的 Cookie 绑定,所以需要解决的问题就是:如何在当前客户端的登录请求中,获取并删除该账号在其他客户端上对应Session 中的登录态。
观察保存在 Redis 中的数据,我们发现 Spring Session 的 key 前缀都是一致的,不同的是后面的字符串(称为 UUID),那这们可以基本判定这个 UUID 跟客户端是有对应关系的。

image-20250116152603994

我们执行一次登录,通过 debug 断点来分析请求对象(request)的 session:

image-20250116153606292

根据图片可以发现,上述的 UUID 其实就是 sessionId:

image-20250116154426850

也就是说,只要有了用户的 sessionId,就能拼接出该用户在 Redis 中存储的 Spring Session key。

那么,只需要在用户登录的时候,把用户的 userId 作为 key、sessionId 作为 value 保存在 Redis 中。之后如果有其他客户端登录相同账号,就可以根据 userId 找到 sessionId,再获取到 Redis 中的 Spring Session 进行删除了。

单设备多客户端登录

通过上述思路,我们已经可以实现单客户端登录限制了。

但如果我自己的单个设备上有多个客户端,比如开多个不同的浏览器,理论上应该让我能够同时登录。

即使是相同的设备,在不同的浏览器内登录,sessionId 也是不同的,如果按照上述设计,还是会 “自己顶掉自己”。

那怎么允许单个设备多客户端同时登录呢?

光用 sessionId 作为冲突检测的凭证肯定是不够的了,我们要引入一个新的参数。

思考一下:我们怎么识别是否为同一个设备呢?

如果移动应用(比如安卓),是可以获取到设备唯一的 ID 的,但用户登录可能是从 PC 端的浏览器登录的,很难获取设备的唯一标识符。有没有其他的方法,能标识设备呢?

我们可以曲线救国,如果在同一个设备登录,大概率登录的网络 IP 地址是相同的。所以可以在保存 sessionId 时,同时保存登录的 IP。

在用户登录时,先判断 sessionId 是否相等,如果:

  • 相等。说明是同一个客户端,那么肯定是同一个设备,正常登录。
  • 不相等。需要补充额外的逻辑,判断 IP 是否相等,如果:
    • 相等,说明是同一个设备,允许登录。
    • 不相等,说明已在其他设备端登录,删除其他设备的登录态。

然后继续执行原有的登录逻辑。

方案

我们对上述思路进行整理,得到最终的实现方案:

1)在登录时,通过 IP 和 sessionId 判断用户是否已在其他设备登录;如果已登录,则通过 sessionId 将其他设备的登录态删除。

2)记录当前客户端的 IP 和 sessionId 并执行原有的登录流程。

我们完善后的方案对应的时序图如下:

image-20250116154627317

也就是说,我们只是增加了一些 IP 和 Session 处理的相关逻辑,而没有对原来的登录流程进行修改。符合开闭原则,保证了兼容性。

编码实现

基本功能

1)确保项目引入了 Redis 登录相关依赖以及工具库。

pom.xml配置如下:

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
39
40
41
42
43
44
45
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- https://hutool.cn/docs/index.html#/-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.8</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

2)因为我们需要操作 Redis 来存储登录态,所以需要设计 Redis 的 key 和 value 存储结构。

可以编写一个常量类 RedisKeyConstant,集中存放生成 key 要用到的多个常量

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface RedisKeyConstant {

// IP 和 sessionId 的 key 前缀
String USER_EXTRA_INFO = "user:extra:";
// Spring Session 中 session 信息的后缀(sessionId 前面)
String SESSION_KEY_POSTFIX = "sessions";
// session 中保存的属性的前缀
String SESSION_ATTRIBUTE_PREFIX = "sessionAttr";

/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "user_login";

String IP = "ip";

String SESSION_ID = "sessionId";
}

再编写一个工具类 RedisKeyUtil,用来快捷生成 key

代码如下:

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
public class RedisKeyUtil {

/**
* 获取已登录用户的 IP 和 sessionId 对应的 key
*
* @param userId 用户 id
* @return {@link String}
*/
public static String getUserExtraInfoKey(Long userId) {
return USER_EXTRA_INFO + String.valueOf(userId);
}


/**
* 获取 session 信息对应的 key
*
* @param sessionId sessionId
* @return {@link String}
*/
public static String getSessionKey(String sessionId) {
return DEFAULT_NAMESPACE + ":" + SESSION_KEY_POSTFIX + ":" + sessionId;
}

/**
* 获取 session 中某一属性的 key
*
* @param attrName 属性名称
* @return {@link String}
*/
public static String getSessionAttrKey(String attrName) {
return SESSION_ATTRIBUTE_PREFIX + ":" + attrName;
}
}

然后我们要编写 value 的存储结构,将用户信息和 ip 封装为一个 UserLoginRedisInfo 类进行存储。

代码如下:

1
2
3
4
5
6
7
8
9
@Data
@Builder
public class UserLoginRedisInfo {

private User user;

private String ip;

}

为了演示方便,用户类就简单一点,只有 id 字段,示例代码如下:

1
2
3
4
5
6
7
8
9
@Data
public class User implements Serializable {
/**
* id
*/
private Long id;

private static final long serialVersionUID = 1L;
}

3)编写判断登录是否冲突、以及登录逻辑。

使用一个独立的 SessionManager 类来集中管理用户的登录相关的功能,便于调用。

代码如下,核心方法是 checkOtherLogin

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
@Component
@Slf4j
public class SessionManager {
@Resource
private StringRedisTemplate stringRedisTemplate;

@Resource
private RedisIndexedSessionRepository sessionRepository;

@Value("${spring.session.timeout}")
private long sessionTimeout;

@Lazy
@Resource
private UserService userService;

/**
* 设置属性
*
* @param request 请求信息
* @param key 键
* @param value 值
* @param login 登录
*/
public void setAttribute(HttpServletRequest request, String key, Object value, boolean login) {
HttpSession session = request.getSession();
if (login) {
UserLoginRedisInfo userLoginRedisInfo = (UserLoginRedisInfo) value;
User user = userLoginRedisInfo.getUser();
// 存储登录态
session.setAttribute(key, user);

// 存储 sessionId 和 ip 信息
String sessionId = session.getId();
String userExtraInfoKey = RedisKeyUtil.getUserExtraInfoKey(user.getId());
stringRedisTemplate.opsForHash().put(userExtraInfoKey, SESSION_ID, sessionId);
stringRedisTemplate.opsForHash().put(userExtraInfoKey, IP, userLoginRedisInfo.getIp());
stringRedisTemplate.expire(userExtraInfoKey, sessionTimeout, TimeUnit.SECONDS);

} else {
session.setAttribute(key, value);
}
}

/**
* 设置登录属性
*
* @param request 请求信息
* @param loginKey 登录键
* @param userLoginRedisInfo 用户信息
*/
public void setLoginAttribute(HttpServletRequest request, String loginKey, UserLoginRedisInfo userLoginRedisInfo) {
setAttribute(request, loginKey, userLoginRedisInfo, true);
}

/**
* 删除属性
*
* @param request 请求信息
* @param key 键
*/
public void removeAttribute(HttpServletRequest request, String key) {
HttpSession session = request.getSession();
session.removeAttribute(key);
}

/**
* 退出登录
*
* @param request 请求信息
*/
public void logout(HttpServletRequest request) {
User loginUser = userService.getLoginUser(request);
removeAttribute(request, USER_LOGIN_STATE);
stringRedisTemplate.delete(RedisKeyUtil.getUserExtraInfoKey(loginUser.getId()));
}

/**
* 检查是否已在其他端登录
*
* @param userId 用户 id
* @return {@link String} 如果已在其他端登录,则返回其他端的 sessionId ,否则返回 null
*/
public String checkOtherLogin(Long userId, String currentIp) {
// 校验 sessionId
Object oldSessionIdObj = stringRedisTemplate.opsForHash().get(RedisKeyUtil.getUserExtraInfoKey(userId), SESSION_ID);
String oldSessionId = null;
if (oldSessionIdObj != null) {
oldSessionId = (String) oldSessionIdObj;
}

// 校验 ip
Object oldIpObj = stringRedisTemplate.opsForHash().get(RedisKeyUtil.getUserExtraInfoKey(userId), IP);
String oldIP = null;
if (oldIpObj != null) {
oldIP = (String) oldIpObj;
}

// 判断 sessionId 如果
// 为空或相等 返回 null
// 不等,判断 ip 如果
// 为空或相等,返回 null
// 不等,返回 oldSessionId
if (StrUtil.isBlank(oldSessionId) || oldSessionId.equals(request.getSession().getId())) {
return null;
} else {
if (StrUtil.isBlank(oldIP) || oldIP.equals(currentIp)) {
return null;
}
return oldSessionId;
}
}

/**
* 删除其他 session 的登录属性
*
* @param sessionId sessionId
*/
public void removeOtherSessionLoginAttribute(String sessionId, Long userId) {
String sessionKey = RedisKeyUtil.getSessionKey(sessionId);
String sessionAttrKey = RedisKeyUtil.getSessionAttrKey(USER_LOGIN_STATE);
// 删除用户的额外信息
Boolean userExtraInfoDelete = stringRedisTemplate.delete(RedisKeyUtil.getUserExtraInfoKey(userId));
Long delete = sessionRepository.getSessionRedisOperations().opsForHash().delete(sessionKey, sessionAttrKey);

log.info("oldSessionId: {}, user extra info delete result: {}, user login state delete result: {}", sessionId, userExtraInfoDelete, delete);

}

/**
* 登录
*
* @param user 用户
* @param request 请求信息
* @return {@link String}
*/
public String login(User user, HttpServletRequest request) {
String message = "登录成功";
String ipAddress = NetUtils.getIpAddress(request);
String oldSessionId = this.checkOtherLogin(user.getId(), ipAddress);
// 不为空,说明已在其他端登录
if (StrUtil.isNotBlank(oldSessionId)) {
// 删除 oldSessionId 的登录态
this.removeOtherSessionLoginAttribute(oldSessionId, user.getId());
message += ",已移除其他设备的登录";
}
UserLoginRedisInfo userLoginRedisInfo = UserLoginRedisInfo.builder()
.user(user)
.ip(ipAddress)
.build();
this.setLoginAttribute(request, USER_LOGIN_STATE, userLoginRedisInfo);

return message;
}
}

注意,在上述代码中,我们使用了 RedisIndexedSessionRepository。后面会讲解

4)测试验证。编写一个 Controller 接口实现多设备模拟登录功能,代码如下:

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
@RestController
@RequestMapping("/user/mock")
@Profile({"local", "dev"})
@Slf4j
public class UserMockController {

@Resource
private SessionManager sessionManager;

/**
* 用户登录模拟
*
* @param request 请求信息
* @return {@link BaseResponse}<{@link User}>
*/
@PostMapping("/login")
public BaseResponse<User> userLoginMock(HttpServletRequest request) {
User user = new User();
user.setId(1L);
sessionManager.login(user, request);
log.info("user login succeed, id = {}", user.getId());

return ResultUtils.success(user);
}
}

功能完善

至此,基本功能就完成了。但还是有一些不完善点,比如会有并发操作的问题:如果用户在多台设备同时发出登录请求,是有小概率可以在多台设备登录上的。

这里可以通过加锁和事务来处理;也可以在每次获取当前登录用户时,再次检验该用户是否已在其他客户端登录。更推荐后面这种做法,双重保险。

Redis 中发生运行时异常时,事务不会回滚

修改获取当前登录用户信息接口,补充校验逻辑。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public User getLoginUser(HttpServletRequest request) {
// 先判断是否已登录
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null || currentUser.getId() == null) {
throw new RuntimeException("未登录");
}
// 校验是否已在其他客户端登录
String ipAddress = NetUtils.getIpAddress(request);
String oldSessionId = sessionManager.checkOtherLogin(currentUser.getId(), ipAddress, request);
if (StrUtil.isNotBlank(oldSessionId)) {
request.getSession().removeAttribute(USER_LOGIN_STATE);
throw new RuntimeException("已在其他设备登录,请重新登录");
}
// 从数据库查询(追求性能的话可以注释,直接走缓存)
long userId = currentUser.getId();
currentUser = this.getById(userId);
if (currentUser == null) {
throw new RuntimeException("当前未登录");
}
return currentUser;
}

这样做的优点是改动比较小,不需要维护锁和事务;缺点也很明显,就是获取当前登录用户时多了一次跟 Redis 的交互