一个欲儿的博客

一个欲儿的博客

Spring Security + Redis 的无状态分布式认证架构设计
2026-06-05

传统Session和JWT的弊端

Session

在传统Web开发中,服务器通过Tomcat的 HttpSession(基于Cookie中的 JSESSIONID)维持状态。但在现代微服务与前后端分离架构下,这种方案存在天然弊端:

1.集群横向扩展困难:Session常驻单机内存,多台服务器间同步成本极高。

2.安全缺陷:基于Cookie的认证天然难以防范CSRF(跨站请求伪造)攻击。

3.为了解决有状态(Stateful)的痛点,业界普遍转向无状态(Stateless)架构。

JWT

image.png

JWT仅适用于单向、短期、无状态的边缘授权(如文件下载凭证、邮件激活码);而对于需要强管控、高并发、支持滑动续期和踢人下线的企业级核心用户系统,Redis Token 才是生产环境的终极解法。

实现思路

1.请求进入网关或Tomcat后,自定义过滤器 RedisTokenFilter 拦截请求,解析 HTTP Header 中的 Authorization 字段。

2.过滤器拿着Token去Redis集群检索。若命中,则说明该用户处于合法登录状态。随后将其包装为框架可识别的令牌,注入 SecurityContextHolder(底层基于 ThreadLocal,确保多线程并发下的线程隔离与安全)。

3.请求流向Spring Security的核心鉴权层(AuthorizationFilter)。鉴权层根据 SecurityConfig 中配置的黑白名单规则,比对当前线程上下文中是否存在令牌。核验通过则放行至 Controller,否则直接响应 403。

代码实现

SQL数据表

如果你还想做权限管理,我建议把数据库的用户表,增加一个字段用来区分权限。

-- phpMyAdmin SQL Dump
-- version 5.2.3
-- 主机: localhost
-- 服务器版本: 5.7.44-log
-- PHP 版本: 8.2.28

SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";


/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;

--
-- 数据库: `auth_study`
--

-- --------------------------------------------------------

--
-- 表的结构 `s_users`
--

CREATE TABLE `s_users` (
  `uid` int(11) NOT NULL,
  `role` varchar(11) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

--
-- 转存表中的数据 `s_users`
--

INSERT INTO `s_users` (`uid`, `role`) VALUES
(1, 'user'),
(2, 'admin');

--
-- 转储表的索引
--

--
-- 表的索引 `s_users`
--
ALTER TABLE `s_users`
  ADD PRIMARY KEY (`uid`);
COMMIT;

/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

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

当客户端携带该Token请求受保护的业务接口时,请求首先被AuthorityFilter安全过滤器拦截,过滤器从请求头提取出Token并拼接成对应的TokenKey去Redis中检索JSON字符串,若未命中则直接放行,由后续的Spring Security鉴权拦截器因上下文为空而统一响应403拒绝访问。 如果成功获取到JSON字符串,过滤器会将其反序列化解析出用户ID和角色,随后为了防御僵尸Token偷跑,过滤器利用解析出的用户ID实时去Redis中读取最新的UidKey,并将当前请求携带的Token与Redis中记录的最新活跃Token进行字符串一致性比对。 若比对完全一致,证明当前请求来自最后登录的合法设备,过滤器便为该角色组装框架认账的权限列表并注入当前请求的线程上下文中恢复合法身份,同时同步为当前的TokenKey和UidKey执行expire操作完成生命周期对齐的滑动续期;反之若比对不一致,说明该Token已被新设备顶替,过滤器则直接拒绝恢复其身份并顺手将该残余的TokenKey从Redis中连根拔除以释放内存,最后调用过滤链放行方法将请求安全传递给后续的路由鉴权关卡。

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();

                    // ===== 漏掉的单端核心校验开始 =====
                    String uidKey = redisUtils.uidPreKey + uid;
                    String currentActiveToken = redisUtils.get(uidKey);

                    if (token.equals(currentActiveToken)) {
                        // 只有携带的 Token 确实是当前 Redis 记录的最活跃 Token 时,才放行并续期
                        List<GrantedAuthority> authorities = new ArrayList<>();
                        authorities.add(new SimpleGrantedAuthority("ROLE_" + role));

                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(uid, null, authorities);
                        SecurityContextHolder.getContext().setAuthentication(authentication);

                        // 同步双向续期
                        redisUtils.expire(redisKey, 30, TimeUnit.MINUTES);
                        redisUtils.expire(uidKey, 30, TimeUnit.MINUTES);
                    } else {
                        // 旧设备,直接从 Redis 删除
                        redisUtils.delete(redisKey);
                    }
                    // ===== 单端核心校验结束 =====
                } catch (Exception e) {
                    this.logger.error("解析用户安全上下文失败", e);
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

TestController

当用户调用登录接口并传入用户ID时,系统首先通过Mapper映射器校验该用户在数据库中是否存在,若不存在则直接返回错误提示阻断流程。 校验通过后,系统利用定义在工具类中的统一前缀配合用户ID拼接出唯一的UidKey,并立即去Redis中检索该键对应的值,若能获取到说明该账号此前已在其他设备登录。 此时系统会严格遵循先清道后建新凭证的顺序,立刻调用删除命令将该旧Token作为键的TokenKey从Redis内存中彻底抹除,以此确保旧设备的身份凭证死透,从根本上解决高并发下的数据覆盖Bug。 清除完旧通道后,系统利用UUID生成一个全新的Token串并组装出对应的新TokenKey,同时将最新的用户ID和角色信息格式化为JSON字符串,随后执行双向绑定写入,即把JSON字符串存入新TokenKey,把新Token串反向覆盖写入UidKey,并为这两个键设置完全一致的三十分钟逻辑过期时间,最后将新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();

        // 1. 严格先行:先检查并干掉旧设备,确保当前 Uid 相关的旧通道被完全清理
        String oldToken = redisUtils.get(uidKey);
        if (StringUtils.hasText(oldToken)) {
            redisUtils.delete(redisUtils.tokenPreKey + oldToken);
        }

        // 2. 后行:旧设备死透了,再开始为新设备张罗新凭证
        String newToken = UUID.randomUUID().toString();
        String tokenKey = redisUtils.tokenPreKey + newToken;
        String jsonValue = String.format("{\"uid\":%d,\"role\":\"%s\"}", sUsers.getUid(), sUsers.getRole());

        // 3. 双向绑定写入 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";
    }


}

RedisUtils

redis相关做了封装,方便一点

package com.redisauth.util;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * Redis 基础操作通用工具类(基于 StringRedisTemplate)
 */
@Component
public class RedisUtils {

    private final StringRedisTemplate redisTemplate;
    public String HEADER = "Authorization";
    public String uidPreKey = "RedisAuth:uid:";
    public String tokenPreKey = "RedisAuth:token:";

    public RedisUtils(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 写入缓存
     */
    public void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 写入缓存并设置过期时间
     */
    public void set(String key, String value, long timeout, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, timeout, unit);
    }

    /**
     * 读取缓存
     */
    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 读取 Hash 指定字段值
     */
    public String hGet(String key, String field) {
        Object value = redisTemplate.opsForHash().get(key, field);
        return value != null ? value.toString() : null;
    }

    /**
     * 删除 Key
     */
    public Boolean delete(String key) {
        return redisTemplate.delete(key);
    }

    /**
     * 设置/更新 Key 过期时间
     */
    public Boolean expire(String key, long timeout, TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 检查 Key 是否存在
     */
    public Boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }
}

主程序入口

目的是关闭spring security自带的登录,以及mapper扫描

package com.redisauth;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;

@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class})
@MapperScan("com.redisauth.mapper")
public class RedisAuthApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisAuthApplication.class, args);
    }

}


发表评论: