后端后端单设备登录限制
xukun需求背景
对于有会员系统或付费机制的项目,为了保证单个用户的账号权益不会被滥用,并且提升系统的安全性,我们通常会限制 单个账号在同一时间只能在一台设备上登录。为此,我们需要给系统添加共享账号的检测能力
系统设计
该需求建立在使用 Cookie/Session 登录的情况下,使用 token 登录不完全适用
业务流程分析
假设在已有的项目中,我们已经集成了 Spring Session 进行登录。
让我们先来梳理一下现在的流程:
1.前端发送登录请求给后端,传递用户名、密码等参数。
2.后端校验登录参数是否合法,如果没问题,则将用户登录态保存到 Redis(可以使用 spring-session-data-redis
库自动实现),并且将 cookie 返回给前端。
3.之后的请求会携带 Cookie,后端可以根据 Cookie(Session)从 Redis 获取到已保存的用户登录态,从而判断当前用户是否登录、已登录用户的信息等。
如图所示:

核心实现思路
就上述的登录逻辑而言,我们没有采取任何措施去限制同一账号只能在单个设备登录,理论上一个账号就可以被任意个用户同时共享使用。这可能会导致资源访问冲突、权限管理不便、数据隐私泄露、影响系统所有者收入等等一系列严重的问题。
那如何实现账号的单设备登录限制呢?
一种很简单的逻辑是,最后登录的人会把前一个登录的人“顶掉”。用计算机术语来解释,就是:在登录时,删除掉该账号在其他设备上的登录态。
目前登录态是保存在后端的 Session 存储里的,而 Session 跟客户端的 Cookie 绑定,所以需要解决的问题就是:如何在当前客户端的登录请求中,获取并删除该账号在其他客户端上对应Session 中的登录态。
观察保存在 Redis 中的数据,我们发现 Spring Session 的 key 前缀都是一致的,不同的是后面的字符串(称为 UUID),那这们可以基本判定这个 UUID 跟客户端是有对应关系的。
我们执行一次登录,通过 debug 断点来分析请求对象(request)的 session:

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

也就是说,只要有了用户的 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 并执行原有的登录流程。
我们完善后的方案对应的时序图如下:
也就是说,我们只是增加了一些 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>
<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>
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency>
<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 {
String USER_EXTRA_INFO = "user:extra:"; String SESSION_KEY_POSTFIX = "sessions"; 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 {
public static String getUserExtraInfoKey(Long userId) { return USER_EXTRA_INFO + String.valueOf(userId); }
public static String getSessionKey(String sessionId) { return DEFAULT_NAMESPACE + ":" + SESSION_KEY_POSTFIX + ":" + sessionId; }
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 {
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;
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);
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); } }
public void setLoginAttribute(HttpServletRequest request, String loginKey, UserLoginRedisInfo userLoginRedisInfo) { setAttribute(request, loginKey, userLoginRedisInfo, true); }
public void removeAttribute(HttpServletRequest request, String key) { HttpSession session = request.getSession(); session.removeAttribute(key); }
public void logout(HttpServletRequest request) { User loginUser = userService.getLoginUser(request); removeAttribute(request, USER_LOGIN_STATE); stringRedisTemplate.delete(RedisKeyUtil.getUserExtraInfoKey(loginUser.getId())); }
public String checkOtherLogin(Long userId, String currentIp) { Object oldSessionIdObj = stringRedisTemplate.opsForHash().get(RedisKeyUtil.getUserExtraInfoKey(userId), SESSION_ID); String oldSessionId = null; if (oldSessionIdObj != null) { oldSessionId = (String) oldSessionIdObj; }
Object oldIpObj = stringRedisTemplate.opsForHash().get(RedisKeyUtil.getUserExtraInfoKey(userId), IP); String oldIP = null; if (oldIpObj != null) { oldIP = (String) oldIpObj; }
if (StrUtil.isBlank(oldSessionId) || oldSessionId.equals(request.getSession().getId())) { return null; } else { if (StrUtil.isBlank(oldIP) || oldIP.equals(currentIp)) { return null; } return oldSessionId; } }
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);
}
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)) { 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;
@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 的交互