一个欲儿的博客

一个欲儿的博客

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数据表

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

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";
    }


}


发表评论: