Spring Security + Redis 的无状态分布式认证架构设计
2026-06-05
传统Session开发和JWT开发的弊端
Session
在传统Web开发中,服务器通过Tomcat的 HttpSession(基于Cookie中的 JSESSIONID)维持状态。但在现代微服务与前后端分离架构下,这种方案存在天然弊端:
1.集群横向扩展困难:Session常驻单机内存,多台服务器间同步成本极高。
2.安全缺陷:基于Cookie的认证天然难以防范CSRF(跨站请求伪造)攻击。
3.为了解决有状态(Stateful)的痛点,业界普遍转向无状态(Stateless)架构。
JWT

JWT仅适用于单向、短期、无状态的边缘授权(如文件下载凭证、邮件激活码);而对于需要强管控、高并发、支持滑动续期和踢人下线的企业级核心用户系统,Redis Token 才是生产环境的终极解法。
实现思路
1.请求进入网关或Tomcat后,自定义过滤器 RedisTokenFilter 拦截请求,解析 HTTP Header 中的 Authorization 字段。
2.过滤器拿着Token去Redis集群检索。若命中,则说明该用户处于合法登录状态。随后将其包装为框架可识别的令牌,注入 SecurityContextHolder(底层基于 ThreadLocal,确保多线程并发下的线程隔离与安全)。
3.请求流向Spring Security的核心鉴权层(AuthorizationFilter)。鉴权层根据 SecurityConfig 中配置的黑白名单规则,比对当前线程上下文中是否存在令牌。核验通过则放行至 Controller,否则直接响应 403。
代码实现
SQL数据表
如果你还想做权限管理,我建议把数据库的用户表,增加一个字段用来区分权限。
SecurityConfig
可以看到,login接口是所有人哪怕未登录的游客也可以访问,但是admin接口是必须要权限为admin的用户才可以访问,hello接口的话登录了都可以访问
package com.redisauth.config;
import com.redisauth.util.RedisUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final RedisUtils redisUtils;
public SecurityConfig(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.addFilterBefore(new AuthorityFilter(redisUtils), UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
auth -> auth
.requestMatchers("/login").permitAll() // 允许未登录时访问登录接口
.requestMatchers("/admin").hasRole("admin") // 允许用户权限为admin的用户登录
.anyRequest().authenticated() // 其它所有剩余接口默认都需要登录
);
return http.build();
}
}AuthorityFilter
这个地方的逻辑是,从用户的 Authorization 请求头中获取到token,然后去redis查该token下对应的用户信息,这里储存了uid和role。
package com.redisauth.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.redisauth.util.RedisUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class AuthorityFilter extends OncePerRequestFilter {
private final RedisUtils redisUtils;
private final ObjectMapper objectMapper = new ObjectMapper();
public AuthorityFilter(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = request.getHeader(redisUtils.HEADER);
if (StringUtils.hasText(token)) {
String redisKey = redisUtils.tokenPreKey + token;
String jsonStr = redisUtils.get(redisKey);
if (StringUtils.hasText(jsonStr)) {
try {
// 1. 解析 JSON 数据
JsonNode jsonNode = objectMapper.readTree(jsonStr);
String uid = jsonNode.get("uid").asText();
String role = jsonNode.get("role").asText();
// 2. 构建框架权限集合
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
// 3. 恢复安全上下文身份
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(uid, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 4. 核心修正:滑动续期时,必须同时延长Token键和UID键的生命周期
redisUtils.expire(redisKey, 30, TimeUnit.MINUTES);
redisUtils.expire(redisUtils.uidPreKey + uid, 30, TimeUnit.MINUTES);
} catch (Exception e) {
this.logger.error("解析用户安全上下文失败", e);
}
}
}
filterChain.doFilter(request, response);
}
}TestController
在基于Redis的单端登录架构中,双向绑定是通过TokenKey和UidKey两条相互映射的记录来绝对控制用户状态的。
当用户成功登录时,后端首先通过RedisAuth:uid:[uid]查出该用户上一次登录的旧Token,如果存在,就直接删除对应的RedisAuth:token:[旧Token],让旧设备直接失效下线,随后生成一个全新Token,写入RedisAuth:token:[新Token]存储用户ID和角色JSON,并更新RedisAuth:uid:[uid]指向这个新Token,完成登录时的剔除与绑定。
当请求携带Token访问时,过滤器解析Token拿到uid后,必须去Redis查一次RedisAuth:uid:[uid],比对当前请求的Token是否等于Redis中记录的最新Token。 如果两者完全一致,说明是最后登录的合法设备,过滤器放行请求,并同时为token:键和uid:键延长30分钟有效期完成双向续期;反之如果不一致,则说明此账号已被新设备顶替,当前Token是过期的僵尸Token,过滤器直接拒绝认证,并顺手删除该残余token:键以释放内存。
package com.redisauth.controller;
import com.redisauth.entity.SUsers;
import com.redisauth.mapper.SUsersMapper;
import com.redisauth.util.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
public class TestController {
@Autowired
private SUsersMapper sUsersMapper;
@Autowired
private RedisUtils redisUtils;
@PostMapping("/login")
public String login(@RequestParam("uid") int uid) {
SUsers sUsers = sUsersMapper.selectByPrimaryKey(uid);
if (sUsers == null) {
return "login failed";
}
String uidKey = redisUtils.uidPreKey + sUsers.getUid();
String newToken = UUID.randomUUID().toString();
String tokenKey = redisUtils.tokenPreKey + newToken;
// 1. 检查该用户此前是否在其他地方登录过
String oldToken = redisUtils.get(uidKey);
if (StringUtils.hasText(oldToken)) {
// 2. 存在旧 Token,直接从 Redis 中无情抹除(旧设备立刻死掉)
redisUtils.delete(redisUtils.tokenPreKey + oldToken);
}
// 3. 生成新 Token
String jsonValue = String.format("{\"uid\":%d,\"role\":\"%s\"}", sUsers.getUid(), sUsers.getRole());
// 4. 双向绑定写入 Redis
redisUtils.set(tokenKey, jsonValue, 30, TimeUnit.MINUTES);
redisUtils.set(uidKey, newToken, 30, TimeUnit.MINUTES);
return newToken;
}
@GetMapping("/hello")
public String hello() {
return "hello";
}
@GetMapping("admin")
public String admin() {
return "admin";
}
}
发表评论: