简介:面向微服务安全场景,这套源码项目以 Spring Cloud Gateway 为统一入口,结合 OAuth2.0 授权协议与 JWT 无状态令牌,实现了登录认证、令牌签发、网关过滤和资源服务鉴权的完整链路。适合拥有 Spring Boot/Cloud 基础、正在搭建统一认证中台的开发者学习参考;压缩包共 270 个文件、大小仅 340KB,其中以 204 个 XML 配置文件与 56 个 Java 源码文件为主,另有少量 YAML 配置、SQL 脚本、PNG 结构图和 Markdown 说明,整体轻量且目录层次清晰。已有 7161 人学习下载;通过研读网关全局过滤器、Token 服务、授权服务器配置、用户信息转换器等核心代码,可以理解认证中心与业务服务如何协作;按照说明修改 Redis、JDBC 连接并部署 Nacos 后,即可运行认证服务和资源服务完成实际操作。从网关拦截到令牌校验,再到用户信息转换,核心链路均有对应源码对照,便于在毕业设计、项目初始化或微服务权限模块中快速上手。 干微服务最尴尬的阶段不是拆分的时候,而是拆完之后联调的那两周。服务间接口还没理清楚,认证授权先堵在路上了——每个接口都得知道"你是谁、你够不够格调我"。我接手过一个拆了十几个服务的项目,认证逻辑散得到处都是:A服务自己写了一套登录,B服务用Session,C服务直接明文传用户ID。后来我把这套逻辑统一收敛到网关层,用 SpringCloud Gateway + OAuth2.0 + JWT 把认证授权彻底抽出来,业务服务从此不再关心"你是谁",只关心"你要干什么"。
这篇文章就把这套方案的完整落地过程写清楚:为什么必须放在网关、认证链路怎么转、网关过滤器每步在验什么、上生产前要处理哪些坑。适合正在搭微服务基础架构、或者准备重构现有认证逻辑的团队参考,内容偏实操,能直接照着改。
1. 为什么把认证放在网关层,而不是每个服务各做一套
1.1 分散认证带来的连锁问题
很多人一开始觉得"每个服务自己做认证更灵活",但服务一旦多起来,分散认证的代价是成倍增长的。首先是重复代码,每个服务都要写一遍Token解析、校验、异常处理,同一个Bug要在十几个服务里各修一次。其次是密钥和会话状态难统一,有的服务用对称密钥,有的用非对称,你根本不知道哪个服务的Token泄露了该吊销哪个。最要命的是权限策略不一致,同一个接口在一个服务里要求ADMIN角色,在另一个服务里居然只校验登录态。
网关层做统一认证的核心逻辑很简单:所有外部请求必须经过网关,那在网关这一层把"你是谁"的问题解决掉,下游服务拿到的就已经是"可信身份"了。这就像公司前台,所有人进门先登记,登记完发个工牌,各个办公室看到工牌就知道这人能进不能进,不需要每个办公室自己再搞一套登记系统。
1.2 网关认证的两条技术路线
在Spring Cloud技术栈里,网关层做认证主流有两条路。
一条是引入 Spring Security 的 Resource Server 体系,配合 OAuth2.0 的资源服务器模式,网关通过 JwtDecoder 自动校验Token。这条路的好处是规范、严谨,Security内置了一大堆过滤器,但坏处也明显:配置复杂、报错信息晦涩,新手经常被过滤链的加载顺序绕晕,出了问题很难定位是哪个Filter拦截的。
另一条是自己在网关写一个 GlobalFilter,用 JJWT 或 hutool 之类的工具库解析Token,校验签名和过期时间。这条路代码量不大,逻辑完全可控,出了问题能直接看代码,也方便根据业务定制白名单和错误响应。缺点是安全细节要自己注意,比如算法混淆攻击、Token注入这些坑都得自己兜住。
我最终选的是第二条路。原因很实际:我们团队的微服务大部分是内部系统,不需要对接第三方开放平台,完整的OAuth2.0授权码流程用不上。真正用到的只是"签Token、验Token、传身份"这三件事,自己写一个过滤器更轻、更好维护。如果你要对接微信、GitHub这类第三方登录,那就得规规矩矩走OAuth2.0的授权码模式,网关这边用Resource Server会更稳。
1.3 我的最终技术选型
- 网关:SpringCloud Gateway,写全局过滤器做统一认证
- 令牌:JWT,RS256非对称签名,私钥只放在认证服务,网关只持有公钥
- 认证服务:独立的 auth-service,负责登录、发Token、刷新Token、注销
- 存储:Redis 放黑名单和刷新Token,用户基础信息放数据库
- 下游服务:通过网关透传的请求头拿到用户ID和角色信息
这套组合的核心理念是"认证服务只认密码,网关只认签名,业务服务只认请求头"。每一层只干一件事,出了问题看日志就能知道卡在哪一环。
2. 从登录到放行:认证链路是怎么转起来的
2.1 登录服务只负责发Token
整个链路的第一步发生在 auth-service。用户带着用户名密码请求登录接口,认证服务校验通过后,生成JWT返回给前端。这个服务的职责就到此为止——它不关心用户之后调了什么接口,也不维护任何会话状态。
很多人在这一步容易把OAuth2.0想复杂,其实在我们这种内部系统场景里,它本质上就是"用密码换Token"。签发Token的代码大致长这样:
String token = Jwts.builder() .setSubject(userId) .claim("username", user.getUsername()) .claim("roles", roleList) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7200_000)) .signWith(privateKey, SignatureAlgorithm.RS256) .compact();这里有几个设计细节值得注意。Subject字段我放的是用户ID,不是用户名,因为用户名可能变更,而ID是永久的。roles放在claim里,这样网关校验完可以直接把角色透传给下游,下游做细粒度权限判断时不用再查一次用户表。过期时间设的是两小时,这个值要根据业务场景调,太短用户频繁重新登录,太长Token泄露的风险窗口就大。
2.2 Token里到底该放什么、不该放什么
JWT的Payload是明文Base64编码的,任何拿到Token的人都能解出来看,所以敏感信息一律不能放。我见过有人把手机号、身份证号甚至密码摘要塞进claim,这等于把用户隐私打印在工牌上到处发。
| Claim | 是否建议放 | 原因 |
|---|---|---|
| 用户ID (sub) | 建议 | 身份唯一标识,下游需要 |
| 用户名 | 建议 | 展示和日志用,变更不频繁 |
| 角色列表 | 建议 | 网关统一从Token取角色做粗粒度授权 |
| 权限标识 | 视情况 | 权限变化频繁的慎放,Token内权限是快照 |
| 手机号/邮箱 | 不建议 | 明文可读,泄露面大 |
| 密码/密钥 | 绝对禁止 | 一旦Token泄露等于账号泄露 |
放角色的逻辑要提前想清楚:Token里的角色是"签发时刻"的快照,如果用户在下线期间被改了角色,旧Token在过期前依然带着老角色。接受这个现实,然后通过设置合理的过期时间来控制风险窗口,而不是试图让JWT变得可以动态撤销——那是后面要讲的另一个话题。
2.3 请求在网关的完整流转
Token签发之后,前端每次请求都会在Header里带上Authorization: Bearer <token>。请求到达网关时,全局过滤器先执行校验,校验通过后把用户信息写入新的请求头,再转发给下游服务。下游服务收到的是已经"洗白"过的请求,带着X-User-Id、X-Username、X-User-Roles这三个头。
这里要特别注意:下游服务绝对不能直接信任前端传的任何自定义 Header,因为请求在到达网关之前可能被伪造。网关在转发前应该把外部传入的这几个Header清掉,再写入解析出来的可信值。如果你拿到的基础代码没做这一步,赶紧补上——这是很常见的越权漏洞来源。
3. 网关过滤器里到底验什么
3.1 白名单机制要先于校验
网关过滤器第一步不是验Token,而是判断这个请求需不需要验。登录接口、注册接口、验证码接口、静态资源、网关自身的健康检查端点,这些都得放进白名单。白名单的匹配逻辑要支持前缀匹配和精确匹配,我用的是配置项驱动:
auth: whitelist: - /auth/login - /auth/refresh - /auth/captcha - /actuator/health - /public/**白名单这步写起来简单,但坑不少。最容易犯的错是白名单路径写得太宽,比如把/public/**写成了/**,等于所有接口都不鉴权了。还有路径大小写问题、上下文路径(context-path)没算进去导致匹配不上。我建议白名单匹配用 AntPathMatcher,并且启动时打印一份实际生效的白名单列表,方便核对。
3.2 JWT校验的四步走
白名单过了之后,过滤器开始验Token,核心逻辑是四步:
第一步,从 Authorization Header 里取出Token。注意Bearer前缀要正确解析,大小写要容错,取不到Header或格式不对直接返回401。
第二步,验签名。用网关持有的RSA公钥验证Token签名,验证失败直接拒绝。这一步是防伪造的关键,如果使用对称密钥HS256,公钥和私钥是同一个密钥,网关和认证服务都得持有它,泄露面就大了。用RS256之后,认证服务持私钥签发,网关只持公钥验证,私钥永远不出认证服务的门。
第三步,验过期时间。JWT的exp字段是签发时写死的,解析时校验当前时间是否在过期时间之前。这里有个细节:不同服务的服务器时间可能有偏差,校验时我会留一个60秒的时钟偏移余量,避免因为毫秒级时间差导致Token提前失效。
第四步,验签成功后把 claim 里的用户信息提取出来,写入转发请求的Header。
核心代码不复杂,GlobalFilter 的实现大致是:
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); if (whitelistService.matches(path)) { return chain.filter(exchange); } String token = resolveToken(request); if (StringUtils.isBlank(token)) { return unauthorized(exchange, "缺少访问令牌"); } try { Claims claims = jwtParser.parse(token); if (claims.getExpiration().before(new Date())) { return unauthorized(exchange, "令牌已过期"); } ServerHttpRequest mutatedRequest = request.mutate() .header("X-User-Id", claims.getSubject()) .header("X-Username", String.valueOf(claims.get("username"))) .header("X-User-Roles", String.valueOf(claims.get("roles"))) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (JwtException e) { return unauthorized(exchange, "令牌无效"); } } }3.3 下游服务怎么消费用户身份
网关透传请求头之后,下游服务要做的是把这些Header解析成统一的用户上下文对象。我习惯在common模块里提供一个工具类,比如UserContext.getUserId(),底层从RequestContextHolder取Header。这样业务代码不需要关心Header名,也不用每处都写@RequestHeader注解。
还有一个实战经验:如果用的是Feign做服务间调用,网关透传的身份信息默认不会跟着Feign请求走。你需要配置一个 Feign 的 RequestInterceptor,把当前请求里的用户信息Header复制到Feign的请求模板里,否则就会出现"网关认了身份,服务间调用却丢了身份"的诡异问题。
4. 真正上生产前必须处理的三个问题
4.1 Token续签:别让用户两小时掉一次线
JWT最大的痛点就是签发之后没法主动让它失效,所以续签策略要想清楚。我见过两种主流方案。
一种是Refresh Token方案,登录时同时发访问Token和刷新Token,访问Token两小时过期,刷新Token有效期七天存在Redis里。前端在收到401时静默调用刷新接口换新Token。这个方案体验好,但要额外处理刷新Token的存储和轮换。
另一种是滑动过期,只要用户在过期时间之前有操作,就重新签发一个更长有效期的Token。实现方式是在认证服务提供一个校验接口,网关发现Token剩余时间不足30分钟时,调用认证服务换新Token返回给前端。
我实际用的是Refresh Token方案。因为滑动过期有个问题——客户端必须把新Token带回来,这对前后端分离的项目意味着每次请求都要检查响应头是否有新Token,逻辑比较绕。Refresh Token的链路更清晰:访问Token过期 → 前端捕获401 → 带Refresh Token调刷新接口 → 拿到新Token重放原请求。
写刷新接口时要加一个并发控制,防止用户同时开多个Tab导致Refresh Token被并发刷新。我的做法是刷新时校验旧Refresh Token并立即删除,然后签发新的Refresh Token,这样同一个Refresh Token只能用一次。
4.2 注销与黑名单
JWT注销是个老大难,Token在客户端手里,服务端没法直接作废。最简单的办法是前端把Token丢掉,但这样服务端仍然会接受这个Token直到过期。对于注销敏感度高的场景(比如管理员踢人、改密码后强制下线),就得引入黑名单机制。
我的做法是在Redis里维护一个黑名单,key是Token的 jti(JWT ID),value是过期时间。用户注销或改密码时,把当前Token的jti写进黑名单,TTL设置为Token剩余有效期。网关过滤器在校验Token之后、放行之前,先查一下这个jti是否在黑名单里,在就直接拒绝。
Redis黑名单要注意清理策略,不能只靠TTL自动过期。高并发下大量注销会堆积很多Key,建议定期用Scan批量清理,而不是用Keys命令——Keys会阻塞Redis,生产环境是事故导火索。
4.3 密钥管理:从YAML里抠出来
签Token的私钥如果写死在配置文件里,第一个泄密点就是代码仓库。我见过不止一个项目把私钥直接提交到Git,然后整个内网都能解出Token。
正确的做法是私钥只放在认证服务的环境变量或专门的密钥管理服务里,网关只保留公钥。我们用的是JKS(Java KeyStore)文件,启动时通过spring.config.import从配置中心拉取,公钥部分则在网关配置里通过classpath:引用。密钥轮换要考虑新旧公钥同时生效的过渡期,最简单的手段是在JWT里带一个kid(Key ID)字段,网关根据kid选择对应的公钥解析,这样就能平滑切换密钥。
5. 踩坑记录:那些网上教程不会告诉你的细节
5.1 算法混淆攻击是最容易被忽略的洞
网上搜"JWT 安全漏洞",出现频率最高的就是算法混淆攻击。攻击原理是:如果服务端用RS256公钥验证签名,但解析库允许攻击者把算法改成HS256,而HS256使用同一个密钥既签名又验签,那攻击者就可以拿公钥当HMAC密钥来伪造Token。
防这个坑有两个层面。第一,在解析端明确指定只接受RS256,不要信任Token头里的alg字段;第二,升级到新版解析库,JWT库早在2018年就默认禁止了算法降级。我用的是JJWT 0.11.x,它会在解析时强制校验alg与预期的一致性。如果你接手的老项目还在用旧版库,先干这件事,比加什么复杂防护都值钱。
5.2 网关过滤器顺序与异常处理
SpringCloud Gateway的过滤器有优先级之分,GlobalFilter通过Ordered接口控制执行顺序。认证过滤器应该排在比较前面的位置,我用的是Ordered.HIGHEST_PRECEDENCE + 10。这里有个坑是 CORS 的处理顺序,如果CORS过滤器排在认证之后,跨域预检请求(OPTIONS请求)会先撞上认证过滤器,而没有自定义Header的OPTIONS请求会被直接打回,导致前端控制台报CORS错误。
解决方法是把OPTIONS请求也放进白名单,或者在认证过滤器里先判断HttpMethod.OPTIONS直接放行。另外,认证失败时返回的错误结构要保持统一,我封装了一个ResultWrapper,固定返回{code: 401, message: "..."}这样的JSON,方便前端统一处理。
5.3 真出问题时怎么排查
网关认证出问题,最有效的排查方式是把过滤器链上的日志打全。我会在认证过滤器的每个关键节点输出日志:请求路径、是否命中白名单、是否取到Token、验签是否通过、放行后透传了哪些Header。开生产环境日志时用debug级别,配合logging.level.org.springframework.cloud.gateway=DEBUG能看到完整的路由和过滤器执行链路。
有一类问题特别容易让人抓狂:同一套代码,本地环境好好的,测试环境一直401。最后查下来是网关和认证服务的系统时间差了五分钟,JWT的exp校验直接失败。给所有服务器统一启用NTP时间同步,这是我踩过最不值钱的坑。
另外提一句:网上的若依微服务版本代码用了类似的自定义网关过滤器思路,如果你用它起步,记得先检查它有没有做上面的Header清洗和算法锁定,这两个点很多人改代码时会弄丢。做微服务认证这件事,不是把代码跑通就算完,而是要把每一层的信任边界画清楚,然后守住它。
我在实际项目里把这套方案落地之后,最直观的变化是新增业务服务不再需要关心认证逻辑,新服务只需要解析请求头里的用户上下文就能干活。后续要扩展的话,可以把粗粒度授权的动态规则(比如按URL配置角色访问控制)从代码里挪到配置中心,网关每次校验时拉取规则,这样连角色变更都不需要改代码发版了。先把认证这层稳下来,后面做细粒度权限也好,做多租户也好,都只是在这个基座上往上搭。
本文还有配套的精品资源,点击获取