前阵子我帮一个团队做接口文档治理,碰到一个特别典型的场景:Swagger页面里接口列得整整齐齐,响应模型也标得清清楚楚,但真正开始联调的时候,每个人都得先去调登录接口拿Token,再手忙脚乱地复制到Postman或者命令行里。Token快过期时更抓狂——前端刚把请求发出去,后端直接甩一个401回来,又得重新去登录、重新复制、重新粘贴。一天下来,光来回倒腾Token就能耗掉半小时。
后来我在文档里接入了Token验证,把Swagger的Authorize机制用起来,联调体验直接拉高了一个档次。这篇文章就把完整的思路、配置步骤和踩过的坑整理出来,给正在做API文档治理、或者被"联调时Token满天飞"折磨的开发者一个可以直接参考的方案。内容以Spring Boot + springdoc-openapi为例,但原理部分对Springfox和纯OpenAPI规范同样适用。
1. 为什么非要在Swagger里做Token验证:联调现场的真实困惑
1.1 没有Token入口的文档,等于让每个调试者自备工具
先说一个我见过很多次的状态:团队里用Spring Boot做了个后台,Swagger依赖已经引入了,@Api、@ApiOperation这类注解也写得挺全,接口文档能正常打开。但只要一涉及需要登录态的接口,整个调试流程就变得非常别扭——前端同学要先去/login接口拿Token,然后打开Postman配置Authorization: Bearer xxx,再回到Swagger页面复制接口路径去发请求。后端同学更惨,同一个Token可能要在好几个环境之间切换,开发环境一个Token、测试环境又一个Token,稍不留神就复制串了。
这还只是效率问题。更深层的问题是,文档本身没有把"这个接口需要什么凭证"表达出来。新来的同事打开页面看到一排接口,完全不知道哪个是公开的、哪个需要Token、Token需要放在哪个Header里。有人照着文档去调/order/list,直接得到401,第一反应是"文档坏了",而不是"我还没带Token"。
1.2 把鉴权信息内建到"门禁系统"里才是正解
Swagger的真正价值不只是展示接口列表,它本身就能承载一个完整的"门禁"概念:每种认证方式被声明成一种安全方案,每个接口声明自己需要哪种安全方案。Swagger UI拿到这些声明后,会在右上角渲染出一个Authorize按钮。调试者只需要把Token填进去一次,之后在这个文档页面里发任何请求,Swagger都会自动把Token塞进对应的请求头,不用再反复复制粘贴。
我第一次用这个功能时的感受是:这才叫文档自洽。它不再是一份静态的接口清单,而是一个"你可以在上面直接跑通完整业务链路"的调试工作台。登录接口返回Token,复制到右上角的输入框里,后面的下单、查询、退款接口一路点到绿灯,整个过程一气呵成。
2. 先搞懂Swagger接收Token的两层机制:声明与作用域
2.1 第一层securitySchemes:告诉文档"我支持哪种凭证"
OpenAPI规范里,components下的securitySchemes字段用来声明整套文档支持的认证方案。你可以声明apiKey(把Token放在Header、Cookie或Query里),也可以声明http类型并指定bearer子类型(对应常见的JWT),还可以声明OAuth2、OpenID Connect等更复杂的方案。
以Spring Boot项目最常用的JWT场景为例,声明长这样:
Components components = new Components() .addSecuritySchemes("bearerAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") .in(SecurityScheme.In.HEADER) .name("Authorization"));这里有几个容易被忽略的点。name是Authorization,in是HEADER,这两个字段是配套的。如果换了位置——比如把in改成COOKIE——那UI渲染出来的输入框和实际请求注入位置都会变。bearerFormat只是一个提示性描述,告诉读者"这里填的是JWT格式的Token",Swagger UI本身不会去校验你填的内容到底是不是JWT,校验逻辑还是后端来做。
我见过不少人把这个配置理解成"配了之后Token校验就生效了",这是个很大的误区。securitySchemes只是"声明",它不会自动让你的后端接口开始校验Token。后端该写过滤器还是得写,Swagger只负责在文档侧把认证方式展示出来。
2.2 第二层securityRequirements:告诉接口"你需要哪种凭证"
如果说securitySchemes是"有哪些锁可以选",那securityRequirements就是"每个门上挂的是哪把锁"。在OpenAPI规范里,这个字段出现在两个层级:openapi.security表示全局默认要求,path.<method>.security表示某个具体路径的覆盖要求。
全局声明通常是这样:
new OpenAPI() .addSecurityItem(new SecurityRequirement().addList("bearerAuth"));这段代码的效果是:文档里所有接口默认都带上了"需要bearerAuth"这个锁。效果上你会看到每个接口旁边都多了一个小锁图标,点击以后会提示输入Token。
但这里就引出一个新的问题:如果你的登录接口本身不需要Token,而它又被全局securityRequirement锁住了,那调试者连登录都发不出去。所以接下来还需要掌握"覆盖"的手段,这个到第4节详细展开。
2.3 Swagger UI里Authorize按钮背后的处理逻辑
很多人点完Authorize按钮,感觉页面"什么都没发生",其实它干了三件事:
- 把输入的Token存到了浏览器的本地存储里,关联到当前文档的URL。
- 给后续所有符合要求的请求自动注入
Authorization头。 - 如果后端返回401,并且
WWW-Authenticate响应头存在,Swagger UI还可能会弹一次认证窗口,引导重新输入Token。
这个机制的直观理解就是:Authorize按钮相当于一个"门禁卡的打卡机",你打一次卡,后面路过每道门都自动刷卡。Token在有效期内,你可以连续调试一整个流程;Token失效了,后端返回401,你去右上角重新填一次就行,不需要改用其他工具。
明白了这两层机制,再去看配置代码就不会觉得它只是"抄过来能用"。遇到UI表现和预期不一致时,也知道该去检查声明,还是去检查作用域。
3. 基于Spring Boot接入Bearer Token:可直接复制的配置过程
3.1 依赖选型:Spring Boot 3.x与2.x的分水岭
我目前习惯用springdoc-openapi,因为它原生支持OpenAPI 3.0规范,和Spring Boot 3.x也配合得更好。依赖坐标如下:
Spring Boot 3.x项目:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.6.0</version> </dependency>Spring Boot 2.x项目:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.8.0</version> </dependency>如果你的项目还在用Springfox,配置方式会稍有不同,但原理一致:Springfox 3.0里通过Docket的securitySchemes()和securityContexts()方法完成同样的声明和作用域设置。我的建议是能迁移到springdoc就尽早迁移,Springfox维护节奏一直比较慢,新项目没必要从它起步。
3.2 配置类的最简写法:一个被Spring扫描到的类就够
下面的配置类可以直接放到主启动类同级的包下,无需额外扫描配置。加入这个类之后,重新启动项目,打开Swagger页面,右上角就会出现Authorize按钮。
package com.example.demo.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { final String securitySchemeName = "bearerAuth"; return new OpenAPI() .info(new Info() .title("模拟项目后台API") .description("内部管理系统接口文档,基于OpenAPI 3.0规范。") .version("v1.0.0")) .components(new Components() .addSecuritySchemes(securitySchemeName, new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") .in(SecurityScheme.In.HEADER) .name("Authorization"))) .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)); } }拆开看这段代码里最关键的几行:
addSecuritySchemes:把bearerAuth这个安全方案写进文档的组件区,UI据此渲染Authorize按钮。addSecurityItem(new SecurityRequirement().addList("bearerAuth")):给所有接口挂上默认的锁图标,这样每个接口都会自动带上Authorization请求头。
如果没有最后这一行,会出现什么现象呢?Authorize按钮依然存在,但接口上没有锁图标,你输入Token之后发出的请求也不会自动带上Token。这个坑很容易踩,后面会专门说。
如果还想用注解方式声明,不写Components,可以直接在配置类上这样写:
@Configuration @SecurityScheme( name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT" ) public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { Info info = new Info().title("模拟项目后台API").version("v1.0.0"); return new OpenAPI() .info(info) .addSecurityItem(new SecurityRequirement().addList("bearerAuth")); } }两种写法结果一致,按团队习惯选一种即可。建议优先用第一段里的代码式写法,因为它把整个配置都集中在一个可视的Bean里,后面想临时调整描述、修改Scheme类型时,一眼就能看到。
3.3 不同鉴权方式的选型对比:不要只想着Bearer
我见过不少团队无论什么场景都往Bearer JWT上靠,其实OpenAPI支持的安全方案远不止这一种。下面这张表是我实际项目里的选择参考:
| 鉴权方式 | OpenAPI声明方式 | 适用场景 | 注意点 |
|---|---|---|---|
| HTTP Bearer(JWT) | Type.HTTP+scheme=bearer | 前后端分离、SPA、移动端 | Token放Authorization头,前端需自行管理刷新 |
| API Key(Header) | Type.APIKEY+in=HEADER+name=X-Api-Key | 内部服务间调用、开放平台网关 | Header名必须和后端校验逻辑一致 |
| API Key(Query) | Type.APIKEY+in=QUERY+name=apikey | 老系统、第三方回调接口 | Token会出现在URL里,有泄露风险,不建议新项目用 |
| Basic Auth | Type.HTTP+scheme=basic | 内网工具、控制台管理端 | 明文传输配合HTTPS使用,密码复杂度不能低 |
| OAuth2授权码 | Type.OAUTH2+ flows配置 | 第三方登录、开放平台 | 配置量最大,要仔细处理回调地址 |
我自己的选择原则是:对外部调用方,优先考虑API Key;对内部用户体系,才考虑Bearer JWT。因为API Key语义直接,网关层好做统计和控制,而JWT适合承载用户身份与权限声明,用在"哪个用户访问了哪些接口"的审计场景里更顺手。
4. 权限作用域怎么配:全局锁、方法级覆盖与白名单
4.1 全局安全项:一条代码让所有接口默认带锁
最省心的做法是像上面那样,在OpenAPI对象上调用addSecurityItem。这么配之后,文档里所有接口一视同仁,全部要求携带Token。
但这个"省心"是有代价的。实际项目中,一定会有几个接口是公开的:登录、注册、发送验证码、健康检查、刷新Token……如果这些接口也默认带锁,调试者第一次打开文档连门都进不去。所以我更建议把全局安全项理解成一个"默认原则",而不是"最终配置"。先用它把大多数接口的鉴权要求声明好,再去想哪些路径需要放行。
4.2 方法级放行:登录接口不该出现锁图标
方法级放行的标准做法,是在Controller方法上加@SecurityRequirements注解,并把value清空:
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController { @Operation(summary = "登录,无需携带Token") @SecurityRequirements(value = {}) @PostMapping("/api/auth/login") public Result<String> login(@RequestBody LoginVO loginVO) { // 业务逻辑:校验用户信息后返回JWT return Result.success("eyJhbGciOi..."); } }加了@SecurityRequirements(value = {})之后,这个接口旁边的锁图标会消失,Swagger发请求时也不会给这个接口注入Token。这个空数组的语义是:"这扇门不要挂锁"。
有一个容易搞混的地方是@SecurityRequirement(单数)和@SecurityRequirements(复数)。单数注解是用来追加某个安全要求的,复数注解才是用来清空或者整体覆盖的。如果你在方法上写:
@SecurityRequirement(name = "bearerAuth")而类上有全局安全项,那么方法上的这个注解会覆盖全局配置,效果还是要求Token,而不是追加第二个要求。这个覆盖语义很关键——多个安全方案叠加时,OpenAPI规范里的默认语义是"OR"关系,即满足任意一个安全方案即可访问。但在注解层面的表现是"后者覆盖前者",建议务必做一次页面点击验证,而不是凭经验判断。
4.3 路径级控制与公开路径清单
除了方法级注解,你还可以在OpenAPIBean层面做更细的路径控制。思路是先不加全局限流,而是分别列出两类路径的要求:
public OpenAPI customOpenAPI() { List<SecurityRequirement> publicPaths = List.of(); List<SecurityRequirement> securedPaths = List.of(new SecurityRequirement().addList("bearerAuth")); return new OpenAPI() .components(new Components() .addSecuritySchemes("bearerAuth", bearerScheme())) .path("/api/auth/login", new PathItem() .get(new io.swagger.v3.oas.models.Operation() .security(publicPaths))) .path("/api/order", new PathItem() .get(new io.swagger.v3.oas.models.Operation() .security(securedPaths))); }这种做法适合路径数量比较少、并且希望把鉴权规则集中维护的场景。路径一多,直接操作PathItem反而会很啰嗦,这时候我仍然建议回到方法级注解,每个接口的鉴权要求和它的业务语义放在一起,可读性更好。
另外,无论用哪种方式,都要清楚:Swagger页面本身也有内部接口需要放行。springdoc默认的/v3/api-docs/**和/swagger-ui/**,在我接触的大部分项目里都可能被后端的安全配置拦住,这个放到第5节细说。
5. 后端拦截器与Swagger白名单的配合
5.1 拦截器放行哪些路径,才能避免"文档自断后路"
前面把Swagger侧配置好了,但文档能否真正可用,还取决于后端鉴权链路怎么处理对文档和认证相关路径的请求。我用Spring Boot的WebMvcConfigurer加拦截器(Interceptor)来举例:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/captcha", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**" ); } }这里有个非常容易踩的细节:拦截器的路径匹配默认是基于应用上下文路径的。如果你的项目配置了server.servlet.context-path=/myapp,那么访问地址会变成http://localhost:8080/myapp/swagger-ui/index.html,但拦截器的excludePathPatterns里写的却是/swagger-ui/**,匹配不上,最终结果就是文档页面白屏,或者接口文档能打开但测试请求被拦。
我遇到过好几个人在这个地方排查了很久,最后发现是上下文路径的问题。解决办法有两个:要么在excludePathPatterns里带上上下文前缀,要么让拦截器匹配路径时不包含前缀——配置方式取决于项目实际设置,关键是先在浏览器里确认一下文档的真实访问地址,再回头对比拦截器放行的路径。
5.2 401返回格式与Swagger弹窗的关系
Token过期后,Swagger UI到底能不能自动弹出重新认证的窗口,取决于后端返回401时有没有带WWW-Authenticate响应头。很多后端在做统一异常处理时,只返回一个JSON:
{ "code": 401, "message": "token expired", "data": null }这个情况下,Swagger UI一般不会弹窗口,它就只是一个普通请求失败。等你换成带WWW-Authenticate响应头的写法:
response.setHeader("WWW-Authenticate", "Bearer realm=\"access to the API\""); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);Swagger UI就会识别为一个Bearer认证挑战,弹出一个对话框让调试者重新输入Token。这个小改动可以明显减少联调时"401之后还要去右上角手动点一下"的步骤。
另外注意一下,如果后端使用的是Spring Security的过滤器链,异常处理不要直接返回一个重定向页面,否则Swagger UI拿到的是302而不是401,文档调试体验会很奇怪。我的习惯是统一在ExceptionHandler或OncePerRequestFilter里直接写JSON响应并设置好状态码和响应头。
5.3 传参方式差异:Bearer Token放在哪个Header
这一点算是在Swagger接入JWT时最容易犯迷糊的地方。OpenAPI声明里的scheme("bearer"),意味着UI会发送这样一个请求头:
Authorization: Bearer eyJhbGciOi...注意Bearer和Token之间是空格,不是其他分隔符。有些项目后端解析需求不严格,能容忍不带Bearer前缀的Token;但更多情况下,后端用的是SecurityContextHolder里框架预设的解析器,它严格要求Bearer前缀,缺了就认为是非法请求。
我见过有人在Authorize输入框里手滑多了一个空格,写成Bearer eyJ...(前面多了个空格),实际注入后变成两个空格,后端解析也会出问题。这类"肉眼看不见"的故障很耗时间,排查时直接在浏览器开发者工具里看请求体里的Authorization值,基本一眼就能定位。
6. 高频故障复盘:从Authorize按钮失灵到接口401
6.1 现象与原因对照:一张排查速查表
我把实际项目中反复出现的问题整理成了一张表,遇到问题时可以按图索骥:
| 现象 | 可能原因 | 排查链路 |
|---|---|---|
| Authorize按钮根本没出现 | SecurityScheme没有被加载,或配置类没被Spring扫描 | 检查配置类是否在扫描包内;检查springdoc依赖版本 |
| 按钮出现,但所有接口都没有锁图标 | 只声明了SecurityScheme,没加addSecurityItem | 在OpenAPI Bean里补全局安全项 |
| 锁图标灰色,点击后没有输入框 | Scheme类型声明有误,例如HTTP Bearer被配成了APIKEY | 核对type、scheme、bearerFormat |
| 填了Token发请求仍没有Authorization头 | 该接口被方法级@SecurityRequirements(value={})覆盖了 | 检查方法注解,确认是否误清了全局要求 |
| 走登录接口也要Token | 全局锁把公开接口也锁住了 | 对公开接口加@SecurityRequirements(value = {}) |
| 文档能打开,测试请求404或403 | 后端拦截器把文档相关路径拦了 | 检查中间件、网关、拦截器白名单 |
| 文档跨端口调试请求CORS报错 | 全局CORS配置与Swagger转发逻辑冲突 | 在CORS配置中放行Swagger UI origin和OPTIONS请求 |
| 后端收到Authorization但解析失败 | 填入了带空格或多余前缀的Token | 浏览器开发者工具检查实际请求头 |
6.2 CORS预检与Token解析的先后问题
如果你把Swagger UI部署在http://localhost:8081,后端API在http://localhost:8080,那么一次简单的POST请求会先触发一次OPTIONS预检。如果你的CORS配置里只处理了GET/POST,没有处理OPTIONS,或者过滤器链在预检请求阶段就去做Token解析,就会产生一种奇怪的现象:从Swagger页面发任何请求都是401,但从Postman发同样的请求却一切正常。
这是因为Postman不触发CORS预检,而浏览器会。解决办法不复杂:在CORS配置里明确放行OPTIONS方法,并确保预检请求不会进入Token校验过滤器。如果后端用的是Spring Security,记得写:
http.cors().and().csrf().disable();并且自定义CorsConfigurationSource,把允许的来源、方法、请求头都列清楚。这类问题在前后端分离场景里几乎必踩,和Token本身关系不大,但一旦混在一起,很容易被误判成"Swagger配错了"。
6.3 前置网关的特殊情况:请求路径别带错
如果项目里还有一层网关,比如通过Gateway做统一鉴权和路由转发,Swagger文档的地址可能是一套,后端服务的实际路径又换了一套。你登录时获取的Token没问题,但文档里的请求发出去之后,网关可能因为路由前缀变化、黑白名单规则,给Swagger的请求也套了一层额外的认证要求。
这种情况调试起来特别耗时间,因为问题不在Swagger配置,而在网关规则。我的建议是:在网关层尽量用统一前缀区分Swagger流量和后端真实流量,例如:8080/doc/swagger-ui/index.html是文档入口,/api/**是业务入口。然后在网关的过滤器里对/doc/**明确放行,避免"文档请求被网关二次拦截"这种八竿子打不着的关系。
6.4 把文档当成"产品"来维护:最后的收尾经验
配置完Token验证之后,我通常还会多走两步,防止以后再出问题:
- 用MockMvc写一个简单的冒烟测试,把所有公开接口拉出来跑一遍,确认没有Token也能正常返回业务码,而不是被安全链路拦截。
- 给文档的
description里补充一句"如何获取Token"的说明,附上登录地址和Token放置位置。
这看起来是小事,但非常重要。文档是给团队所有人用的,不是只给后端自己看的。第一次打开页面的人,如果能从描述里直接知道"先登录,再点右上角Authorize,把Token粘贴进去",整个团队的联调效率都会上一个台阶。
我个人现在养成的习惯是:每次给新项目加Swagger,都会在第一版就把SecurityScheme、SecurityRequirement和拦截器白名单一起配好,而不是等项目上线后被人喊"文档没法调接口"再回头补。Token验证这块,狠心花半小时一次配到位,省下来的时间远不止半小时。