news 2026/10/14 3:59:29

Spring Boot 3 + Security 6 + JWT + Redis 无状态登录认证实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3 + Security 6 + JWT + Redis 无状态登录认证实战

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的完整逻辑

登录接口的职责不止是“比一下密码”,我把它拆成四步:

  1. 预校验:验证账号是否存在、账号是否被锁定、验证码(如果有)是否正确。
  2. 校验密码:使用BCryptPasswordEncoder比对密文。
  3. 策略判断:比如限制同一账号在线设备数,先清理多余会话。
  4. 签发令牌:生成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还是其他语言里的类似框架,你都能很快上手。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:59:24

无标题需求如何落地:从需求挖掘到技术选型的实操指南

没有真正意义上的“无标题项目”。标题栏写着“【无标题】”&#xff0c;正文一栏空白&#xff0c;只有一句聊天记录&#xff1a;“帮我做个类似的东西”——这种需求我在过去几年里接过无数次。很多人第一反应是“这没法做”&#xff0c;但我的经验恰恰相反&#xff1a;没有标…

作者头像 李华
网站建设 2026/10/14 3:59:21

猫咪年龄计算器的Flutter for OpenHarmony实战:从算法到平台适配

去年年底接到一个项目需求&#xff1a;做一个跑在 OpenHarmony 设备上的猫咪管家 App&#xff0c;功能包括猫咪档案管理、喂养提醒、健康记录&#xff0c;还有我今天要重点讲的这个年龄计算器。整个团队里我主要负责 Flutter for OpenHarmony 侧的落地实现&#xff0c;这个年龄…

作者头像 李华
网站建设 2026/10/14 3:59:19

WSL下Anaconda环境断点失效?Cursor调试配置全解析

先说一个我最近撞到的现场。某个项目里&#xff0c;某开发者把数据处理脚本放在Windows的E盘&#xff0c;Anaconda装在WSL里的Linux系统上&#xff0c;然后在Cursor里很熟练地装了Python扩展&#xff0c;从解释器下拉框里翻来翻去&#xff0c;也选定了conda环境。结果一按F5&am…

作者头像 李华
网站建设 2026/10/14 3:59:06

SVGATextMode实战:提升Linux文本控制台分辨率的完整指南

在Linux的文本界面里折腾过的人&#xff0c;多半都遇到过那个默认得有点尴尬的 8025 分辨率&#xff1a;想看的日志一翻就是大半屏&#xff0c;表格类输出折行看得脑壳疼&#xff0c;tmux 分屏之后更是挤得不行。“SVGA Text Mode命令”就是这么一类专门解决文本控制台显示密度…

作者头像 李华
网站建设 2026/10/14 3:58:56

学术视角下大模型在科研工作流中的高效应用与边界

学术圈最近两年有一句话流传得很广&#xff1a;AI不会替你思考&#xff0c;但会替你找资料。作为这个系列的第003篇&#xff0c;我继续沿着“人工智能应用”这条线往下走&#xff0c;但这一次不从产品经理视角看落地&#xff0c;而是真正扎进学术视角&#xff0c;认真聊聊大模型…

作者头像 李华
网站建设 2026/10/14 3:58:51

2GB内存跑WordPress够吗?内存优化与性能排查实战指南

如果你的网站突然变得慢慢吞吞&#xff0c;后台点个保存都要转好几圈&#xff0c;你登录服务器一看free -h&#xff0c;内存占用已经到了90%以上——这时候你大概率会开始怀疑人生&#xff1a;当初图省事选了个2GB内存的入门配置&#xff0c;是不是从一开始就走错了路。先给一个…

作者头像 李华