
1. 为什么登录认证这件事值得单独拿出来讲做过几个后台系统的朋友大概都有这个体会业务代码写得再漂亮登录和权限这块一旦设计得糙后面全是补丁。我见过太多项目一开始图省事用户登录成功之后往 session 里塞个 userId然后在每个接口里手动if (userId null) return 401;权限判断就靠一个role字段硬编码。等项目涨到几十个接口、三四种角色的时候这套东西基本就崩了——改一处权限逻辑得翻遍整个工程。登录认证和权限授权本质上解决的是两个问题你是谁以及你能干什么。前者是认证Authentication后者是授权Authorization。这两个词经常被混着用但它们对应的技术方案和出错后果完全不同。认证做错了最坏情况是别人能冒充你的账号授权做错了最坏情况是一个普通用户能删掉整个数据库的数据或者一个普通员工能看到老板的工资条。这篇内容围绕SpringSecurity JWT session这三样东西展开。为什么是这三样因为它们代表了当前 Java 后端做认证授权的两条主流路线而且经常被放在一起对比、甚至混用。SpringSecurity 是框架层面的安全骨架负责把认证、授权、过滤器链、会话管理这些事统一收口JWT 是无状态令牌的代表适合分布式和前后端分离session 是传统有状态方案的代表简单直接但扩展性上有它的边界。搞懂这三者的关系和取舍基本就能覆盖从单体后台到中台微服务的大部分场景。适合谁看如果你已经会写 CRUD但对登录之后 token 怎么发、怎么验、过期了怎么办、权限怎么拦这些还是一团浆糊那这篇会比较对症。如果是老手也可以重点看权限设计、JWT 防篡改和 token 续签那几节里面有些是我踩过坑之后才想明白的东西。全文不会只贴配置代码重点讲清楚每一步为什么这么选、不这么做会出什么问题。开篇先把一个概念钉死认证和授权是两条流水线。认证流水线负责把匿名请求变成已登录用户产出的是一个身份凭证session 或 token授权流水线负责拿着这个身份凭证去判断这个用户能不能访问这个资源。很多新手把这两步揉在一个拦截器里写结果就是登录接口自己被拦了或者免登录接口莫名其妙要鉴权。后面第二节会专门拆这条流水线。2. 整体架构设计三套方案到底怎么选2.1 先搞清楚 session 和 JWT 的本质区别Session 方案的运作方式用一个生活类比特别好理解。你去洗浴中心前台给你一个手牌你的衣服和手机都锁在柜子里柜子钥匙在前台手里。手牌本身没有任何信息它只是一个编号前台拿着编号去查这个编号对应哪个柜子、里面是谁。这个手牌就是sessionId前台那本登记册就是服务端的session 存储。JWT 则是反过来。它像一张自带的会员卡卡面上直接印着你的姓名、等级、有效期甚至还有一串防伪签名。门卫拿到卡不需要打电话回总部查自己看一眼签名对不对、有效期过没过就能放行。信息都在卡上服务端不存东西这就是无状态。这个区别带来一连串连锁反应我把关键维度拉成表格对比方便直接对照对比维度Session 方案JWT 方案状态存储位置服务端内存/Redis/数据库客户端token 自身携带服务端压力每个在线用户占一份存储几乎不占存储只做验签横向扩展多节点需共享 session如 Redis天然支持各节点只需同一密钥主动踢人下线容易删 session 即可麻烦需维护黑名单令牌体积小只是一个随机串大payload 越长越大跨域/跨端依赖 Cookie跨域要额外配置放 Header 里跨域友好过期控制服务端说了算可随时改签发时写死中途改不了典型适用场景传统单体后台、管理端前后端分离、App、微服务看到这里你应该能感觉到这两者不是谁替代谁的关系而是取舍。如果你的系统就是一套单体后台用户量几千运维不想引入 Redis那 session 方案又快又稳没必要为了时髦上 JWT。反过来如果你的前端是 Vue/React 独立部署后端要开多个实例做负载均衡那 session 共享这件事早晚会恶心到你JWT 会更顺。注意JWT 的无状态是相对的。只要你需要主动踢人下线或者改权限立即生效就一定要在服务端维护一份状态黑名单或版本号这时候它其实已经不是纯无状态了。网上很多文章把 JWT 吹成万能解药这是不准确的。2.2 SpringSecurity 在这套体系里扮演什么角色SpringSecurity 不是一个具体的认证方案它是一套安全框架骨架。你可以把它理解成一个小区物业的门禁系统总控它规定了进门要先刷卡、刷卡要验证、验证通过才放行这套流程但具体用哪种卡session 还是 JWT、卡怎么验证查数据库还是验签是你自己插进去的。它的核心是一个过滤器链Filter Chain。一个 HTTP 请求进来会依次经过一串过滤器每个过滤器负责一件事有的是解析认证信息有的是做权限校验有的是处理异常。这套设计的好处是职责分明你可以往链条里插自定义过滤器比如放一个解析 JWT 并构建认证对象的过滤器在用户名密码认证之前。SpringSecurity 默认自带的是基于 session 的那套登录成功后把认证信息存进SecurityContext再存进 session下次请求带着 sessionId框架自动从 session 里恢复认证状态。所以如果你直接用默认配置其实就是在用 session 方案。要改成 JWT本质上就是关掉 session 存储改用一个从请求头解析 token 的过滤器来恢复认证状态。这里有个关键点容易搞混SecurityContext是线程级别的当前请求上下文session是跨请求的存储。默认情况下框架会通过SecurityContextPersistenceFilter把 session 里的认证信息加载到SecurityContext请求结束时再存回去。改成 JWT 后这个持久化环节要去掉改成每次从 token 重建否则会出现用了 token 但认证状态还是从 session 来的诡异现象。2.3 三套方案的组合策略实际项目里最常见的不是三选一而是按端分流。我经手过一个中型系统后台管理端用的是 session管理员数量少需要严格的即时踢人能力C 端 App 用的是 JWT用户量大多实例部署两端共用同一套用户表和权限模型但在安全过滤器链上走不同的配置。这种一套用户体系、两套认证通道的做法落地起来其实是比较好维护的。具体分工可以这样定后台管理端session SpringSecurity 默认链路配合 Redis 做 session 共享权限用PreAuthorize注解做方法级控制。C 端 / 移动端JWT双 tokenaccess token 短有效期 refresh token 长有效期权限在网关或过滤器里做粗粒度校验。内部服务间调用JWT 携带服务身份配合内网白名单不做用户级鉴权。这个分工的核心逻辑是对安全性要求越高、用户越少、越需要即时控制的场景越偏向 session对扩展性要求越高、用户越多、越能容忍短暂延迟的场景越偏向 JWT。这句话不是绝对的但作为一个选型的起点八九不离十。3. 认证流程的核心细节拆解3.1 用户名密码认证到底走了哪些步骤很多人写登录接口就是查一下用户表密码对了返回成功。但 SpringSecurity 帮你把这件事拆成了标准化的几步理解这几步是自定义认证方式的前提。第一步请求进入UsernamePasswordAuthenticationFilter它从请求里取出用户名和密码封装成一个Authentication对象此时是未认证状态。第二步这个对象被交给AuthenticationManager它内部会遍历所有AuthenticationProvider找到能处理这种类型认证的那个默认是DaoAuthenticationProvider。第三步Provider 调用UserDetailsService去加载用户信息返回一个UserDetails。第四步Provider 用PasswordEncoder把前端传来的密码和数据库里存的密码做比对。第五步比对成功后用UserDetails里的权限信息构建一个已认证的Authentication对象存入SecurityContext。这里面有几个坑点值得单拎出来说。密码编码器必须显式配置SpringSecurity 新版本不再默认提供明文比较的编码器如果你不配PasswordEncoder登录会直接报错说找不到编码器。这其实是好事逼着你不用明文存密码。推荐用BCryptPasswordEncoder它自带盐值且计算强度可调比 MD5 加盐安全得多——MD5 现在用彩虹表加 GPU 爆破弱密码几分钟就出来了。另一个坑是UserDetailsService的返回值。很多人在loadUserByUsername里查不到用户就返回 null正确做法是抛UsernameNotFoundException否则框架会因为返回 null 而报空指针错误信息也不友好。还有一个隐蔽问题如果你在UserDetails里返回的权限集合是空的即使密码对了认证也会被标记为未授权后面访问任何需要权限的接口都会被 403。权限前缀也有讲究用hasRole(ADMIN)时框架会去找ROLE_ADMIN所以数据库里存的权限串要么统一加ROLE_前缀要么统一用hasAuthority。3.2 认证成功后的会话该怎么落地认证通过之后身份凭证怎么发给客户端是 session 和 JWT 的分水岭。走 session 路线的话框架默认会在认证成功时把SecurityContext存入 session然后通过响应把JSESSIONID这个 Cookie 下发给浏览器。浏览器后续请求会自动带上这个 Cookie服务端凭它找回会话。这里有三个配置细节经常被忽略。第一Cookie 的安全属性。生产和开发环境一定要分清楚HttpOnly必须开防止 XSS 脚本读取 CookieSecure属性要求 HTTPS 才发送生产环境务必打开SameSite设置成Lax或Strict能有效缓解 CSRF。这几个属性不加等于把会话凭证裸奔在浏览器里。第二session 超时时间。默认 30 分钟通常不够用后台系统一般设 2 到 8 小时。但要注意超时时间设太长服务端内存占用会上去所以超过一定用户量就该转 Redis 存储。第三并发控制。SpringSecurity 支持限制同一账号的并发会话数超过就踢掉最早的。这个功能在后台管理场景挺有用防止一个账号被多人共用。走 JWT 路线的话认证成功后要生成 token 并返回。token 的生成有个关键决策payload 里放什么。原则是只放必要且不敏感的信息比如用户 ID、用户名、角色、签发时间、过期时间。绝对不要放密码、手机号、身份证这类敏感数据因为 JWT 的 payload 只是 Base64 编码不是加密任何人拿到 token 都能解出内容。这一点非常多人栽跟头以为 token 是密文。3.3 JWT 的结构与签名机制JWT 由三段组成用点号分隔Header、Payload、Signature。Header 声明算法类型通常是 HS256 或 RS256Payload 放声明数据Signature 是对前两段加密钥做签名得到的结果。签名的计算过程是把 Base64Url 编码后的 Header 和 Payload 用点号拼起来再用密钥和指定算法做 HMAC 运算结果再做 Base64Url 编码。服务端验证时重新算一遍签名只要对得上就说明 token 没被改过。这里必须强调一个安全事实签名保证的是不被篡改不是不被读取。有人会问JWT 怎么防止数据被串改答案就在签名上——攻击者想把 payload 里的role: USER改成role: ADMIN那 payload 变了签名必须同步变但他没有密钥算不出合法的签名服务端一验就露馅。但反过来攻击者把 token 解开看看里面有什么是完全做得到的。那怎么防止信息泄露两个办法。一是 payload 里别放敏感数据这是根本二是如果确实要传敏感信息用 JWE加密的 JWT而不是 JWS签名的 JWT把整个 payload 加密。大多数业务场景第一种就够了。签名算法选择上HS256对称加密和 RS256非对称加密要分清。HS256 用同一个密钥签发和验证适合单体应用RS256 用私钥签发、公钥验证适合多个服务都要验证 token 但只有认证中心能签发的场景。如果选 RS256公钥可以随便分发私钥必须在认证服务手里。有个经典漏洞是算法降级攻击攻击者把 Header 里的算法改成none某些解析库会直接跳过验签。所以服务端必须显式指定允许的算法不能信任 token 自己声明的算法。注意密钥长度一定要足够。HS256 的密钥建议至少 256 位32 字节随机字符串别用123456或者项目名当密钥。密钥泄露等于所有 token 都能被伪造这比数据库泄露还可怕。3.4 Token 续签的几种策略Access token 有效期设短比如 15 分钟到 2 小时能降低泄露风险但用户体验上总不能让人每两小时重新登录一次。所以续签机制是必须的。常见的续签方案有三种各有取舍第一种是双 token 机制。access token 短命refresh token 长命7 天到 30 天。access token 过期后前端用 refresh token 去换一个新的。refresh token 只用于换 token不能访问业务接口而且要存在服务端数据库或 Redis以便随时吊销。这个方案比较稳推荐。第二种是自动续期。在 access token 快过期时比如剩余时间少于阈值服务端在响应头里下发一个新 token前端替换掉旧的。用户完全无感。缺点是每次请求都可能带新 token网络开销略增。第三种是滑动过期。每次请求都刷新 token 的过期时间。这个方案简单但安全性最差因为一个被盗的 token 可以无限续命。不推荐在生产环境用。不管用哪种过期时间判断要用服务端时间不能用客户端时间。有次我就吃过亏前端本地时间被用户改动了导致它误判 token 没过期一直发请求一直被拒用户体验极差。4. 权限授权体系的落地方法4.1 权限模型的三种粒度权限设计是整个体系里最容易设计过度、也最容易设计不足的地方。我把它分成三个粒度层次按需选择。粗粒度就是基于角色Role。用户属于某个角色接口绑定某个角色代码里写PreAuthorize(hasRole(ADMIN))。简单直接适合角色数量少、职责边界清晰的系统。缺点是角色一多就爆炸比如财务只能看财务数据但财务主管能审批这种需求你就得造出FINANCE、FINANCE_MANAGER两个角色后面再来个财务主管只能审批金额小于 10 万的就还得造新角色。中粒度是角色 资源。引入权限码的概念比如user:add、order:delete、report:export角色和权限码是多对多关系。接口绑定权限码用户通过角色间接获得权限码。这是目前后台管理系统的主流做法灵活度够用复杂度可控。细粒度是数据级权限也就是同一个接口不同用户看到的数据不同。比如销售 A 只能看自己名下的客户主管能看整个团队的。这个级别通常不靠框架注解实现而是在 SQL 层面拼条件或者用 MyBatis 的拦截器统一注入数据范围条件。设计上要小心如果每张表都要拼权限条件代码会很脏建议用统一的数据权限组件处理。4.2 SpringSecurity 的授权注解怎么用才不踩坑SpringSecurity 提供了几个注解但启用它们需要先在配置类上加EnableGlobalMethodSecurity较新版本是EnableMethodSecurity不加注解是不生效的——这是新手最常见的为什么我的权限注解没拦住请求的原因。PreAuthorize在方法执行前校验最常用。它可以写复杂的 SpEL 表达式比如PreAuthorize(hasRole(ADMIN) or #userId authentication.principal.id)后面的写法实现了管理员或者本人才能操作这类本人才能改自己数据的场景很常见。PostAuthorize在方法执行后校验用在你需要先查出结果再判断权限的场景比如只能查看自己部门的数据。但要注意方法已经执行了数据库查询已经发生了如果权限不过只是返回值被拦这中间的资源消耗是已经产生的。PreFilter和PostFilter用于对集合参数或返回值做过滤比如PostFilter(filterObject.ownerId authentication.principal.id)会自动过滤掉不属于当前用户的元素。这个功能看着很美好但它是内存里过滤的集合大了性能很差数据量大的场景还是应该在 SQL 里过滤。用注解最容易踩的坑是权限数据从哪来。authentication.principal默认是UserDetails对象如果你在 JWT 过滤器里塞进去的是一个自定义的LoginUser那表达式里就得按自定义对象写。类型对不上会抛异常而且异常信息往往不直观。4.3 前后端分离下的权限传递前后端分离之后权限控制多了一层前端也要根据权限渲染菜单和按钮。很多项目在这里犯了错把权限判断只做在前端以为隐藏了按钮用户就点不到这是彻底的安全误区。前端隐藏只是体验优化真正的拦截必须做在后端。后端一般会在登录成功后返回一个权限码列表前端存起来用自定义指令或组件控制按钮显隐。路由级别则根据角色动态生成路由表。这里的经验是权限码要设计成稳定的、语义清晰的字符串别用数据库自增 ID 当权限标识因为环境迁移时 ID 会变。还有一点权限变更后前端什么时候刷新。如果管理员改了某人的权限而这个人还在线前端缓存的权限码还是旧的。前台表现是该看到的按钮没出现。解决办法是权限变更后强制该用户重新登录或者前端每次路由跳转时异步拉一次权限。前者简单粗暴但有效后者体验好但要控制好请求频率。4.4 记住我功能怎么安全实现SpringSecurity 的记住我Remember Me功能本质是在用户关闭浏览器后仍然保持登录。实现方式有两种基于哈希的令牌和基于持久化的令牌。基于哈希的令牌存 Cookie格式是用户名:过期时间:签名服务端用密钥验签。优点是无需存库缺点是改密码时无法批量失效已发出的令牌因为服务端不掌握令牌列表且如果密钥泄露攻击者能伪造任意用户的记住我令牌。基于持久化的令牌会把令牌存进数据库表每次使用后更新令牌值令牌轮换安全性更好且能主动吊销。实际使用时我建议只在低敏感场景开启记住我比如内容类网站的下次自动登录。后台管理系统、涉及资金或个人隐私的系统不要开或者在开启时配合二次验证。这个功能的便利性和安全性天生对立用之前想清楚业务能不能接受。5. 完整实操从零搭一套 JWT 认证授权5.1 依赖与基础配置假设是一个 Spring Boot 项目先引入必要依赖。核心是spring-boot-starter-securityJWT 部分我用jjwt这个库它 API 清晰、维护活跃。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.5/version scoperuntime/scope /dependency注意jjwt从 0.10 版本开始拆成了三个包api是编译期用impl和jackson是运行期用。很多人只引api结果运行时报找不到实现类就是漏了后面两个。接下来配置安全策略。新一代 Spring Boot3.x里WebSecurityConfigurerAdapter已经废弃改用直接注册SecurityFilterChainBean 的方式。这个变化坑了不少人网上老教程还在用适配器写法照抄会编译不过。Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; public SecurityConfig(JwtAuthFilter jwtAuthFilter) { this.jwtAuthFilter jwtAuthFilter; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(sm - sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }几个关键点解释一下。SessionCreationPolicy.STATELESS告诉框架不要创建和使用 session这是 JWT 方案必须的否则框架会在每次请求时试图从 session 恢复上下文白费功夫还可能引发状态混乱。addFilterBefore把自定义的 JWT 过滤器插到用户名密码过滤器之前这样每个请求进来先尝试解析 token如果解析出合法身份就提前把认证状态放进SecurityContext后续的授权判断才能生效。注意csrf().disable()在纯 JWT 前后端分离场景下是合理的因为 token 放在请求头里浏览器不会自动携带天然规避了 CSRF。但如果你同时保留了 session Cookie那就不能关 CSRF否则有安全风险。这个取舍要看方案组合不能无脑关。5.2 JWT 工具类的实现细节工具类负责签发和解析 token是整个方案的心脏。Component public class JwtUtil { private final SecretKey key; private final long accessExpireMs 30 * 60 * 1000L; // 30 分钟 private final long refreshExpireMs 7 * 24 * 3600 * 1000L; // 7 天 public JwtUtil(Value(${jwt.secret}) String secret) { this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateAccessToken(String userId, String username, ListString roles) { Date now new Date(); return Jwts.builder() .subject(userId) .claim(username, username) .claim(roles, roles) .issuedAt(now) .expiration(new Date(now.getTime() accessExpireMs)) .signWith(key, Jwts.SIG.HS256) .compact(); } public Claims parse(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } }Keys.hmacShaKeyFor会自动校验密钥长度如果密钥不足 256 位会直接抛异常这是个不错的保护。signWith里显式指定算法是防算法降级攻击的关键一步。解析时用parseSignedClaims而不是旧版的parseClaimsJws这是新版本 API。密钥配置放配置文件里并且生产环境要用环境变量或配置中心注入不要硬编码在代码或提交到仓库。我见过把密钥直接写进application.yml然后推到公开仓库的等于把整站钥匙挂门口。5.3 自定义认证过滤器的完整流程过滤器要做的事是从请求头取 token校验构建认证对象塞进上下文。Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; public JwtAuthFilter(JwtUtil jwtUtil) { this.jwtUtil jwtUtil; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { chain.doFilter(request, response); return; } String token header.substring(7); try { Claims claims jwtUtil.parse(token); String userId claims.getSubject(); ListString roles claims.get(roles, List.class); ListSimpleGrantedAuthority authorities roles null ? Collections.emptyList() : roles.stream().map(SimpleGrantedAuthority::new).toList(); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(userId, null, authorities); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException | IllegalArgumentException e) { SecurityContextHolder.clearContext(); } chain.doFilter(request, response); } }几个容易出错的点。第一token 解析失败不要直接抛异常中断请求而是清空上下文后继续放行让后面的授权环节去拒绝。这样错误处理统一由框架的AuthenticationEntryPoint负责返回格式一致。第二OncePerRequestFilter保证过滤器在单次请求中只执行一次用普通Filter在转发场景可能执行多次。第三注意setDetails某些场景下框架需要它虽然 JWT 流程不一定用得上但加上更稳妥。5.4 登录接口与 token 下发登录接口自己实现不依赖 SpringSecurity 的默认登录页。RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authManager; private final JwtUtil jwtUtil; private final UserService userService; // 构造器注入省略 PostMapping(/login) public ResultLoginVO login(RequestBody Valid LoginDTO dto) { Authentication authentication authManager.authenticate( new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()) ); LoginUser loginUser (LoginUser) authentication.getPrincipal(); String accessToken jwtUtil.generateAccessToken( loginUser.getUserId(), loginUser.getUsername(), loginUser.getRoles()); LoginVO vo new LoginVO(); vo.setAccessToken(accessToken); vo.setExpiresIn(1800); return Result.ok(vo); } }AuthenticationManager是从哪来的需要配一个 BeanBean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }认证失败时authenticate会抛BadCredentialsException或DisabledException需要配合全局异常处理器转成统一响应。注意不要把原始异常信息直接透给前端尤其不能区分用户不存在和密码错误——这会帮助攻击者枚举用户。统一返回用户名或密码错误即可。5.5 登录拦截与全局异常处理未登录访问受保护接口时SpringSecurity 会抛出AuthenticationException交给AuthenticationEntryPoint处理。权限不足时交给AccessDeniedHandler。这两个都要自定义否则返回的是框架默认的 HTML 错误页前端解析会炸。Component public class RestAuthEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest req, HttpServletResponse resp, AuthenticationException ex) throws IOException { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); } }把这两个处理器注册到配置里.exceptionHandling(ex - ex.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))。这个环节做好了前端才能根据 HTTP 状态码统一跳登录页。我见过不少项目这里没配前端拿到 403 的 HTML 页面判断逻辑直接失灵。6. 高频问题排查与避坑实录6.1 认证与 token 相关典型问题问题一token 一直提示签名不合法。排查顺序是密钥在签发和验证两端是否一致分布式部署时最容易两边配置不同、密钥长度是否够、算法是否一致。还有一个隐蔽场景是 Base64 编码的密钥在配置文件里被读取时带了换行或空格肉眼看不出来建议在初始化时打印密钥的哈希值做对比。问题二改了权限但用户还是老权限。因为 JWT 的 payload 是签发时写死的。如果权限存在 token 里用户权限变更后必须等 token 过期或强制重新登录。要立即生效就得在每次校验时补充查询数据库或者维护一个用户权限版本号token 里带上版本号版本不一致就拒绝。后者性能更好。问题三token 能解出内容是不是不安全。前面说过JWT 的 payload 是 Base64 编码不是加密。解决办法是别往里面放敏感数据或者用 JWE。这个认知纠正了很多人的误解。问题四登录接口被自己的拦截器拦了。因为没有把登录和刷新接口加入白名单。用requestMatchers(/api/auth/**).permitAll()放行注意路径匹配规则要写对加了 context-path 的情况容易漏。问题五跨域请求带不上 Authorization 头。前后端分离必踩的坑。后端要配置 CORS允许的 Header 里加Authorization并且allowCredentials和allowedOrigins不能同时用通配符*。前端 fetch/axios 要显式设置请求头。6.2 session 相关疑难杂症session 丢失 / 频繁掉线。最常见原因是多实例部署没有做 session 共享。解决办法是引入 Redis用spring-session-data-redis配置后 session 自动存 Redis多节点共享。另一个原因是 Cookie 的Domain和Path设置不对导致跨子域时带不上 Cookie。Session Fixation会话固定攻击。攻击者先访问系统拿到一个 sessionId然后诱导用户用这个 sessionId 登录登录后 sessionId 不变攻击者就能用同一个 ID 访问用户会话。防御办法是登录成功后重新生成 sessionId。现代框架默认已经做了这个处理但如果你自己手写登录逻辑就要显式调用request.changeSessionId()或先invalidate()再新建。这也是session fixation这个词在安全测试里经常出现的原因。session 占用 CPU 过高。如果通过监控工具看到某个进程的 session 管理相关操作吃满 CPU通常是两种原因一是 session 对象太大序列化和清理开销高二是 session 泄漏——代码里不断创建 session 却没有超时清理。排查时先看 session 的数量和平均大小正常情况下单个 session 不该超过几 KB如果发现有 session 塞了大对象比如把整个用户权限树、大列表放进去要优化存储。如何确认浏览器里的 session。前端调试时浏览器开发者工具的 Application 面板能看到 Cookie但HttpOnly的 Cookie 在 JavaScript 里读不到这是正常的不是 bug。后端要看当前 session 内容可以在调试接口里注入HttpSession打日志。手机浏览器的调试相对麻烦一般需要借助远程调试工具或者抓包来分析请求头里的 Cookie。6.3 JWT 安全加固清单风险点加固措施密钥太弱或硬编码使用 32 字节以上随机密钥配置中心或环境变量注入算法降级攻击签发和验证都显式指定算法拒绝 none 算法敏感信息泄露payload 不放密码、手机号等必要时用 JWEtoken 被长期盗用access token 短有效期 refresh token 轮换无法主动吊销维护 token 黑名单或用户 token 版本号重放攻击关键操作加一次性 nonce 或请求签名传输被劫持全站 HTTPS禁止 HTTP 回退这张表里的每一项我都见过对应的真实事故案例。尤其是无法主动吊销这条很多人设计时没考虑等用户投诉我改了密码为什么还能登的时候才回头补成本很高。6.4 常见问题速查表现象可能原因排查方向登录后立刻 401token 没存到前端 / 请求头没带看浏览器请求头是否有 Authorization权限注解不生效没加EnableMethodSecurity检查配置类注解认证通过但接口 403权限串前缀不匹配核对hasRole与数据库权限值token 续签后旧 token 还能用未做令牌轮换refresh 时吊销旧 token重启服务后用户全掉线session 存内存改 Redis 共享存储密码校验总是失败编码器不一致签发和校验用同一个 PasswordEncoder接口偶发 302 跳转认证失败走了默认跳转自定义 AuthenticationEntryPoint7. 最后聊几句实操体会这套认证授权体系我前后在四五个项目里落地过最大的感受是别追求一步到位按业务复杂度分层落地。项目初期就用 session 简单角色权限几十个用户完全撑得住等 C 端流量起来了、部署节点多了再平滑迁移到 JWT 双 token权限数据复杂到角色爆炸时再引入权限码模型。反过来小项目一上来就搞 JWT 黑名单 权限版本号 数据级权限维护成本会压垮你。还有一个体会是安全配置要写测试。认证授权最容易出问题的地方不是逻辑写错而是配置漏配——少放行了一个白名单路径、忘了加方法安全注解、Cookie 安全属性没开。这些都不会报错但埋着雷。给关键的权限拦截加上自动化测试用例比事后人工翻代码靠谱得多。如果后续要继续扩展比较值得深挖的方向有两个一个是把认证中心独立出来做成统一登录多个子系统共用另一个是在网关层做统一的 token 解析和权限粗筛业务服务只做细粒度校验这样能减少每个服务的重复工作。这两条路在系统规模变大后基本都会走到早点在架构上留好口子后面迁移会轻松很多。