☰
SpringSecurity+JWT+Session认证授权体系设计
2026/10/1 11:55:14 网站建设 项目流程

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,双 token(access 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 脚本读取 Cookie;Secure属性要求 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 或 RS256),Payload 放声明数据,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-security,JWT 部分我用jjwt这个库,它 API 清晰、维护活跃。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>

注意jjwt从 0.10 版本开始拆成了三个包,api是编译期用,impl和jackson是运行期用。很多人只引api结果运行时报找不到实现类,就是漏了后面两个。

接下来配置安全策略。新一代 Spring Boot(3.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, List<String> 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(); List<String> roles = claims.get("roles", List.class); List<SimpleGrantedAuthority> 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 Result<LoginVO> 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是从哪来的?需要配一个 Bean:

@Bean 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;charset=UTF-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 不放密码、手机号等,必要时用 JWE
token 被长期盗用access token 短有效期 + refresh token 轮换
无法主动吊销维护 token 黑名单或用户 token 版本号
重放攻击关键操作加一次性 nonce 或请求签名
传输被劫持全站 HTTPS,禁止 HTTP 回退

这张表里的每一项我都见过对应的真实事故案例。尤其是"无法主动吊销"这条,很多人设计时没考虑,等用户投诉"我改了密码为什么还能登"的时候才回头补,成本很高。

6.4 常见问题速查表

现象可能原因排查方向
登录后立刻 401token 没存到前端 / 请求头没带看浏览器请求头是否有 Authorization
权限注解不生效没加@EnableMethodSecurity检查配置类注解
认证通过但接口 403权限串前缀不匹配核对hasRole与数据库权限值
token 续签后旧 token 还能用未做令牌轮换refresh 时吊销旧 token
重启服务后用户全掉线session 存内存改 Redis 共享存储
密码校验总是失败编码器不一致签发和校验用同一个 PasswordEncoder
接口偶发 302 跳转认证失败走了默认跳转自定义 AuthenticationEntryPoint

7. 最后聊几句实操体会

这套认证授权体系我前后在四五个项目里落地过,最大的感受是:别追求一步到位,按业务复杂度分层落地。项目初期就用 session + 简单角色权限,几十个用户完全撑得住;等 C 端流量起来了、部署节点多了,再平滑迁移到 JWT 双 token;权限数据复杂到角色爆炸时,再引入权限码模型。反过来,小项目一上来就搞 JWT 黑名单 + 权限版本号 + 数据级权限,维护成本会压垮你。

还有一个体会是安全配置要写测试。认证授权最容易出问题的地方不是逻辑写错,而是配置漏配——少放行了一个白名单路径、忘了加方法安全注解、Cookie 安全属性没开。这些都不会报错,但埋着雷。给关键的权限拦截加上自动化测试用例,比事后人工翻代码靠谱得多。

如果后续要继续扩展,比较值得深挖的方向有两个:一个是把认证中心独立出来做成统一登录,多个子系统共用;另一个是在网关层做统一的 token 解析和权限粗筛,业务服务只做细粒度校验,这样能减少每个服务的重复工作。这两条路在系统规模变大后基本都会走到,早点在架构上留好口子,后面迁移会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询