1. 这套技术栈到底在解决什么问题
先直接说结论:Spring Boot 3 + Spring Security 6 + JWT + Redis这套组合,是我在做某企业内部后台系统登录认证模块时沉淀下来的一套方案。它解决的核心问题是——无状态、可横向扩展、能主动失效的token身份认证。
很多刚接触服务端开发的人会问:为什么不用传统的Session?为什么Spring Boot 2时代好用的spring.security配置到3.x突然不灵了?为什么JWT已经是无状态的,还要再用Redis存一份?这些问题不搞明白,代码照着敲一遍也会在线上翻车。
先说适用范围。这套方案适合中小型前后端分离项目、微服务网关后面的多个业务系统、以及需要统一登录态管理的场景。如果你正在做的项目满足以下任意一点,就有必要认真看这篇:
- 前端是Vue/React,通过Ajax/Fetch调用后端接口;
- 后端需要横向扩容,多实例部署在同一负载均衡后面;
- 登录态除了“能不能访问”之外,还需要“什么时候踢人”“改密码后立刻失效”这类主动控制;
- 想彻底脱离Servlet容器自带的HttpSession机制,让认证逻辑与容器解耦。
我在重构自己负责的某跨平台系统时,最头痛的就是老代码里到处session.getAttribute()和request.getSession()混用,加一台服务器就要配置Session粘滞。换成这套方案后,所有实例共享同一套忽略会话的认证逻辑,扩容从“改配置”变成“直接加节点”,省心非常多。
还需要强调一句:Spring Boot 3对应的Spring Security 6跟2.x相比有很多破坏性变更,网上大量教程还是旧写法。如果照着旧文章抄,大概率编译都过不了。这篇文里所有的配置和代码,我都是用当前生产环境验证过的写法,踩过的坑也会单列一章专门讲。
2. 整体设计思路:JWT负责“证明”,Redis负责“控制”
2.1 为什么JWT做凭证,而不是自己发明个随机token
先理清概念。JWT(JSON Web Token)本质上是一个经过签名、结构化的JSON字符串,包含三部分:Header(算法与类型)、Payload(业务数据)、Signature(签名)。签名保证token内容无法被篡改。
很多人问:我不用JWT,直接用UUID做token存Redis不行吗?技术上完全可行。但JWT的优势在于自包含:认证服务验签之后,可以直接从Payload里拿到用户ID、角色、过期时间等信息,不需要回查数据库或Redis就能完成第一轮身份判断。这样的好处是——在高频接口调用时,省掉一次Redis网络请求的设计余地。
但JWT有个天生的痛点:无法主动失效。通俗讲,只要签发出去,在过期之前它永远有效。哪怕用户点了“退出登录”,只要有人把token复制走了,依然能继续访问。这个问题靠JWT自己是解决不了的,必须引入存储层,这就是Redis在方案中的角色。
2.2 Redis在这里到底扛了什么活
Redis在我这套方案里干了三个活:
第一,做token的“服务端状态”。登录成功后,Redis中存入一个login:token:{jwt}键,value存用户基本信息,设置了与JWT过期时间一致的TTL。每次请求走到鉴权过滤器时,校验JWT签名通过之后再查一次Redis,只有两边都对才算认证通过。这样一旦删掉Redis里的键,JWT立刻变成一张“假票”,实现强制下线、踢人、改密码后失效等能力。
第二,做刷新凭证的存储。我采用的是Access Token + Refresh Token双token方案,其中Refresh Token不放入JWT Payload的常规字段,而是在Redis中以refresh:token:{userId}的形式保存,并绑定设备标识。这样当Access Token过期时,用户拿Refresh Token换新票,不用重新登录。这个设计对App尤其重要——不能三天两头让用户输入账号密码。
第三,做并发控制和登录策略。如果业务要求“同一账号只能在一处登录”,只需要在登录时把旧的Refresh Token覆盖;如果要求“允许同时在线但限制最多N个设备”,可以用Redis有序集合按登录时间维护用户会话列表。这些操作在Redis里都是原子的,配合Lua脚本可以做到无锁。
一句话概括整体思路:JWT负责“你是谁”的快速验证,Redis负责“你现在是否还有效”的实时控制。两者各管一段,互为补充,谁也不会替代谁。
2.3 画出登录和鉴权的完整时序
在写任何代码之前,我强烈建议先把自己的系统时序画出来。我这里给出参考流程:
- 登录阶段:用户提交账号密码 → 后端校验(顺便记录登录失败次数) → 校验通过生成Access Token和Refresh Token → Redis写入登录态 → 返回前端两个token与过期时间。
- 请求鉴权阶段:前端携带
Authorization: Bearer <Access Token>访问接口 → Spring Security过滤器链处理 → 解析并验签JWT → 查Redis确认token有效 → 封装Authentication到SecurityContext → 放行到Controller。如果Redis不存在,直接返回401。 - 令牌续期阶段:Access Token过期 → 前端拿Refresh Token请求刷新接口 → 服务端校验Refresh Token在Redis中是否存在且匹配 → 生成新的Access Token(可选:同时轮换Refresh Token) → 返回前端。
- 退出登录阶段:请求登出接口 → 从Redis删除当前用户的登录态 → 双token即刻失效。
这套流程中最重要的认知转变:不要感知为“每次请求要查Redis,那不如退回Session”,而要理解为:JWT让网关/内部服务可以快速做粗校验,Redis只做精准的最终确认。真实环境中Redis的延迟通常是毫秒级,多一笔查询换来的却是可控性提升,这笔账非常划算。
3. 核心实现细节:工程落地顺序与关键点
3.1 依赖与基础配置(Spring Boot 3.x专属坑)
我用的是Spring Boot 3.2.x,对应的Spring Security版本是6.2.x。在build.gradle里引入依赖:
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.jsonwebtoken:jjwt-api:0.12.5' implementation 'org.springframework.boot:spring-boot-starter-validation' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'这里必须强调一个容易翻车的地方:Spring Boot 3.x必须使用Jakarta EE命名空间,同时Spring Security 6不再自动适配spring.factories这类老机制。我在搭建骨架时,就遇到网上教程里import javax.servlet.*的旧写法根本无法编译。另外,Spring Boot 3要求JDK 17及以上,别在JDK 8上挣扎,直接换版本。
配置文件application.yml中需要单独定义token相关的参数,建议用配置类管理而不是散落在代码里:
custom: jwt: # 密钥至少256位,推荐用Base64编码后的随机串 secret: "你的Base64编码密钥" # access token过期时间,单位毫秒,这里15分钟 access-token-expire: 900000 # refresh token过期时间,单位毫秒,这里7天 refresh-token-expire: 604800000注意一下过期时间的设定逻辑。Access Token要短,比如15分钟到2小时,因为它是每次请求都会带的,泄露风险高;Refresh Token要长,比如7到30天,因为它只走刷新接口,不参与日常请求。如果一个token既能长期有效、又能无差别访问所有接口,那它一旦泄露等于账号白给。这个思想是整个方案的基石。
3.2 登录接口:从校验到签发token的完整逻辑
登录接口的职责不止是“比一下密码”,我把它拆成四步:
- 预校验:验证账号是否存在、账号是否被锁定、验证码(如果有)是否正确。
- 校验密码:使用
BCryptPasswordEncoder比对密文。 - 策略判断:比如限制同一账号在线设备数,先清理多余会话。
- 签发令牌:生成Access Token与Refresh Token,写入Redis,封装UserInfo返回前端。
先提供用于生成JWT的工具类,核心代码示例如下:
@Component public class JwtTokenProvider { @Value("${custom.jwt.secret}") private String secret; @Value("${custom.jwt.access-token-expire}") private long accessTokenExpire; public String generateAccessToken(UserAuthDetails user) { long now = System.currentTimeMillis(); return Jwts.builder() .subject(user.getUserId()) .claim("username", user.getUsername()) .claim("roles", user.getRoles()) .issuedAt(new Date(now)) .expiration(new Date(now + accessTokenExpire)) .signWith(getSigningKey(), Jwts.SIG.HS256) .compact(); } private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(keyBytes); } }登录成功后,写入Redis的代码最关键。设计键名时一定要带上可辨识的前缀和业务ID,我习惯这样组织:
login:token:{userId}:{jwtId}value可以序列化为用户的轻量信息,TTL与Access Token过期时间一致。这样组织的好处是:如果要让用户“全部设备下线”,只需要按login:token:{userId}:*批量删除;如果要“仅踢某个设备”,精确删一条即可。扫描时可以用SCAN而非KEYS,避免生产环境阻塞Redis单线程。
3.3 过滤器链:Security 6.2的正确打开方式
这是整篇最值得细看的部分。Spring Security 6里的过滤器链配置方式比5.x变化很大,旧写法里WebSecurityConfigurerAdapter已经删了,必须用SecurityFilterChain的Bean方式配置。
一个能跑的配置类长这样:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .exceptionHandling(ex -> ex .authenticationEntryPoint(restAuthenticationEntryPoint()) .accessDeniedHandler(restAccessDeniedHandler())) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }几个细节值得展开说:
SessionCreationPolicy.STATELESS必须显式声明。Spring Security 6默认可能还会尝试创建Session,加了这个配置才能彻底摆脱容器会话。requestMatchers是6.x的合法写法,旧版的antMatchers已经移除。这是很多升级Spring Boot 3之后编译报错的第一大原因。- 过滤器注册顺序有讲究:
addFilterBefore确保JWT过滤器在UsernamePasswordAuthenticationFilter之前执行,这样才能优先解析token、填充上下文。
JWT过滤器的职责,本质上就是把“HTTP请求头里的token”翻译成“SecurityContext里的Authentication”。这个翻译过程不能省,也不能出错。
3.4 JWT过滤器:验签、查Redis、封装Authentication
自定义过滤器的核心逻辑如下:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); if (jwtTokenProvider.validateToken(token)) { String userId = jwtTokenProvider.getUserId(token); // 关键:检查Redis中是否存在 if (redisTemplate.hasKey("login:token:" + userId + ":" + jwtTokenProvider.getJwtId(token))) { UserAuthDetails user = userAuthService.loadUserByUserId(userId); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } }这里我故意把Redis查证放在验签之后。为什么?因为验签是纯本地计算,开销小、速度快;Redis查询属于外部IO,应该尽量用“已经验证过身份的请求”来触发。如果每个匿名请求都先查Redis,等于给攻击者提供了一个无差别打Redis的入口。
另外一个常见的误区:这里不要每次都从数据库捞用户。初次登录时可以把用户ID和角色信息放进JWT Payload,Redis里再存一份冗余的用户快照,这样过滤器在做认证时,直接从Redis取用户信息即可,完全不用回数据库。只有当发生权限变更、角色调整、禁用账号等操作时,才主动刷新Redis中的快照。这个“三级缓存思维”能显著降低数据库压力。
3.5 UserDetailsService与用户加载策略
Spring Security的认证流程中,UserDetailsService承担着“根据用户名加载用户”的任务。有的教程会把它用在登录校验阶段,有的用来在过滤器里加载完整信息。我的策略是:
- 登录阶段:通过
UserDetailsService.loadUserByUsername()拿到数据库中的完整用户信息,包括密码密文。 - 过滤器阶段:只从Redis拿轻量用户快照,不回数据库。
对应的实现是这样的:
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserAccount user = userAccountMapper.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return new UserAuthDetails(user); } }这里要提醒一个问题:Spring Security的DaoAuthenticationProvider默认会用“加载出来的UserDetails里的密码”去比对用户提交的密码。如果你在登录接口里手动调authenticationManager.authenticate(),那么loadUserByUsername里必须返回完整的密文,否则校验必失败。这是个看似简单、实际非常容易忽略的点。
有一次我的同事A在这上面折腾了一晚上,因为他在过滤器里使用了同一个UserDetailsService,返回的UserDetails里密码是空字符串,结果登录接口永远报BadCredentialsException。后来拆成“登录查库、请求走缓存”两个路径才解决。记住:UserDetailsService不要一股脑只写一个版本,要结合它的调用场景决定是否填充密码字段。
3.6 刷新令牌与登出接口的实践
刷新接口的逻辑是:拿Refresh Token换新Access Token。基本过程分三步:
@PostMapping("/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); // 1. 验签解析出userId和tokenId // 2. 检查Redis的refresh:token:{userId}是否与当前token一致 // 3. 一致则删除旧登录态、签发新的双token并写入Redis }这里有一个值得做的细节:刷新操作一定要“旋转Refresh Token”。也就是每次刷新后,旧的Refresh Token立刻失效,Redis中覆盖为新值。这样可以防止Refresh Token泄露后反复使用。代价是用户如果频繁刷新,Redis写入操作增多——但换来的是安全性显著提升,我非常推荐。
登出接口则简单许多:
@PostMapping("/logout") public ResponseEntity<?> logout(HttpServletRequest request) { // 从SecurityContext中拿到userId,根据tokenId删除Redis中的login:token和refresh:token }如果想要“退出所有设备”,就是批量删除login:token:{userId}:*和refresh:token:{userId}。这个能力在用户“忘记密码”或“账号被盗”的场景里极其重要——不用等token自然过期,立刻就能让所有会话失效。
4. 实操过程中的常见坑与排查方法
4.1 每个配置都要问一遍“为什么”
我在整套搭建过程中最大的体会是:Spring Security 6里没有一行配置是可以“照着抄”的,每改一个地方都要知道它在过滤器链里的位置和用途。排查问题时,第一步永远是确认“当前请求到底走到了过滤器的哪一环”。如果请求还没到Controller就被断了,多半是过滤器链先后顺序或者权限规则的问题。
常见的一个现象:请求返回403但不是因为权限不足,而是因为匿名请求没有Authentication。403和401的区别在Spring Security里很微妙:未认证访问受限资源返回401,已认证但权限不够返回403。如果配置了自定义AuthenticationEntryPoint但没有配置AccessDeniedHandler,某些情况下会把本应401的请求变成403,排查起来非常容易迷惑。两个异常处理器建议都配。
有一个让我印象深刻的bug:某跨平台系统上线后,运维反馈“接口偶尔401”。查了半天,发现是过滤器里用了redisTemplate.hasKey判断登录态,而Redis连接在偶发超时后抛了异常,直接让请求掉了链子。后来在Redis操作外层加了“缓存故障时放行但记录日志”的降级策略,才彻底解决。在任何认证链路上,第三方依赖的故障都不能直接表现为“拦截所有正常用户”,降级方案必须在设计阶段就考虑好。
4.2 过滤器里的事务是陷阱
Spring Security过滤器中的代码不要加@Transactional。原因很简单:过滤器生命周期不受Spring事务管理器统一管理,而且认证过程应该保证尽量轻量。如果在过滤器里开数据库事务,遇到高并发时事务长时间不提交,数据库连接池很快会被打满。我见过不止一个团队在这个问题上踩坑:他们把用户权限查询写进了过滤器的事务里,结果流量稍一上来,数据库连接池就报错。
解决方式是把“完整用户信息+权限”在登录时缓存到Redis中,过滤器只读Redis。如果系统对权限实时性要求极高,可以采用“短TTL + 懒刷新”策略,而不是在过滤器里实时查库。
4.3 密码加密,不只是一行BCrypt
BCrypt是Spring Security默认推荐的密码加密算法,简单用法就是new BCryptPasswordEncoder().encode(明文)和.matches(明文, 密文)。但工程上还要注意:
- 不要让前端传明文密码到后端的过程中没有防护。如果项目不是全站HTTPS,密码在传输过程中可能被截获。至少要做到前端加盐或使用SSL终止。
- 登录取消防护:要记录登录失败次数,比如Redis中
login:fail:{username}计数,5次失败后锁定账号15分钟。这个需求看着简单,但千万不能把计数放在JVM内存里——多实例部署时每个节点的计数不共享,等于没有限流。
我曾经犯过的错误是把登录失败次数存在ConcurrentHashMap里,线下单机测试没问题,一上生产集群,攻击者轮着打不同节点就绕过了。后来改成RedisINCR+EXPIRE的原生原子操作,才算真正堵住漏洞。
4.4 过滤器链顺序错乱导致的“不生效”
Spring Security 6中自定义过滤器可以用addFilterBefore/After/At来指定位置。常见需求:
- 想把JWT过滤器放在
UsernamePasswordAuthenticationFilter之前:addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)。 - 想覆盖某些特殊逻辑可以在某个过滤器之后:
addFilterAfter(...)。
如果发现自定义过滤器一直不执行,先看两个地方:一是过滤器类上是否标注了@Component导致被Spring Security默认加载两次;二是SecurityFilterChain里是否真的注册了这个过滤器。只声明成Bean但不在http.addFilterBefore()里显式使用,等于白写。另外,Spring Boot的OncePerRequestFilter能保证每个请求只执行一次,但前提是不要有两个相同性质的过滤器同时生效。
还有一个容易踩的:如果项目同时引入了spring-boot-starter-security和spring-boot-starter-oauth2-resource-server,Spring Security会自动配置一条JWT处理链,干扰自定义逻辑。排查时多看一眼自动配置报告(debug=true),看看到底哪些过滤器被加载了。这一步能节省大量调试时间。
5. 事后回顾:这套方案还能怎么变着用
项目落地以后,我个人在后续迭代中又尝试过几个方向的扩展,顺便跟你聊聊。
第一是把Authorization逻辑从Authentication逻辑中抽出去。Spring Security 6中支持@EnableMethodSecurity配合@PreAuthorize("hasRole('ADMIN')")做方法级权限控制。相对于URL级别的requestMatchers,方法级注解能更精细地控制权限,比如“本人只能看自己的订单,管理员能看所有”。这种方式适合业务权限规则复杂的项目。
第二是引入简单的Token黑名单机制。虽然我主结构是“登录状态在Redis”,但如果某些业务要求JWT必须完整存活到过期(比如部分第三方对接要求),可以只把“主动注销的token列表”放进Redis黑名单。这在设计上是个变种方案,大家可以根据自己系统的实际约束来选。
第三是把认证逻辑拆成独立服务。当项目发展到多个子系统各自为政时,可以把这个登录认证模块抽成公共的认证微服务,其他系统通过JWT验签+统一Redis接入。这套方案天然支持这种拆分,因为JWT的自包含和Redis的共享存储给服务化打了很好的地基。
最后想提醒一点:不要在生产环境里追逐“最新版本”的Spring Boot/Spring Security,但也不要长期停在Spring Boot 2时代。一旦升级到3.x,很多三方库的兼容性变化会让人崩溃,这属于一个投入产出比很低的痛苦过程。但如果已经在新项目中启动,直接按Spring Boot 3的技术体系来设计,尽快把Security 6的过滤器链模型吃透,后面越写越顺。
对我个人而言,从Session到JWT+Redis这个过渡,真正的门槛不是代码,而是思维模型的切换。Session是“服务端说了算”,JWT是“凭据自己说话”,而Redis是“让服务端还能再管一下这份凭据”。把这层关系想透了,不管是Spring Security还是其他语言里的类似框架,你都能很快上手。