java实现登录:多点登录互踢,30分钟无操作超时

解决"多点登录互踢":

数据结构:我们需要一个全局的 。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分钟超时

代码实现

  1. 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);
        }
    }
}
  1. 切面类 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());
        }
    }
}
  1. 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。

相关推荐
一字白首16 小时前
Vue Router 进阶,声明式 / 编程式导航 + 重定向 + 404 + 路由模式
前端·javascript·vue.js
Three K16 小时前
Redisson限流器特点
java·开发语言
Halo_tjn16 小时前
Java 多线程机制
java·开发语言·windows·计算机
广州华水科技16 小时前
单北斗变形监测在水库安全中的应用与维护该如何实施?
前端
Jeff-Nolan16 小时前
C++运算符重载
java·开发语言·c++
她说..16 小时前
Spring AOP场景3——接口防抖(附带源码)
java·后端·spring·java-ee·springboot
计算机毕设指导616 小时前
基于微信小程序的积分制零食自选平台【源码文末联系】
java·spring boot·mysql·微信小程序·小程序·tomcat·maven
神仙别闹16 小时前
基于QT(C++)实现(图形界面)连连看
java·c++·qt