在很多业务场景中,我们需要控制单个账号的登录数量。本文介绍一种轻量级实现方案,灵活支持单账号单登录和多登录两种模式。
付费系统场景:某在线教育平台销售VIP会员账号,如果不限制登录数量,一个账号可能被多个用户共享,导致收入损失。平台需要确保一个付费账号只能有一个用户同时在线。
企业办公系统:为了保证数据安全,企业OA系统通常要求员工账号只能在指定设备上登录。当员工在新设备登录时,需要自动踢出其他设备上的会话,防止账号被滥用。
传统方案的痛点
传统的账号登录控制方案存在各种问题:
Session方案的局限:使用HttpSession实现虽然简单,但在分布式环境下会面临Session共享的难题。引入Spring Session虽然可以解决问题,但增加了系统的复杂度和依赖,对于简单的需求来说有些"杀鸡用牛刀"。
Spring Security的复杂性:Spring Security提供了强大的安全框架,但对于仅仅需要登录控制的需求来说,学习成本过高,配置复杂,而且引入了很多不必要的功能。
数据库存储的性能问题:将登录状态存储在数据库中会导致每次请求都需要查询数据库,在高并发场景下会成为性能瓶颈。虽然可以通过缓存优化,但又增加了系统复杂度。
前端状态管理的麻烦:传统的页面跳转方式已经不适应现代前端框架的需求。前后端分离架构需要统一的API接口和状态管理机制。
二、方案概述
2.1 核心思路
本文提出的方案基于以下几个核心理念:
Token认证:使用自定义Token替代传统的Session机制。Token是无状态的,可以在分布式环境下轻松扩展,同时避免了Session共享的复杂性问题。
拦截器验证:通过Spring MVC的拦截器机制统一处理登录验证。这种方式的优点是不需要修改业务代码,通过配置即可实现对所有请求的拦截。
接口抽象:定义清晰的SessionManager接口,将存储逻辑与业务逻辑分离。这样既可以使用Map实现,也可以轻松切换到Redis,为系统扩展提供可能。
模式切换:通过简单的配置项即可在"单登录"和"多登录"模式之间切换,满足不同业务场景的需求。
2.2 技术选型
核心依赖
仅使用Spring Boot Web Starter,不引入任何额外的安全框架或中间件。
存储方案
单机部署:使用ConcurrentHashMap 分布式部署:通过接口抽象无缝切换到Redis
三、核心实现
3.1 会话管理接口
SessionManager接口是整个方案的核心,它定义了会话管理的所有基本操作:
java
public interface SessionManager {
// 登录:生成并返回Token
String login(String username, LoginInfo loginInfo);
// 登出:使指定Token失效
void logout(String token);
// 验证:检查Token是否有效
TokenInfo validateToken(String token);
// 查询:获取用户的所有活跃Token
List getUserTokens(String username);
// 管理:强制用户下线
void kickoutUser(String username);
}
3.2 Map实现方案
MapSessionManager是基于ConcurrentHashMap的具体实现,它使用两个Map来管理会话状态:
tokenMap:存储Token到TokenInfo的映射,用于快速验证Token的有效性。TokenInfo包含用户名、登录时间、过期时间等关键信息。
userTokenMap:存储用户名到Token集合的映射,用于管理用户的所有会话。这样设计的好处是,当需要踢出用户时,可以快速找到该用户的所有Token并使其失效。
java
@Component
public class MapSessionManager implements SessionManager {
// Token -> TokenInfo 的映射,用于验证
private final Map tokenMap = new ConcurrentHashMap<>();
// Username -> Token集合的映射,用于管理
private final Map> 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);
}
// 存储新Token
tokenMap.put(token, tokenInfo);
userTokenMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet())
.add(token);
return token;
}
}
3.3 拦截器设计
LoginInterceptor负责拦截所有需要认证的请求,验证Token的有效性。它的设计要点包括:
路径排除:静态资源、登录接口等不需要认证的路径需要被排除。
Token提取:从HTTP请求头中提取Token,支持自定义Header名称。
验证逻辑:调用SessionManager验证Token,将验证结果存入请求属性。
java
@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;
}
}
3.4 配置管理
LoginProperties集中管理所有可配置的参数,通过Spring Boot的配置机制,可以在application.yml中灵活调整:
yaml
app:
login:
# 登录模式控制
mode: MULTIPLE # SINGLE-单登录, MULTIPLE-多登录
# Token相关配置
token-expire-time: 1800 # 有效期30分钟
token-header: Authorization # 请求头名称
token-prefix: TOKEN_ # Token前缀
# 维护配置
enable-auto-clean: true # 启用自动清理
clean-interval: 5 # 清理间隔(分钟)
四、API接口设计
4.1 接口定义
系统提供了一套RESTful API接口,支持完整的用户会话管理功能:
| 接口路径 | 请求方法 | 功能说明 | 请求参数 | 响应数据 |
|---|---|---|---|---|
| /api/auth/login | POST | 用户登录 | username, password | Token信息 |
| /api/auth/logout | POST | 用户退出 | 无 | 操作结果 |
| /api/auth/current | GET | 当前用户 | 无 | 用户信息 |
| /api/auth/online | GET | 在线列表 | 无 | 用户名列表 |
| /api/auth/tokens | GET | 用户Tokens | username | Token列表 |
| /api/auth/kickout | POST | 踢出用户 | username | 操作结果 |
4.2 统一响应格式
所有API接口都遵循统一的响应格式,便于前端处理:
json
{
"code": 200, // 状态码
"message": "成功", // 提示信息
"data": {} // 业务数据
}
状态码规范:
- 200:请求成功
- 401:未认证或Token失效
- 403:权限不足
- 500:服务器内部错误
4.3 登录认证流程
完整的登录流程包括以下几个步骤:
1. 用户提交凭据
javascript
POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
2. 服务端验证并生成Token 服务端验证用户名密码后,生成一个唯一的Token,包含:
- Token字符串(UUID)
- 用户信息
- 登录时间
- 过期时间
3. 返回Token给客户端
json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "TOKEN_550e8400-e29b-41d4-a716-446655440000",
"username": "admin",
"expireTime": 1704067200000,
"loginMode": "MULTIPLE"
}
}
4. 客户端存储Token 根据用户选择,Token可以存储在localStorage(长期有效)或sessionStorage(会话级别)。
5. 后续请求携带Token
javascript
GET /api/auth/current
Authorization: TOKEN_550e8400-e29b-41d4-a716-446655440000
6. 服务端验证Token 拦截器自动验证Token的有效性,包括:
- Token是否存在
- Token是否过期
- Token是否被踢出
4.4 错误处理机制
当认证失败时,系统会返回明确的错误信息:
json
{
"code": 401,
"message": "Token已过期,请重新登录",
"data": null
}
前端收到401响应后,会自动清除本地存储的Token,并跳转到登录页面。
4.5 管理功能接口
管理员可以通过专门的接口管理系统用户:
获取在线用户:返回当前所有在线用户的用户名列表,可用于监控和统计。
踢出指定用户:强制指定用户下线,清除其所有Token,常用于处理异常情况。
查看用户会话:获取指定用户的所有活跃Token,了解用户的登录状态。
五、前端实现
5.1 核心实现
api.js:负责所有API请求的封装,包括Token管理、请求拦截、错误处理等。
页面模块:login.html、index.html、admin.html各自负责独立的功能,通过import导入api模块。
状态管理:使用localStorage和sessionStorage管理Token状态,支持"记住我"功能。
5.2 Token管理机制
Token管理是前端的核心功能,实现了完整的生命周期管理:
javascript
// Token获取策略
export function getToken() {
// 优先从localStorage获取(长期有效)
return localStorage.getItem('token')
|| sessionStorage.getItem('token'); // 再从sessionStorage获取
}
// Token存储策略
export function setToken(token, remember) {
if (remember) {
localStorage.setItem('token', token); // 用户选择"记住我"
} else {
sessionStorage.setItem('token', token); // 仅本次会话有效
}
}
这种设计的优势是给了用户选择权:隐私敏感的场景可以使用sessionStorage,确保关闭浏览器后自动清除;个人设备上可以使用localStorage,提供更好的用户体验。
5.3 请求拦截器
前端实现了一个轻量级的请求拦截器,自动处理认证相关的逻辑:
javascript
async function apiRequest(url, options = {}) {
// 自动添加Token
const token = getToken();
if (token) {
options.headers = {
...options.headers,
'Authorization': token
};
}
const response = await fetch(url, options);
// 统一处理401响应
if (response.status === 401) {
clearToken();
// 避免在登录页重复跳转
if (location.pathname !== '/login.html') {
location.href = '/login.html';
}
}
return response.json();
}
这个拦截器实现了:
- 自动携带Token,减少重复代码
- 统一的401处理,确保用户在Token失效时能够及时重新登录
六、总结
整个方案仅依赖Spring Boot Web,不需要引入任何额外的安全框架、缓存中间件或数据库。这使得项目的依赖保持最小化。
通过SessionManager接口抽象,实现了业务逻辑与存储逻辑的完全分离。当需要从单机扩展到分布式时,只需要实现一个基于Redis的SessionManager即可,业务代码无需任何修改。
ruby
https://github.com/yuboon/java-examples/tree/master/springboot-single-login