解决"多点登录互踢":
数据结构:我们需要一个全局的 。Map<UserId, Token>
逻辑:也就是"一个用户 ID 只能对应一个 Token"。当用户再次登录时,生成新 Token,覆盖 Map 中旧的 Token。
验证:旧设备拿着旧 Token 来访问时,发现 Map 里的 Token 已经变了,于是抛出"您的账号在异地登录,请重新登录"的异常。
解决"30分钟无操作超时":
数据结构:Token 对象中存储 (最后访问时间)。lastAccessTime
逻辑:每次 AOP 拦截到请求,检查 是否超过 30 分钟。当前时间 - lastAccessTime
刷新:如果未超时, 为当前时间(续命)。更新lastAccessTime
我们可以通过维护 来分别处理、和。3 个 ConcurrentHashMap身份索引互踢逻辑超时控制
核心设计思路:三表联动
java
tokenToUserMap (Map<String, String>):
Key: Token
Value: UserId
作用:根据请求头的 Token 快速找到是谁。
java
userToCurrentTokenMap (Map<String, String>):
Key: UserId
Value: Token
作用:实现。存的是该用户"当前唯一有效"的 Token。互踢
java
tokenTimestampMap (Map<String, Long>):
Key: Token
Value: 时间戳 (Lo
作用:实现。如果不封装对象,就单独开一个 Map 存时间。30分钟超时
代码实现
- SessionManager (纯 Map 实现)
这里是逻辑的核心。注意看 方法中的互踢逻辑,和 方法中的时间比对。loginvalidate
Java
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SessionManager {
// 30分钟超时 (毫秒)
private static final long TIMEOUT_MS = 30 * 60 * 1000;
// Map 1: Token -> UserId (用于身份识别)
private final Map<String, String> tokenToUserMap = new ConcurrentHashMap<>();
// Map 2: UserId -> Token (用于互踢,保证一个用户只有一个Token)
private final Map<String, String> userToCurrentTokenMap = new ConcurrentHashMap<>();
// Map 3: Token -> LastAccessTime (用于超时控制)
private final Map<String, Long> tokenTimestampMap = new ConcurrentHashMap<>();
/**
* 用户登录,生成 Token
*/
public String login(String userId) {
// 1. 检查该用户是否已经登录(互踢逻辑)
String oldToken = userToCurrentTokenMap.get(userId);
if (oldToken != null) {
// 如果存在旧 Token,直接从索引表和时间表中移除
// 注意:不要移除 userToCurrentTokenMap 里的,因为马上要覆盖
tokenToUserMap.remove(oldToken);
tokenTimestampMap.remove(oldToken);
System.out.println("用户 " + userId + " 重新登录,旧 Token " + oldToken + " 已失效");
}
// 2. 生成新 Token
String newToken = UUID.randomUUID().toString().replace("-", "");
long now = System.currentTimeMillis();
// 3. 存入三个 Map
tokenToUserMap.put(newToken, userId);
userToCurrentTokenMap.put(userId, newToken);
tokenTimestampMap.put(newToken, now);
return newToken;
}
/**
* 验证 Token (AOP 调用)
*/
public void validate(String token) throws Exception {
// 1. 基础非空校验
if (token == null || token.trim().isEmpty()) {
throw new Exception("未携带 Token,请登录");
}
// 2. 校验 Token 是否存在 (如果被踢掉,这里会查不到)
if (!tokenToUserMap.containsKey(token)) {
throw new Exception("Token 无效或账号已在异地登录"); // 满足整改点:禁止多点登录
}
// 3. 校验超时 (如果为了极致性能,这一步可以先做)
Long lastAccessTime = tokenTimestampMap.get(token);
long now = System.currentTimeMillis();
if (lastAccessTime == null || (now - lastAccessTime) > TIMEOUT_MS) {
// 超时了,执行清理
logout(token);
throw new Exception("会话已过期(30分钟无操作),请重新登录"); // 满足整改点:超时终止
}
// 4. 验证通过,续期 (更新时间戳)
tokenTimestampMap.put(token, now);
}
/**
* 登出 / 清理数据
*/
public void logout(String token) {
if (token != null && tokenToUserMap.containsKey(token)) {
String userId = tokenToUserMap.get(token);
// 三表清理
tokenToUserMap.remove(token);
tokenTimestampMap.remove(token);
// 清理 userId 映射时,要防止误删(万一用户刚才又用新设备登录了呢?)
// 只有当映射表里的 Token 等于当前要注销的 Token 时才删
userToCurrentTokenMap.remove(userId, token);
}
}
}
- 切面类 AuthAspect (基本不变)
切面逻辑保持清爽,它只负责拦截,具体的脏活累活交给 SessionManager。
Java
package com.yige.security;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
@Aspect
@Component
public class AuthAspect {
@Autowired
private SessionManager sessionManager;
// 拦截所有 controller,排除 login 接口
@Pointcut("execution(public * com.yige.controller..*.*(..)) " +
"&& !execution(public * com.yige.controller..*.login(..))")
public void verify() {}
@Around("verify()")
public Object doInterceptor(ProceedingJoinPoint point) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return point.proceed();
HttpServletRequest request = attributes.getRequest();
// 约定前端将 Token 放在 Header 的 "Authorization" 字段
String token = request.getHeader("Authorization");
try {
// 验证逻辑
sessionManager.validate(token);
// 验证通过,放行
return point.proceed();
} catch (Exception e) {
// 抛出异常,由全局异常处理器捕获返回 JSON
throw new RuntimeException("鉴权失败: " + e.getMessage());
}
}
}
- Controller 调用示例 (登录接口)
需要一个登录接口来触发 sessionManager.login()。
Java
package com.yige.controller;
import com.yige.security.SessionManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class AuthController {
@Autowired
private SessionManager sessionManager;
@PostMapping("/login")
public Map<String, Object> login(@RequestBody Map<String, String> params) {
String username = params.get("username");
String password = params.get("password");
// 1. 模拟查数据库验证密码
if ("admin".equals(username) && "123456".equals(password)) {
// 2. 验证通过,调用 Manager 生成 Token 并记录
String token = sessionManager.login(username);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "登录成功");
result.put("token", token); // 前端拿到这个 Token 存起来
return result;
} else {
throw new RuntimeException("用户名或密码错误");
}
}
// 测试接口:必须带 Token 才能访问
@GetMapping("/test")
public String test() {
return "验证通过,您可以访问核心数据!";
}
}
互踢原理:
利用 userToCurrentTokenMap。
用户A第一次登录:Map存 UserA -> Token1。
用户A第二次登录:Map更新 UserA -> Token2。
此时如果用 Token1 访问,去 tokenToUserMap 查到 UserA,再反查 userToCurrentTokenMap 发现是 Token2。
因为 Token1 != Token2(或者直接在登录时就把 Token1 从 tokenToUserMap 删了),所以拒绝请求。
超时原理:
利用 tokenTimestampMap。
每次请求进来,System.currentTimeMillis() - value。
如果不超时,put(key, newTime) 覆盖旧值。
注意:由于使用的是内存 Map,如果你的服务重启,所有用户都需要重新登录(这对安全来说是可以接受的)。如果是多实例部署,依然建议后续替换为 Redis,逻辑完全一样,只是把 map.put 换成 redis.set。