登录校验的时候jwt好是好,但是有一个缺点就是不能够单点登录,意思就是你的token只能过期删除,所以没办法实现退出和使token失效保持同时。

因为:

  • JWT 是无状态的
  • 服务器不保存任何 token 信息
  • 服务器无法主动让某个 JWT 失效
  • JWT 只能等到它自己的 exp 过期

来举一个最直观的例子:

你的银行账号只能在一个地方登录,这就是单点登录,也就是说只允许有一个有效token。

所以我们这次引用了redis来确保可以进行单点登录,并且还是双重防护。

下面直接来看代码:

 @PostMapping("/login")
    @RateLimiter(key = "login:#{#request.username}", capacity = 10, rate = 5, message = "登录请求过于频繁,请稍后再试")
    public BaseResponse<UserResponse> login(@Valid @RequestBody LoginRequest request) {
        try {
            // 登录验证
            User user = userService.login(request.getUsername(), request.getPassword());
            
            // 生成 JWT Token
            String token = jwtUtil.generateToken(user.getId(), user.getUsername());
            
            // 如果用户之前登录过,删除旧的 token(实现单点登录:新登录会踢掉旧登录)
            String userTokenKey = "user_token:" + user.getId();
            String oldToken = (String) redissonUtil.get(userTokenKey);
            if (oldToken != null) {
                // 删除旧的 token,使旧 token 立即失效(即使还没过期)
                String oldTokenKey = "token:" + oldToken;
                redissonUtil.delete(oldTokenKey);
            }
            
            // 将新的 Token 存储到 Redis,用于撤销和登出功能
            // token:jwt_token -> userId,过期时间7天
            String tokenKey = "token:" + token;
            redissonUtil.set(tokenKey, user.getId(), 7, TimeUnit.DAYS);
            
            // 存储用户ID到Token的映射,用于下次登录时删除旧token
            redissonUtil.set(userTokenKey, token, 7, TimeUnit.DAYS);
            
            // 返回用户信息和 JWT Token
            return BaseResponse.success("登录成功", UserResponse.fromUser(user, token));
        } catch (BusinessException e) {
            return BaseResponse.fail(ResponseCode.BUSINESS_ERROR.getCode(), e.getMessage());
        } catch (Exception e) {
            log.error("登录失败", e);
            return BaseResponse.fail(ResponseCode.INTERNAL_ERROR);
        }
    }
    
    /**
     * 用户登出
     * 
     * @param request HTTP请求
     * @return 统一响应
     */
    @PostMapping("/logout")
    @RequireLogin
    public BaseResponse<Void> logout(HttpServletRequest request) {
        try {
            String token = (String) request.getAttribute("token");
            Long userId = (Long) request.getAttribute("userId");
            
            if (token != null) {
                // 删除 Redis 中的 token,使 token 失效
                String tokenKey = "token:" + token;
                redissonUtil.delete(tokenKey);
            }
            
            if (userId != null) {
                // 删除用户ID到Token的映射
                String userTokenKey = "user_token:" + userId;
                redissonUtil.delete(userTokenKey);
            }
            
            return BaseResponse.success("登出成功", null);
        } catch (Exception e) {
            log.error("登出失败", e);
            return BaseResponse.fail(ResponseCode.INTERNAL_ERROR);
        }
    }
    

登录是直接删除旧的key,退出是直接删除key。

key为

String userTokenKey = "user_token:" + user.getId();

登录登出和拦截器校验都是对这个key进行的操作,即用了单key又实现了单点操作

下面来看拦截器:

package com.code.codeplus.interceptor;

import com.code.codeplus.config.AuthConfig;
import com.code.codeplus.exception.BusinessException;
import com.code.codeplus.util.JwtUtil;
import com.code.codeplus.util.RedissonUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * JWT 拦截器
 * 验证请求中的 JWT Token
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
    
    private final JwtUtil jwtUtil;
    private final RedissonUtil redissonUtil;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 如果不是方法处理器,直接放行
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        
        // 获取请求路径
        String requestPath = request.getRequestURI();
        
        // 方案1:白名单机制(推荐)- 默认需要校验 Token,只有白名单中的不需要
        // 检查路径是否在白名单中(不需要校验 Token)
        if (!AuthConfig.requiresAuth(requestPath)) {
            // 在白名单中,不需要校验 Token,直接放行
            return true;
        }
        
        // 方案2:注解机制(兼容保留)- 如果方法有 @RequireLogin 注解,强制需要校验 Token
        // 当前使用白名单机制,默认需要校验 Token
        // 如果将来需要改为只有注解才需要校验,可以在这里判断:
        // RequireLogin requireLogin = handlerMethod.getMethodAnnotation(RequireLogin.class);
        // if (requireLogin == null) return true;
        
        // 需要登录,验证 Token
        String token = extractToken(request);
        if (token == null || token.isEmpty()) {
            throw new BusinessException("未登录,请先登录");
        }
        
        // 验证 JWT Token 签名和过期时间
        if (!jwtUtil.validateToken(token)) {
            throw new BusinessException("Token 无效或已过期");
        }
        
        // 从 JWT 中获取用户ID
        Long userId = jwtUtil.getUserIdFromToken(token);
        if (userId == null) {
            throw new BusinessException("Token 中无法获取用户信息");
        }
        
        // 检查 Token 是否是最新的(同一个key,实现单点登录)
        // 从 Redis 中获取该用户当前有效的 token
        String userTokenKey = "user_token:" + userId;
        String currentValidToken = (String) redissonUtil.get(userTokenKey);
        
        // 如果 user_token:userId 不存在,说明已登出或未登录
        if (currentValidToken == null) {
            throw new BusinessException("Token 已失效,请重新登录");
        }
        
        // 如果请求的 token 和 Redis 中存储的 token 不一致,说明是旧 token(已被新登录覆盖)
        if (!currentValidToken.equals(token)) {
            throw new BusinessException("Token 已失效,请重新登录");
        }
        
        // 将用户信息存储到 request 中,方便后续使用
        String username = jwtUtil.getUsernameFromToken(token);
        request.setAttribute("userId", userId);
        request.setAttribute("username", username);
        request.setAttribute("token", token);
        
        return true;
    }
    
    /**
     * 从请求中提取 Token
     * 支持从 Header 的 Authorization 字段提取(Bearer token)
     */
    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        // 也支持从请求参数中获取(用于某些特殊场景)
        String token = request.getParameter("token");
        return token;
    }
}

因为假如说你第一次登录存入了一个key和vaule,然后你第二次登录就把原来的那个key和value给删除了存入的是新的key和value虽然key是不变的但是value是跟之前不一样的,然后拦截器再去校验如果你携带的是原来的那个token也就是原来的那个key的value是无法通过的,因为你已经给删除了,现在的就是新的只有新的token的value可以过校验。

第一次登录:
存储:user_token:1 -> token1

第二次登录(没有先登出):
存储:user_token:1 -> token2(新 token 覆盖旧 token)✅

验证:
- 请求 token1:user_token:1 = token2,不一致 ❌ → 拒绝
- 请求 token2:user_token:1 = token2,一致 ✅ → 通过

结果:
- token1 失效 ✅(因为 user_token:1 中的值已经不是 token1 了)
- token2 有效 ✅
- 实现单点登录 ✅

下面来看redis令牌桶:

令牌桶一般就是针对单个用户来说的,防止单个用户恶意一直重复登录。

是通过lua脚本来实现的,以及aop切面。先来看Aop:

package com.code.codeplus.aspect;

import com.code.codeplus.annotation.RateLimiter;
import com.code.codeplus.exception.BusinessException;
import com.code.codeplus.service.RateLimiterService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
 * 限流切面
 */
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimiterAspect {
    
    private final RateLimiterService rateLimiterService;
    private final ExpressionParser parser = new SpelExpressionParser();
    private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
    
    @Pointcut("@annotation(com.code.codeplus.annotation.RateLimiter)")
    public void rateLimiterPointcut() {
    }
    
    @Around("rateLimiterPointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RateLimiter rateLimiter = method.getAnnotation(RateLimiter.class);
        
        // 生成限流key
        String key = generateKey(rateLimiter.key(), joinPoint, method);
        
        // 执行限流检查
        boolean acquired = rateLimiterService.tryAcquire(
            key,
            rateLimiter.capacity(),
            rateLimiter.rate()
        );
        
        if (!acquired) {
            // 限流拒绝
            throw new BusinessException(rateLimiter.message());
        }
        
        // 限流通过,继续执行原方法
        return joinPoint.proceed();
    }
    
    /**
     * 生成限流key
     * 支持SpEL表达式,如:user:#{#userId}
     */
    private String generateKey(String keyExpression, ProceedingJoinPoint joinPoint, Method method) {
        if (keyExpression == null || keyExpression.trim().isEmpty()) {
            // 如果没有指定key,使用 类名:方法名
            return method.getDeclaringClass().getName() + ":" + method.getName();
        }
        
        // 解析SpEL表达式
        try {
            Expression expression = parser.parseExpression(keyExpression);
            EvaluationContext context = new StandardEvaluationContext();
            
            // 获取方法参数名和值
            // args: 参数值数组,如 [RegisterRequest对象]
            Object[] args = joinPoint.getArgs();
            // paramNames: 参数名数组,如 ["request"]
            // 注意:如果编译时没有 -parameters 参数,可能返回 ["arg0", "arg1"] 等
            String[] paramNames = nameDiscoverer.getParameterNames(method);
            
            // 将参数名和参数值绑定到 SpEL 上下文
            // 这样 SpEL 表达式 #{#request.email} 才能正确解析
            // request 是参数名,email 是 RegisterRequest 的属性
            if (paramNames != null) {
                for (int i = 0; i < paramNames.length; i++) {
                    context.setVariable(paramNames[i], args[i]);
                }
            }
            
            Object value = expression.getValue(context);
            return value != null ? value.toString() : keyExpression;
        } catch (Exception e) {
            log.warn("SpEL表达式解析失败,使用原表达式: {}", keyExpression, e);
            return keyExpression;
        }
    }
}

        

@annotation(com.code.codeplus.annotation.RateLimiter)意思是在加了@RateLimiter注解的方法执行前进行拦截执行先aop的方法在执行原来的方法。

这串代码就是先解析出参数的数据这个参数是request就是前端传过来的request因为controller那个登录接口接收到的急速request,

capacity为令牌桶的最大容量rate为速度就是每秒生成几个令牌。
/ 执行限流检查
boolean acquired = rateLimiterService.tryAcquire(
    key,
    rateLimiter.capacity(),
    rateLimiter.rate()
);

这一步就是最关键的限流操作,这里是用redissionutil实现的。

package com.code.codeplus.service;

import com.code.codeplus.util.RedissonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RScript;
import org.springframework.stereotype.Service;

import java.util.Collections;

/**
 * 令牌桶限流服务(基于Lua脚本实现真正的令牌桶算法)
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class RateLimiterService {
    
    private final RedissonUtil redissonUtil;
    
    /**
     * 令牌桶限流 Lua 脚本
     * 
     * Redis Key 结构:
     * - {key}:tokens -> 当前令牌数
     * - {key}:lastRefillTime -> 上次补充令牌的时间戳(秒)
     * 
     * 算法逻辑:
     * 1. 获取当前令牌数和上次补充时间
     * 2. 计算距离上次补充时间过了多少秒
     * 3. 根据速率补充令牌(但不能超过容量)
     * 4. 如果令牌数 >= 1,则减1并返回成功;否则返回失败
     */
    private static final String TOKEN_BUCKET_SCRIPT = 
        "local tokensKey = KEYS[1] .. ':tokens'\n" +
        "local timeKey = KEYS[1] .. ':lastRefillTime'\n" +
        "local capacity = tonumber(ARGV[1])\n" +
        "local rate = tonumber(ARGV[2])\n" +
        "local now = tonumber(ARGV[3])\n" +
        "\n" +
        "-- 获取当前令牌数和上次补充时间\n" +
        "local tokens = tonumber(redis.call('get', tokensKey) or capacity)\n" +
                //获取上次的补充时间,如果没有就赋值为now,下一次在执行lua脚本这个时间就是上次补充令牌的时间
        "local lastRefillTime = tonumber(redis.call('get', timeKey) or now)\n" +
        "\n" +
        "-- 计算时间差(秒)\n" +
        "local elapsed = now - lastRefillTime\n" +
        "\n" +
        "-- 根据速率补充令牌(每秒生成 rate 个令牌)\n" +
        "if elapsed > 0 then\n" +
        "    local tokensToAdd = math.floor(elapsed * rate)\n" +
        "    if tokensToAdd > 0 then\n" +
        "        tokens = math.min(capacity, tokens + tokensToAdd)\n" +
        "    end\n" +
        "elseif elapsed < 0 then\n" +
        "    -- 时间倒退(服务器时间不同步),重置为当前时间\n" +
        "    lastRefillTime = now\n" +
        "end\n" +
        "\n" +
        "-- 尝试获取1个令牌\n" +
        "if tokens >= 1 then\n" +
        "    tokens = tokens - 1\n" +
        "    redis.call('set', tokensKey, tokens)\n" +
        "    redis.call('set', timeKey, now)\n" +
        "    return 1  -- 成功\n" +
        "else\n" +
        "    redis.call('set', tokensKey, tokens)\n" +
        "    redis.call('set', timeKey, now)\n" +
        "    return 0  -- 失败(令牌不足)\n" +
        "end";
    
    /**
     * 尝试获取令牌(真正的令牌桶算法)
     * 
     * @param key 限流key
     * @param capacity 令牌桶容量(最大令牌数)
     * @param rate 令牌生成速率(每秒生成多少个令牌)
     * @return true-获取成功,false-获取失败(被限流)
     */
    public boolean tryAcquire(String key, int capacity, int rate) {
        try {
            String fullKey = "rate_limiter:" + key;
            long now = System.currentTimeMillis() / 1000; // 当前时间戳(秒)
            
            // 执行 Lua 脚本(原子操作)
            RScript script = redissonUtil.getScript();
            Long result = script.eval(
                RScript.Mode.READ_WRITE,
                TOKEN_BUCKET_SCRIPT,
                RScript.ReturnType.INTEGER,
                Collections.singletonList(fullKey),
                capacity, rate, now
            );
            
            boolean acquired = result != null && result == 1;
            
            // 限流检查完成
            
            return acquired;
        } catch (Exception e) {
            log.error("限流检查失败: key={}", key, e);
            // 发生异常时,为了不影响业务,默认允许通过
            return true;
        }
    }
}
        

为了保持原子性这里使用了lua脚本,进来先判断离上一次补充令牌的时间是不是大于等于1分钟如果符合的话就给令牌加相应速率计算出来的令牌的数量

  1. 按时间差补充:tokensToAdd = elapsed × rate
  1. 不超过容量:tokens = min(capacity, tokens + tokensToAdd)
  1. 向下取整:math.floor() 确保令牌数为整数

,并且把现在的时间戳赋值给 lastRefillTime,然后进行判断如果令牌桶里面令牌的数量不为0则减一返回true否则返回fasle表式被限流,直接给前端返回fasle表示访问失败。

失败的三种情况:

其实根本就是补充的不及时。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐