在很多业务场景中,我们需要控制单个账号的并发登录数量。本文将介绍一种轻量级的实现方案,它能够灵活地支持单账号“单登录”和“多登录”两种模式,以满足不同业务的安全需求。
典型业务场景
- 付费系统场景:例如,某在线教育平台销售VIP会员账号。如果不限制登录数量,一个付费账号可能被多个用户共享使用,导致平台收入损失。因此,平台需要确保一个付费账号在同一时间只能有一个用户在线。
- 企业办公系统:为了保证核心数据的安全,企业内部的OA系统通常要求员工账号只能在公司指定的设备上登录。当员工尝试在新设备登录时,系统需要能自动踢出旧设备上的会话,防止账号被盗用或滥用。
传统方案的痛点
传统的账号登录控制方案在实际应用中往往面临各种挑战:
- Session方案的局限:使用HttpSession实现虽然简单,但在分布式微服务架构下会面临Session共享的难题。引入Spring Session或Session复制方案虽能解决问题,但显著增加了系统的复杂度和外部依赖,对于仅需登录控制的需求而言显得过于繁重。
- Spring Security的复杂性:Spring Security 作为强大的安全框架,功能全面,但其学习曲线陡峭,配置复杂。若仅为了实现登录控制而引入整套框架,会带来许多不必要的功能和性能开销。
- 数据库存储的性能问题:将登录状态直接存储在数据库中,意味着每次请求都需要进行数据库查询以验证状态,这在高并发场景下极易成为性能瓶颈。虽然可以通过引入缓存层(如Redis)来优化,但这又额外增加了系统的复杂度和维护成本。
- 与现代架构的适配问题:传统的基于页面跳转的认证方式已难以适应前后端分离的现代应用架构。前后端分离需要一套清晰、统一的API接口规范和状态管理机制。
方案概述
核心思路
本文提出的方案基于以下几个核心设计理念:
- Token认证:采用自定义Token机制替代传统的Session。Token具备无状态特性,在分布式环境下扩展性极佳,从根本上避免了Session共享的复杂性。
- 拦截器统一验证:利用Spring MVC的拦截器(Interceptor)机制,对所有需要保护的请求进行统一的登录状态验证。这种方式无需侵入业务代码,通过配置即可实现全局管控。
- 接口抽象与解耦:定义清晰的
SessionManager接口,将会话的存储逻辑与核心业务逻辑彻底分离。这种设计使得底层存储可以从内存Map轻松切换到Redis等分布式缓存,为系统未来的水平扩展提供了可能。
- 灵活的模式配置:通过简单的配置项(如
app.login.mode),即可在“单账号单登录”和“单账号多登录”模式间自由切换,快速响应不同业务线的需求。
技术选型
- 核心依赖:仅依赖
spring-boot-starter-web,不引入额外的安全框架或中间件,保持项目轻量化。
- 存储方案:
- 单机部署:使用
ConcurrentHashMap实现,零外部依赖。
- 分布式部署:通过实现
SessionManager接口,可无缝切换至Redis等分布式存储。
核心实现
会话管理接口
SessionManager接口是整个方案的核心抽象,它定义了会话生命周期的所有关键操作:
public interface SessionManager {
// 登录:生成并返回Token
String login(String username, LoginInfo loginInfo);
// 登出:使指定Token失效
void logout(String token);
// 验证:检查Token是否有效
TokenInfo validateToken(String token);
// 查询:获取用户的所有活跃Token
List<String> getUserTokens(String username);
// 管理:强制用户所有会话下线
void kickoutUser(String username);
}
Map实现方案
MapSessionManager是基于ConcurrentHashMap的线程安全实现。它使用两个Map来高效管理会话状态:
- tokenMap:维护
Token到TokenInfo对象的映射,用于实现O(1)复杂度的Token快速验证。TokenInfo包含了用户名、登录时间、客户端信息及过期时间等。
- userTokenMap:维护
用户名到其所有有效Token集合的映射。此设计使得在“单登录”模式下踢出用户旧会话时,能够快速定位并清理所有相关Token。
关键登录逻辑代码如下:
@Component
public class MapSessionManager implements SessionManager {
private final Map<String, TokenInfo> tokenMap = new ConcurrentHashMap<>();
private final Map<String, Set<String>> userTokenMap = new ConcurrentHashMap<>();
@Override
public String login(String username, LoginInfo loginInfo) {
String token = generateToken();
TokenInfo tokenInfo = new TokenInfo(token, username, loginInfo,
System.currentTimeMillis() + properties.getTokenExpireTime() * 1000);
// 单登录模式:先踢出该用户所有旧会话
if (properties.getMode() == LoginMode.SINGLE) {
kickoutUser(username);
}
// 存储新会话
tokenMap.put(token, tokenInfo);
userTokenMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(token);
return token;
}
}
拦截器设计
LoginInterceptor负责拦截并验证请求。其设计要点包括:
- 路径排除:配置白名单,避免对登录接口、静态资源等路径进行拦截。
- Token提取:支持从自定义的HTTP请求头(如
Authorization)中提取Token。
- 统一验证与上下文传递:调用
SessionManager.validateToken()进行验证,并将验证后的用户信息存入请求属性,供后续Controller使用。
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private SessionManager sessionManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = getTokenFromRequest(request);
if (token == null) {
return handleUnauthorized(response, "请先登录");
}
TokenInfo tokenInfo = sessionManager.validateToken(token);
if (tokenInfo == null) {
return handleUnauthorized(response, "登录已过期或无效");
}
// 将用户信息存入请求上下文
request.setAttribute("username", tokenInfo.getUsername());
return true;
}
}
配置管理
通过LoginProperties类集中管理所有配置项,并利用Spring Boot的@ConfigurationProperties机制,实现与应用配置文件的绑定,使策略调整极其灵活。
application.yml配置示例:
app:
login:
# 登录模式控制:SINGLE(单登录), MULTIPLE(多登录)
mode: SINGLE
# Token相关配置
token-expire-time: 1800 # 有效期(秒),默认30分钟
token-header: Authorization # 请求头名称
# 维护配置
enable-auto-clean: true # 是否启用过期Token自动清理
clean-interval: 5 # 自动清理间隔(分钟)
API接口设计与前端集成
统一的RESTful API
系统提供了一套完整的RESTful API用于认证与会话管理:
| 接口路径 |
方法 |
说明 |
参数 |
/api/auth/login |
POST |
用户登录 |
username, password |
/api/auth/logout |
POST |
用户登出 |
(Token via Header) |
/api/auth/current |
GET |
获取当前用户信息 |
(Token via Header) |
/api/auth/online |
GET |
查询所有在线用户 |
无 |
/api/auth/kickout |
POST |
强制踢出指定用户 |
username |
所有接口遵循统一的响应格式,便于前端处理:
{
"code": 200,
"message": "成功",
"data": {}
}
前端Token管理机制
前端核心在于Token的生命周期管理。一个健壮的实现通常包含:
- 智能获取:优先从
localStorage(“记住我”功能)获取,其次从sessionStorage获取。
- 灵活存储:根据用户登录时的选择,决定将Token持久化到何处。
- 请求拦截器:在每次发起API请求前,自动将Token注入到
Authorization请求头中。
- 统一错误处理:拦截HTTP 401响应,自动清除本地无效Token并跳转至登录页。
关键前端代码示例:
// 自动为请求添加Token头
async function apiRequest(url, options = {}) {
const token = getToken(); // 从storage获取
if (token) {
options.headers = {
...options.headers,
'Authorization': token
};
}
const response = await fetch(url, options);
// 统一处理认证失效
if (response.status === 401) {
clearToken();
if (!window.location.pathname.includes('login')) {
window.location.href = '/login.html';
}
}
return response.json();
}
总结与扩展
本方案最大的优势在于简洁与可扩展性。它仅依赖Spring Boot Web基础模块,无需引入繁重的安全框架,极大降低了项目的复杂性和维护成本。
通过SessionManager接口的抽象设计,实现了业务逻辑与存储逻辑的彻底解耦。当应用需要从单机部署扩展到分布式集群时,仅需提供一个基于Redis(或其他分布式缓存)的SessionManager实现即可,所有业务代码无需任何改动。这种设计完美遵循了“对扩展开放,对修改关闭”的原则。
完整的可运行示例代码已开源,您可以通过以下链接获取并快速集成到项目中:
https://github.com/yuboon/java-examples/tree/master/springboot-single-login