Redis+JWT的安全保障以及redis令牌桶(登录或者注册时的措施)
登录校验的时候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分钟如果符合的话就给令牌加相应速率计算出来的令牌的数量
- 按时间差补充:tokensToAdd = elapsed × rate
- 不超过容量:tokens = min(capacity, tokens + tokensToAdd)
- 向下取整:math.floor() 确保令牌数为整数
,并且把现在的时间戳赋值给 lastRefillTime,然后进行判断如果令牌桶里面令牌的数量不为0则减一返回true否则返回fasle表式被限流,直接给前端返回fasle表示访问失败。
失败的三种情况:



其实根本就是补充的不及时。
更多推荐

所有评论(0)