登录认证和权限授权这两件事基本上每个后端项目都绕不开也是我最怕看到先跑通再说的地方。跑通很容易一个拦截器加一个 token 校验就够了但真到了要区分角色、要支持多端登录、要在集群里保持状态、要防止别人拿着抓包工具改几个字段就混进来的时候前面偷的懒会一次性全部还回来。这篇就围绕 SpringSecurity、JWT、session 这套经典组合把我自己在实际项目里趟过的路、踩过的坑和最后沉淀下来的做法整理一遍。不管你是刚开始接触权限模块的新手还是已经用过但总觉得哪里不踏实的开发者应该都能从里面捞到点能直接抄的东西。先说清楚这篇讲的是什么一个典型的 Web 后端认证授权体系认证解决你是谁授权解决你能干什么。认证层用 SpringSecurity 兜底过滤器链凭证载体在两个方向里选——无状态的 JWT 和有状态的 session授权层用角色 / 权限码配合注解做方法级拦截。适合谁看写过登录接口但没系统梳理过权限模型的同学以及手里项目马上要加权限、想少走弯路的同学。我会尽量把每个选择背后的原因讲透而不是甩一段配置让你照抄。1. 认证授权方案的整体设计与选型思路1.1 先把两个概念拆干净认证和授权不是一回事很多人把这两个词混着用结果代码里login和hasPermission搅在一起后面越写越乱。我习惯用进小区的类比来解释认证就是门口保安核对你的脸和门禁卡确认你是这个小区的业主授权是你进了小区之后能开自己家的门但开不了别人家的门也进不去设备间。两者的输入输出完全不同——认证的产出是一个身份凭证授权的产出是一个允许 / 拒绝的布尔结果。这个区分落到代码上边界就清晰了认证相关的逻辑放在UserDetailsService和登录接口里负责查库、校验密码、发放凭证授权相关的逻辑放在过滤器和注解里负责拿到凭证后解析身份再判断这个身份有没有访问当前资源的资格。SpringSecurity 本身也是按这个思路设计的AuthenticationManager管认证AccessDecisionManager新版本里是AuthorizationManager管授权两者通过SecurityContext串起来。你要是把这两块职责混在一处写后面加一个登录后强制改密码或者管理员可以模拟登录的需求就会发现无从下手。还有一个容易被忽略的点认证失败和授权失败在 HTTP 语义上是不一样的。没带凭证或者凭证过期应该是 401带了凭证但权限不够应该是 403。很多项目图省事全返回 200 加一个code: 500前端就得分情况猜联调的时候天天扯皮。我在项目初期就把这两个状态码定死前端只按状态码做跳转逻辑省掉大量沟通成本。1.2 三种会话保持方式本质差异在哪JWT 和 session 经常被拿来对立讨论其实它们解决的是同一个问题的两种思路服务端怎么在多次请求之间认出同一个用户。session 的做法是服务端存一份数据客户端只拿一个没有含义的 IDJWT 的做法是把数据本身放进凭证里服务端不存靠签名保证内容没被改过。对比维度SessionJWT无状态JWT Redis有状态数据存放位置服务端客户端客户端 服务端服务端存储压力有随在线人数增长无有但可精确控制横向扩展需要共享存储天然支持需要共享存储主动踢人 / 强制下线容易困难容易凭证体积小几十字节大几百字节起大跨域携带依赖 Cookie较麻烦走 Header方便走 Header方便典型适用场景传统服务端渲染、内部系统移动端、开放 API前后端分离的主流选择这张表我建议每个做权限的同学都自己画一遍画完很多纠结就自然消解了。JWT 最大的优势是无状态最大的劣势也是无状态——你没法撤回一个已经发出去的 token除非引入额外的黑名单或白名单机制而一旦引入它就不再是无状态的了。很多人吹 JWT 的时候只讲前半句不提后半句这是不负责的。1.3 我在中小项目里的默认选型说结论前后端分离的项目我默认选 JWT 存 access token Redis 存刷新凭证的组合传统服务端渲染或者公司内网系统我直接上 session不折腾。理由很实在——前后端分离场景下前端可能跑在完全不同的域名甚至不同端H5、小程序、AppCookie 的跨域携带和 SameSite 策略会带来一堆莫名其妙的登录态丢失问题Header 里带 token 反而最省心。而为什么要在 JWT 之外再加一层 Redis因为纯无状态 JWT 有三个绕不过去的硬伤一是无法主动失效用户改了密码或者管理员封了号旧 token 在过期前依然有效二是刷新体验差要么让用户频繁重新登录要么把过期时间设得很长安全性又下去了三是权限变更无法即时生效你把某个用户的角色降级了他手里的 token 里还写着老角色。所以我的做法是access token 有效期设短比如 30 分钟只用来扛正常请求refresh token 有效期 7 天存 Redis用户来换新 token 的时候校验一次 Redis 里的记录同时顺便检查这个用户有没有被拉黑。登出的时候删掉 Redis 里的 refresh token达到准主动下线的效果。这个方案不纯粹但工程上非常好用成本也就多一个 Redis key 的事。提示不要为了追求架构上的纯粹无状态而牺牲可运维性。能主动踢人、能查在线用户、能审计登录记录这些在真实项目里比架构洁癖重要得多。2. SpringSecurity 过滤链拆解与基础配置落地2.1 过滤器链的执行顺序决定了你的逻辑什么时候生效SpringSecurity 的核心就是一个过滤器链请求进来之后依次穿过这些过滤器任何一个环节判定不通过就直接返回不会走到 Controller。理解顺序比背 API 重要得多因为很多我的自定义过滤器不生效跨域配置没起作用的问题根源都是顺序。大致的执行顺序是这样的SecurityContextPersistenceFilter新版是SecurityContextHolderFilter先把上下文从存储里恢复出来然后UsernamePasswordAuthenticationFilter处理表单登录接着BearerTokenAuthenticationFilter之类的处理 token 认证再往后是ExceptionTranslationFilter负责把认证授权异常翻译成 401/403最后FilterSecurityInterceptor新版对应AuthorizationFilter做最终的授权判定。实操中最常见的两个坑第一跨域过滤器如果放在 SpringSecurity 的过滤器链后面预检请求 OPTIONS 会被拦下来返回 401前端看到的就是跨域失败其实根本不是跨域问题。解决办法是让 CORS 过滤器排在认证之前http.cors()开起来同时把.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()放开。第二自定义的 JWT 过滤器要用addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)插进去别用addFilter否则位置不确定行为会很诡异。顺带说一句放行路径的写法新版 SpringSecurity 用的是requestMatchers老版本是antMatchers网上很多教程还是老写法直接抄会编译不过。放行清单我一般包含登录、注册、验证码、公开的静态资源、健康检查、以及 OPTIONS 预检。这个清单越短越好我见过有人图省事直接.anyRequest().permitAll()等于把大门拆了。2.2 认证管理器与 UserDetailsService 的真实配合方式AuthenticationManager的工作流程是这样的你传进去一个未认证的Authentication通常是用户名密码它委托给配置好的AuthenticationProvider而DaoAuthenticationProvider会调用你实现的UserDetailsService去加载用户拿加载出来的密码和传进来的密码做比对比对通过后返回一个已认证的Authentication。这个链条里有两个地方最容易出问题。第一个是密码编码器不一致。你的UserDetailsService返回的是数据库里的 BCrypt 密文而PasswordEncoder配的是NoOpPasswordEncoder比对必然失败而且报错信息很模糊你只会看到用户名或密码错误。我的做法是全局只允许存在一个PasswordEncoderBean用 BCrypt注册和登录都走它不给第二种可能。第二个是UserDetailsService里抛异常。用户名不存在的时候标准做法是抛UsernameNotFoundException而不是返回 null。返回 null 的话SpringSecurity 内部会包一层最后呈现出来的日志可能让你完全摸不着头脑。Service public class DbUserDetailsService implements UserDetailsService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public DbUserDetailsService(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper userMapper; this.passwordEncoder passwordEncoder; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectByUsername(username); if (user null) { // 统一抛这个异常别返回 null throw new UsernameNotFoundException(用户不存在); } if (user.getStatus() 0) { throw new DisabledException(账号已被停用); } ListString perms userMapper.selectPermCodesByUserId(user.getId()); ListGrantedAuthority authorities perms.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities); } }注意这里权限码的加载。我直接把权限码字符串塞进GrantedAuthority后面在注解里用hasAuthority(user:add)判断。角色和权限我建议分开存角色是粗粒度分组一个人是运营还是财务权限是细粒度动作订单导出还是订单退款。只做角色控制的话加到十几个角色之后会失控每次加需求都得新建角色。2.3 授权决策与注解体系的实际用法授权这块SpringSecurity 提供了三层能力URL 级别的配置、方法级别的注解、以及自定义的表达式。我实际项目的做法是粗粒度用 URL 配置兜底细粒度全用方法注解理由是权限点跟着业务方法走代码可读性最好谁负责哪个接口一目了然。方法注解要在配置类上加EnableMethodSecurity旧版是EnableGlobalMethodSecurity(prePostEnabled true)然后就可以在 Service 方法上写PreAuthorize(hasAuthority(order:refund)) public void refund(Long orderId) { ... } // 支持 SpEL可以拿到方法参数做数据级校验 PreAuthorize(permChecker.canAccessDept(#deptId)) public DeptVO getDept(Long deptId) { ... }第二种写法是重点。很多系统做到接口级权限就停了但真实业务里经常需要数据级权限——同一个查看订单接口A 只能看自己部门的单子B 能看全公司。这种逻辑塞不进hasAuthority得靠自定义 Bean 写判断。我一般会起一个permChecker之类的组件把数据归属判断收在里面这样权限逻辑集中改起来不至于到处翻。注意PreAuthorize失效最常见的原因是同类内部方法调用。A 方法调 B 方法B 上的注解不生效因为走的是this调用而不是代理对象。要么把 B 挪到另一个 Bean要么注入自己。3. JWT 的生成、校验与防篡改实战3.1 三段结构拆解与签名算法怎么选JWT 长这样xxxxx.yyyyy.zzzzz三段分别是用 Base64Url 编码的头部、载荷和签名。头部声明算法类型载荷放业务数据标准的叫 claim签名是对前两段加上密钥计算出来的结果。这里有个必须说三遍的事——Base64 只是编码不是加密任何人把中间那段拿去做个 Base64 解码就能看到里面的明文。所以载荷里绝对不能放密码、身份证号、手机号这类敏感信息。我见过有人把整个用户对象塞进去这就等于把用户资料挂在了公网上。签名算法上HS256 是对称加密用同一个密钥签名和验签RS256 是非对称私钥签名、公钥验签。什么时候选哪个单体应用或者内部服务用 HS256 完全够简单快。如果是多个服务都要验签、但只有认证中心能签发的情况用 RS256把公钥分发下去避免密钥到处扩散——密钥扩散得越广泄露概率越高。密钥本身的管理也得当回事。硬编码在配置文件里是及格线至少别提交到代码仓库更好的做法是走环境变量或者配置中心并且确保密钥有足够的长度HS256 建议至少 256 位也就是 32 字节以上。我见过用secret当密钥的项目这跟没签名区别不大。3.2 生成与解析的核心代码用 jjwt 这套库写起来挺直接的我贴一份自己常用的工具类结构Component public class JwtTokenProvider { private final SecretKey key; private final long accessExpireMs; public JwtTokenProvider(Value(${jwt.secret}) String secret, Value(${jwt.access-expire-ms:1800000}) long accessExpireMs) { // HS256 要求密钥长度不低于 256 bit this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.accessExpireMs accessExpireMs; } public String createAccessToken(Long userId, String username, ListString perms) { Date now new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(perms, perms) .setIssuedAt(now) .setExpiration(new Date(now.getTime() accessExpireMs)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parse(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }解析方法这里有个细节parseClaimsJws在签名不对或者 token 过期时会抛JwtException必须捕获并转成未认证状态别让它冒到全局异常处理器变成一个 500。我在项目里会专门捕获ExpiredJwtException和SignatureException分开处理——前者走刷新逻辑后者直接当作攻击尝试记一笔日志因为正常情况下客户端不可能拿到一个签名错误的 token。3.3 防篡改、防重放、防越权的三道防线热词里在问JWT 如何防止数据被篡改这个问题的答案其实就藏在签名里。你改了载荷里的userId签名就对不上了服务端验签直接失败。所以防篡改这件事只要你不关掉验签、不把parseClaimsJws换成parseClaimsJwt这个不带签名校验用它等于自废武功就已经做到了。但防篡改只是最低要求真正难的是防重放和防越权。防重放的意思是别人拿到了你的 token 直接复用这个光靠签名防不住因为 token 本身是合法的。常见的几种缓解手段一是缩短有效期把窗口压到几十分钟二是给 token 加jti唯一 ID服务端对敏感操作做一次性的消费记录三是对特别敏感的操作要求二次验证比如改密码、大额转账时重新输密码。第三种虽然麻烦但安全性提升最明显金融类系统基本都这么干。防越权这块我踩过最深的坑是信任 token 里的权限。有段时间我把用户的角色列表放进了 token接口里直接读claims.get(perms)来判断结果运营给某个用户降级之后他手里的旧 token 在过期前还是能拿到管理员权限。后来改成token 里只放userId这类不变的身份标识权限每次从缓存或数据库读缓存加个短 TTL。多一次查询但换来了权限变更的即时生效这个交换很划算。// 不推荐权限写死在 token 里 boolean canEdit claims.get(perms, List.class).contains(user:edit); // 推荐token 只带身份权限实时查 Long userId Long.valueOf(claims.getSubject()); SetString perms permCache.get(userId); // 缓存 TTL 5 分钟 boolean canEdit perms.contains(user:edit);3.4 Token 续签的两种落地方案续签是我被问得最多的问题之一。用户正在填一个长表单token 突然过期了提交时跳回登录页体验极差。两种做法我都在项目里用过。第一种是滑动过期每次请求进来如果 token 距离过期还有不到一半时间就签发一个新 token 放在响应头里前端拦截器拿到就替换本地的。这个方案改动小但有个副作用——只要用户一直有操作token 就永远不过期安全边界被拉平了。所以要配合一个绝对过期时间比如无论怎么滑动登录后 12 小时必须重新登录。第二种是双 tokenaccess token 短命30 分钟refresh token 长命7 天且只存在服务端。前端发现 401 就用 refresh token 换一对新的换的时候服务端检查 refresh token 是否在 Redis 白名单里不在就拒绝。这个方案能精确控制下线也能记录刷新日志是我更推荐的做法。实现上要注意刷新接口本身要做并发控制多个请求同时 401 的时候可能触发多次刷新一般让前端加个队列刷新完再重放挂起的请求。// 前端 axios 拦截器骨架避免并发刷新 let refreshing false; let queue []; instance.interceptors.response.use(null, async (err) { const { config, response } err; if (response?.status ! 401 || config._retry) return Promise.reject(err); if (refreshing) { return new Promise((resolve) queue.push({ resolve, config })); } refreshing true; config._retry true; try { const newToken await doRefresh(); queue.forEach(({ resolve, config }) { config.headers.Authorization Bearer ${newToken}; resolve(instance(config)); }); queue []; return instance(config); } finally { refreshing false; } });4. Session 方案的真实适用场景4.1 Session 存储选型与集群共享Session 方案的核心就一句话服务端存数据客户端拿 ID。看起来简单但在多实例部署的时候立刻出问题——用户第一次请求打到 A 机器session 存在 A 的内存里第二次请求被负载均衡打到 B 机器B 说没见过这个 session ID用户就被踢回登录页了。解决办法有三种。一是粘性会话让同一个用户的请求固定打到同一台机器配置简单但机器挂了会话就丢扩缩容也不方便只适合临时方案。二是会话复制机器之间互相同步 session节点一多同步开销爆炸不建议。三是集中存储把 session 放到 Redis 里所有节点共享这是我现在唯一会用的方案。Spring Boot 里接 Redis 存 session 只需要加依赖和几行配置spring: session: store-type: redis timeout: 30m redis: namespace: app:session flush-mode: on_save data: redis: host: 127.0.0.1 port: 6379配上EnableRedisHttpSession注解就完事了业务代码一行都不用改。这里有个细节值得留意flush-mode设成on_save可以避免每次请求都全量写回 Redis减少无谓的网络开销只在 session 真正被修改时才写。另外namespace一定要设不然多个应用共用同一个 Redis 实例的时候 key 会互相踩。4.2 Session 固定攻击的防御思路Session 固定攻击Session Fixation的原理是攻击者先拿到一个合法的 session ID想办法让受害者用这个 ID 去登录登录之后服务端把身份绑在了这个 ID 上攻击者拿着同一个 ID 就能直接访问受害者的账号。这个攻击成立的前提是服务端在登录前后不换 session ID。SpringSecurity 默认已经帮你处理了它会在认证成功时创建新的 session 并迁移数据配置项是sessionManagement().sessionFixation().migrateSession()旧版本默认就是这个。但如果你自己写了登录接口绕过了 SpringSecurity 的认证流程比如手动往SecurityContextHolder里塞Authentication那这个默认行为就不生效了必须自己手动调用request.changeSessionId()。// 自定义登录接口里的正确写法 PostMapping(/login) public Result login(RequestBody LoginDTO dto, HttpServletRequest request, HttpServletResponse response) { Authentication auth authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword())); // 关键一行登录成功后换掉 session ID防固定攻击 request.changeSessionId(); SecurityContextHolder.getContext().setAuthentication(auth); // 显式保存上下文确保写入 session securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response); return Result.ok(); }这个小动作很多人不知道出了安全问题才知道后悔。我个人的习惯是只要涉及会话状态的变更登录、提权、切换租户都要重新生成会话标识。4.3 本地会话资源占用异常的排查思路热词里有个local session manager 占用 CPU 过高这个是 Windows 系统层面的本地会话管理服务跟 Web 应用的 session 完全是两码事但既然很多人搜到我也顺带说一下排查思路免得混淆概念。Local Session Manager是 Windows 里负责管理远程桌面和本地登录会话的服务。它 CPU 飙高通常不是它自己的问题而是有人在频繁建连断连。排查顺序我一般是先看任务管理器里的详细信息确认是lsass.exe还是svchost.exe挂载的这个服务再查系统日志里的安全事件看有没有大量重复的登录失败记录这往往意味着有程序在尝试暴力枚举接着检查有没有第三方的远程管理软件在后台反复重连最后考虑是不是系统更新后的驱动兼容问题重启或者回滚更新能缓解。这里要提醒一句Windows 的服务和 Java 应用里的HttpSession没有任何关系搜索资料的时候别被关键词带偏浪费半天时间。5. 常见问题与排查技巧实录5.1 认证类问题速查表认证这块的问题表现都很像——登录不上、登录上又被踢、接口时不时 401。但原因差得远我整理了一张表基本覆盖了我遇到过的九成情况。现象常见原因排查动作登录一直提示用户名或密码错误密码编码器不一致存的是 BCrypt 校验用的是别的确认全局只有一个 PasswordEncoder且注册登录都走它登录成功但下一个请求就 401token 没放进请求头或者前缀多了 / 少了 Bearer抓包看请求头确认格式是Authorization: Bearer xxx时不时 401刷新一下又好多实例部署JWT 密钥不一致检查各实例的密钥来源是否统一跨域请求全部失败OPTIONS 预检被安全过滤器拦了放行 OPTIONSCORS 过滤器前置手机上登录态莫名其妙丢失Cookie 的 SameSite 策略限制了跨站携带前后端分离场景建议改用 Header 带 tokentoken 明明没过期却验签失败密钥被重新生成过或字符编码不一致固定密钥来源统一用 UTF-8 取字节这张表我建议贴在项目文档里新人上手能少问一半问题。特别是第一条我见过太多次了因为 SpringSecurity 在密码不匹配的时候统一返回Bad credentials不会告诉你到底是用户不存在还是密码错了光看日志根本定位不到。5.2 授权异常与 403 排查403 的排查比 401 容易因为方向明确身份是认出来了就是不让你干。常见的几个原因。第一个是权限码对不上注解里写的是user:add数据库里存的是user:create一个字符的差别查半天。我的习惯是权限码统一用常量类管理数据库初始化的 SQL 也从常量类对应的文档里生成避免手写。第二个是权限没加载进去UserDetailsService里查权限的 SQL 写错了关联条件返回空列表结果就是所有人都是零权限。第三个是PreAuthorize没生效忘了加EnableMethodSecurity或者方法被同类内部调用绕过了代理。还有一种比较隐蔽的权限判断通过了但数据层面还是查不出来。比如接口能访问但返回的列表是空的这通常是数据权限的过滤条件写错了或者多租户的租户 ID 没带进查询。这种问题排查起来最费劲因为不报错。我的做法是在数据权限的过滤逻辑里加 debug 日志把最终拼出来的条件打出来比对一下就知道哪里的条件多加了或者少加了。提示把权限不足的场景统一返回无权访问该资源不要返回权限不足需要 xxx 权限。后者等于告诉攻击者你的权限模型长什么样属于不必要的信息泄露。5.3 几个踩过的坑第一个坑是关于免认证路径的配置。早期我图省事用通配符放行了一大批路径结果某次加了个新的内部接口路径刚好落在通配符范围内直接暴露在外网。后来我改成白名单最小化并且加了一个测试用例遍历所有 Controller 的映射检查除了显式声明的公开接口外其余接口在匿名访问时都必须返回 401。这个测试写起来不复杂但价值极高等于给权限配置上了个保险。第二个坑是登出逻辑。JWT 方案下登出不能让服务端删除token因为 token 在客户端手里。我最初的做法是前端删掉本地存储就算登出后来发现这样在共享设备上很危险而且服务端完全无感知。改进后的做法是登出时把 token 的jti写进 Redis 黑名单过期时间设为 token 的剩余有效期过滤器里顺便查一下黑名单。多一次 Redis 查询但能实现真正的登出。第三个坑是时钟问题。JWT 的过期时间依赖服务器时间如果部署在多台机器上而机器之间的时间有偏差就会出现刚签发就过期或者过期了还认的情况。解决办法很简单所有机器统一走 NTP 同步部署脚本里加一步时间校验。这个坑不大但排查起来绕因为本地测试永远复现不了。最后一个是我觉得最值得说的经验权限模块千万不要等到业务写完再补。我参与过的一个项目就是先做了三个月业务最后临时加权限结果每个接口都要回头改还漏了几个上线后被安全扫描扫出来一批未授权访问。正确顺序是先定权限码规范、定好拦截骨架、把白名单机制跑通业务接口照着模板写后面加权限点只是往表里插数据的事。如果你现在手里的项目还没做权限我的建议是先花半天把拦截骨架搭起来哪怕先只放行所有请求把规范和结构定下来也比业务铺开之后再补要轻松十倍。这套东西后续还能自然扩展加数据权限、加多租户隔离、加审计日志都是在现有的过滤器链和权限码体系上做加法不用推倒重来。我自己后来加数据权限的时候因为前面的骨架留了口子只改了一个自定义判断组件就接上了几乎没动业务代码。