1. 这不是加个注解就能跑通的事:Swagger2与Spring Security集成的真实水深
很多人看到“Swagger2集成Spring Security”这个标题,第一反应是翻出官方文档,照着抄几行@EnableWebSecurity和@EnableSwagger2,再加个permitAll()放行/v2/api-docs和/swagger-ui.html——然后发现登录接口在Swagger里点不动,点击“Try it out”直接弹401,或者连UI页面都加载不出来,控制台报一堆AccessDeniedException。我第一次接手某高校实验室的模拟项目X时,也是这么干的。导师只说“把接口文档和权限系统对齐”,结果我花了整整三天,卡在同一个地方:Swagger UI能打开,但所有带@PreAuthorize("hasRole('ADMIN')")的接口,在UI里点执行就返回空响应,F12看Network全是302跳转到/login,压根没走到Controller。后来才发现,问题根本不在配置顺序,而在于我们默认把Swagger当成了“静态文档生成器”,却忽略了它本质上是一个运行在Spring MVC容器内的、需要完整参与请求生命周期的动态Web应用。它要调用你的接口,就必须像真实前端一样,携带合法的认证凭证;而Spring Security的默认配置,恰恰把Swagger的资源路径(比如/swagger-resources/**)和代理路径(比如/webjars/**)当作了需要保护的普通业务路径。更隐蔽的是,很多教程教你在WebSecurityConfigurerAdapter里重写configure(WebSecurity web)方法,把/v2/api-docs等路径ignoring()掉,这看似解决了UI加载问题,实则埋下巨大隐患:一旦你后续启用了CSRF防护或Session策略,这些被忽略的路径会绕过所有安全过滤器链,形成事实上的安全缺口。所以,这不是一个“加几个白名单”的配置题,而是一场关于认证上下文传递、资源路径语义划分、以及前后端交互契约重构的系统性工程。核心关键词就三个:统一认证入口、认证凭证透传、资源路径语义隔离。这篇文章不讲怎么让Swagger“看起来能用”,而是带你从零开始,亲手搭起一套真正健壮、可审计、且符合生产环境规范的集成方案。
2. 认证体系的底层逻辑:为什么“忽略路径”是饮鸩止渴
要真正解决问题,必须先理解Spring Security的过滤器链(Filter Chain)是如何工作的。它不是一个开关,而是一条流水线,每个请求进来,都会依次经过UsernamePasswordAuthenticationFilter、BasicAuthenticationFilter、CsrfFilter等十几道关卡。Swagger UI发起的请求,无论是加载/swagger-resources/configuration/ui,还是最终调用你的/api/user/login,都是标准的HTTP请求,同样要走这条链。所谓“忽略路径”,就是在WebSecurity配置里,把某些路径从整个过滤器链中摘出来,让它们完全不经过任何安全检查。这听起来很省事,但后果严重。
首先,/swagger-resources/**这类路径,返回的是JSON格式的API元数据,里面包含了所有接口的URL、参数、响应结构。如果这些路径被ignoring(),攻击者无需任何认证,就能直接curlhttp://localhost:8080/swagger-resources,拿到你整个系统的接口蓝图,包括那些本该严格保密的管理接口。这等于把数据库ER图贴在公司大门上。
其次,/webjars/**路径下存放的是Swagger UI的前端静态资源(HTML、JS、CSS)。如果这里也被忽略,意味着攻击者可以任意读取这些JS文件。而Swagger UI的JS代码里,硬编码了它如何构造请求、如何处理Token、甚至如何解析后端返回的错误信息。一旦这些逻辑被逆向分析,攻击者就能精准模拟Swagger UI的行为,绕过你自以为严密的前端校验。
最致命的是,这种“忽略”破坏了认证上下文的完整性。Spring Security的核心是SecurityContext,它通常绑定在当前线程的ThreadLocal里。当你在configure(HttpSecurity http)里配置authorizeRequests()时,你是在定义“哪些请求需要什么样的SecurityContext”。而ignoring(),则是彻底放弃了对这些请求建立SecurityContext的权利。这导致一个诡异现象:你的登录接口本身可能配置了permitAll(),但Swagger UI在调用它时,因为其自身资源路径被忽略,整个请求链路里根本没有SecurityContext,导致SecurityContextHolder.getContext().getAuthentication()在Controller里返回null,你的登录逻辑可能依赖这个对象做日志记录或上下文传递,结果全线崩盘。
正确的思路,不是“绕开”安全体系,而是“融入”它。我们要做的是:明确区分三类资源,并为每一类配置精准的访问策略。第一类是Swagger UI自身的前端资源(HTML/JS/CSS),它们是公开的,但必须确保不泄露后端逻辑;第二类是Swagger提供的元数据接口(/v2/api-docs,/swagger-resources/**),它们是公开的,但必须确保返回的数据不包含敏感字段;第三类是你自己的业务接口,它们需要严格的认证授权。这三类资源,应该在HttpSecurity的authorizeRequests()里,用精确的antMatchers()分别声明,而不是一股脑ignoring()。比如,/swagger-ui.html和/webjars/**可以permitAll(),但/v2/api-docs必须要求用户已认证(authenticated()),因为它的响应体里可能包含接口的详细描述,而这些描述文本里,有时会不小心写上“此接口用于删除用户表”,这种信息在未认证状态下就不该暴露。这才是生产环境该有的严谨。
提示:
WebSecurity.ignoring()只应用于绝对静态、无任何业务逻辑、且不依赖任何Spring Bean的资源,比如/images/**、/fonts/**。对于任何涉及Spring MVC、返回动态内容的路径,一律禁用ignoring(),改用HttpSecurity.authorizeRequests()进行精细化控制。
3. 统一认证入口的设计与实现:让Swagger成为认证流程的参与者
既然不能绕过,那就让它成为认证流程的一部分。我们的目标是:当用户在Swagger UI里点击“Authorize”按钮,输入用户名密码,点击“Login”后,Swagger UI能将凭证发送给你的登录接口,登录成功后,将返回的Token(无论是JWT还是Session ID)自动注入到后续所有请求的Header里。这要求我们做两件事:一是提供一个标准的、Swagger友好的登录接口;二是配置Swagger,让它知道去哪里获取Token、如何存储、以及如何在请求中携带。
首先,登录接口不能是简单的@PostMapping("/login")。它必须遵循OAuth2的password模式(即使你不用OAuth2协议,也要模仿其契约),因为这是Swagger UI原生支持的唯一认证方式。这意味着你的登录接口必须接受username和password作为Form Data参数,并在成功后,返回一个标准的JSON结构,其中必须包含access_token字段。很多开发者习惯返回{"code":200,"data":{"token":"xxx"}},这会导致Swagger UI完全无法识别,它只会显示“Login failed”。
@RestController @RequestMapping("/api/auth") public class AuthController { @PostMapping(value = "/login", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity<Map<String, String>> login( @RequestParam String username, @RequestParam String password, HttpServletRequest request) { // 1. 手动触发Spring Security的认证流程 UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); Authentication authentication = authenticationManager.authenticate(token); SecurityContextHolder.getContext().setAuthentication(authentication); // 2. 生成Token(此处以JWT为例) String jwtToken = generateJwtToken(authentication); // 3. 构造Swagger UI能识别的标准响应 Map<String, String> response = new HashMap<>(); response.put("access_token", jwtToken); response.put("token_type", "Bearer"); response.put("expires_in", String.valueOf(3600)); // 1小时 return ResponseEntity.ok(response); } private String generateJwtToken(Authentication authentication) { // 此处为JWT生成逻辑,使用JJWT等库 // 注意:payload中应包含用户ID、角色、过期时间等必要信息 return Jwts.builder() .setSubject(authentication.getName()) .claim("authorities", authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority).collect(Collectors.toList())) .setExpiration(new Date(System.currentTimeMillis() + 3600000)) .signWith(SignatureAlgorithm.HS512, "your-secret-key".getBytes()) .compact(); } }这段代码的关键点在于:它没有直接调用UserDetailsService,而是通过AuthenticationManager触发了Spring Security的完整认证流程。这意味着所有的UserDetailsChecker、AccountStatusUserDetailsChecker等校验逻辑都会被执行,保证了与系统其他部分的一致性。同时,返回的JSON结构严格遵循了Swagger UI的预期,access_token、token_type、expires_in这三个字段缺一不可。
接下来是Swagger的配置。我们不再使用老旧的Swagger2,而是升级到springdoc-openapi-ui(即OpenAPI 3.0规范的实现),因为它对Spring Security的集成支持更原生、更健壮。在pom.xml中引入:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.6.14</version> </dependency>然后,在Swagger配置类中,我们需要定义一个SecurityScheme,告诉Swagger UI:“这是一个Bearer Token认证,你需要去/api/auth/login这个URL获取Token”。
@Configuration public class SwaggerConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .components(new Components() .addSecuritySchemes("bearerAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") .in(SecurityScheme.In.HEADER) .name("Authorization"))) .info(new Info() .title("模拟项目X API文档") .version("1.0") .description("统一认证与授权接口文档")); } @Bean public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public-apis") .pathsToMatch("/api/**") .build(); } }这个配置里,SecurityScheme定义了认证类型是HTTP Bearer,并且指明了Token将放在AuthorizationHeader里。但这还不够,Swagger UI还不知道去哪里登录。我们需要在application.yml中,添加一个关键配置:
springdoc: swagger-ui: # 启用OAuth2登录按钮 oauth2RedirectUrl: http://localhost:8080/swagger-ui/oauth2-redirect.html # 配置登录端点 auth-url: http://localhost:8080/api/auth/login注意,auth-url指向的就是我们上面写的AuthController.login方法。当用户点击“Authorize”按钮,Swagger UI会自动向这个URL发起POST请求,携带username和password,并接收返回的access_token。之后,所有标记了@Operation(security = @SecurityRequirement(name = "bearerAuth"))的接口,在执行时,Swagger UI就会自动在请求头里加上Authorization: Bearer xxxxx。
注意:
oauth2RedirectUrl的值必须与你的应用实际部署地址一致。本地开发是http://localhost:8080,上线后要改成你的域名,比如https://api.example.com。如果配错,点击Login后页面会白屏,控制台报Failed to fetch。
4. 权限控制的精准落地:从@PreAuthorize到@Operation的语义对齐
Swagger UI能发Token了,但你的业务接口上还挂着@PreAuthorize("hasRole('ADMIN')"),这还不够。因为@PreAuthorize是Spring Security的AOP切面,它在Controller方法执行前做校验,而Swagger UI的“Authorize”按钮,只是负责把Token塞进Header里。这两者之间,需要一个清晰的映射关系:Swagger UI里的某个接口,应该对应哪个权限?这个映射,不能靠程序员凭记忆去维护,而应该在代码里显式声明。
OpenAPI规范提供了@Operation注解,它比@Api和@ApiOperation(Swagger2旧版)更强大,可以直接嵌入安全需求。我们可以在每个Controller方法上,用@Operation明确指出,这个接口需要什么级别的认证。
@RestController @RequestMapping("/api/user") public class UserController { @Operation(summary = "获取当前用户信息", security = @SecurityRequirement(name = "bearerAuth")) @GetMapping("/profile") public ResponseEntity<UserDTO> getProfile() { // 实现逻辑 return ResponseEntity.ok(new UserDTO()); } @Operation(summary = "创建新用户", security = @SecurityRequirement(name = "bearerAuth"), responses = { @ApiResponse(responseCode = "200", description = "创建成功"), @ApiResponse(responseCode = "403", description = "无权限") }) @PreAuthorize("hasRole('ADMIN')") @PostMapping public ResponseEntity<UserDTO> createUser(@RequestBody UserCreateDTO dto) { // 实现逻辑 return ResponseEntity.ok(new UserDTO()); } }看到了吗?@Operation里的security属性,和@PreAuthorize是并存的,不是替代关系。@Operation(security = ...)的作用,是告诉Swagger UI:“这个接口需要Bearer Token认证”,从而在UI界面上,为这个接口的“Try it out”按钮旁边,自动显示一个锁形图标,并且只有在用户点击“Authorize”并成功登录后,这个按钮才可用。而@PreAuthorize("hasRole('ADMIN')"),则是在服务端做最终的、不可绕过的权限校验。两者分工明确:Swagger UI负责前端交互的引导和约束,Spring Security负责后端逻辑的兜底和保障。
这种双重保障,带来了极强的可维护性。假设某天,产品需求变更,要求“创建用户”接口,除了ADMIN,ROLE_MANAGER也可以操作。你只需要修改@PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')"),Swagger UI会自动感知——因为@Operation里声明的只是“需要认证”,具体的“认证后能做什么”,由@PreAuthorize决定,UI层无需任何改动。反之,如果你只依赖@PreAuthorize,那么Swagger UI里所有接口的“Try it out”按钮永远都是灰色的,用户根本不知道哪些接口是自己能用的,这违背了API文档“降低使用门槛”的初衷。
更进一步,我们可以利用OpenAPI的Tag功能,对API进行分组,并为每个Tag设置全局的安全要求。比如,所有管理类接口,都打上@Tag(name = "Admin", description = "管理员专用接口"),然后在Swagger配置中,为Admin这个Tag统一设置security = @SecurityRequirement(name = "bearerAuth")。这样,就避免了在几十个方法上重复写@Operation(security = ...)。
@Bean public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("admin-apis") .pathsToMatch("/api/admin/**") .addOpenApiCustomizer(openApi -> openApi .tags(Arrays.asList( new Tag().name("Admin").description("管理员专用接口") .externalDocs(new ExternalDocumentation() .url("https://example.com/docs/admin") .description("管理员接口详细说明")))) .build(); }5. 生产环境的终极加固:CSRF、CORS与敏感信息过滤
当一切在本地开发环境跑通后,别急着上线。生产环境有更多“温柔的陷阱”。最常见的两个问题,就是CSRF防护和CORS跨域。
首先,CSRF(跨站请求伪造)。Spring Security默认开启了CSRF防护,它会要求所有非GET、HEAD、OPTIONS、TRACE的请求,必须携带一个名为_csrf的Token。而Swagger UI发起的POST、PUT、DELETE请求,恰恰属于被拦截的范围。如果你不做处理,点击“Try it out”后,会收到403 Forbidden,响应体里写着Invalid CSRF Token。解决办法不是关闭CSRF(那等于裸奔),而是让Swagger UI也能正确获取并携带这个Token。
Spring Security提供了CsrfTokenRepository,我们可以将其暴露为一个公开的Endpoint。在配置类中添加:
@Configuration public class CsrfConfig { @Bean public CsrfTokenRepository csrfTokenRepository() { HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository(); repository.setParameterName("_csrf"); return repository; } @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/csrf-token") .allowedOrigins("http://localhost:8080") // 开发环境 .allowCredentials(true); } }; } } @RestController public class CsrfController { @GetMapping("/csrf-token") public CsrfToken csrfToken(CsrfTokenRepository csrfTokenRepository, HttpServletRequest request, HttpServletResponse response) { CsrfToken csrfToken = csrfTokenRepository.generateToken(request); csrfTokenRepository.saveToken(csrfToken, request, response); return csrfToken; } }然后,在Swagger UI的index.html里,或者通过自定义的swagger-ui.html模板,添加一段JavaScript,在页面加载时,先GET/csrf-token,拿到token值,然后在每次提交表单前,把它作为_csrf参数附加上。这需要一点前端工作,但它是生产环境安全的基石。
其次,CORS(跨域资源共享)。Swagger UI作为一个独立的前端应用,通常部署在http://localhost:8080/swagger-ui.html,而你的后端API在http://localhost:8080/api/**,这看似同源,但如果你把Swagger UI部署在Nginx上,而API在Tomcat上,端口不同,就构成了跨域。Spring Security的HttpSecurity配置里,必须显式开启CORS,并允许Swagger UI的Origin。
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(Arrays.asList("http://localhost:8080", "https://your-swagger-domain.com")); config.setAllowCredentials(true); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setExposedHeaders(Arrays.asList("Authorization", "X-Total-Count")); // 暴露Authorization头,供Swagger UI读取 return config; })) .csrf(csrf -> csrf.disable()) // 注意:这里disable的是CSRF,不是CORS!CSRF我们上面已单独处理。 .authorizeRequests(authz -> authz .antMatchers("/swagger-ui.html", "/webjars/**", "/swagger-resources/**", "/v2/api-docs", "/csrf-token").permitAll() .antMatchers("/api/auth/login").permitAll() .antMatchers("/api/**").authenticated() .anyRequest().denyAll()); } }最后一道防线,是敏感信息过滤。Swagger UI的/v2/api-docs接口,会返回所有Controller的详细信息,包括类名、方法名、参数类型、甚至注释。如果某个Controller的类名是AdminUserDeleteController,或者某个方法的JavaDoc里写着// 删除用户及其所有关联数据,慎用!,这些信息都会原封不动地暴露出去。生产环境必须过滤掉。
我们可以通过自定义OpenAPIBean,在构建文档时,移除所有包含敏感词的@Tag、@Operation.summary或@Parameter.description。更通用的做法,是使用OpenAPICustomiser:
@Bean public OpenAPICustomiser openApiCustomiser() { return openApi -> { // 移除所有包含"admin"、"delete"、"remove"的Tag openApi.getTags().removeIf(tag -> tag.getName().toLowerCase().contains("admin") || tag.getName().toLowerCase().contains("delete") || tag.getName().toLowerCase().contains("remove")); // 遍历所有Paths,移除敏感接口的Operation openApi.getPaths().forEach((path, pathItem) -> { pathItem.readOperations().removeIf(operation -> { String summary = operation.getSummary() != null ? operation.getSummary().toLowerCase() : ""; return summary.contains("delete") || summary.contains("remove") || summary.contains("admin"); }); }); }; }这只是一个示例,实际中,你应该根据公司的安全规范,制定一份敏感词列表,并在CI/CD流程中,将这个过滤逻辑作为一道门禁,确保任何包含敏感信息的API文档,都无法进入生产环境。
6. 踩坑实录:那些让你怀疑人生的排查链路
最后,分享几个我在多个项目中反复踩过的坑,以及完整的排查思路。这些不是“答案”,而是“思考过程”,希望能帮你少走弯路。
6.1 现象:Swagger UI能打开,但所有接口的“Try it out”按钮都是灰色的,点击无反应。
排查链路:
- 第一步,看浏览器控制台(Console)。按F12,切换到Console标签页,刷新页面。如果看到
Failed to load resource: the server responded with a status of 401 (Unauthorized),说明Swagger UI在尝试加载/v2/api-docs时失败了。这通常是因为/v2/api-docs路径没有被permitAll(),而你的Spring Security配置里,又没有为它配置authenticated(),导致它被默认的denyAll()拦截。 - 第二步,看网络请求(Network)。在Network标签页,刷新页面,找到
/v2/api-docs这个请求,点开它,看Response。如果是401,回到第一步;如果是200,但Response Body为空或报错,说明你的OpenAPIBean构建失败,可能是某个Controller的@Operation注解里,引用了一个不存在的SecurityScheme名字。 - 第三步,检查
@Operation(security = ...)的name是否与OpenAPI.components.securitySchemes()里定义的key完全一致。大小写、下划线、拼写,一个字符都不能错。我曾在一个项目里,把"bearerAuth"写成了"bearer-auth",查了两天。
6.2 现象:点击“Authorize”,输入账号密码,点击Login,弹出“Login failed”,但后端没有任何日志。
排查链路:
- 第一步,确认
application.yml里的springdoc.swagger-ui.auth-url是否正确。用Postman手动模拟一次请求:POST http://localhost:8080/api/auth/login,Body选择x-www-form-urlencoded,添加username和password字段。如果Postman也失败,问题一定在你的AuthController。 - 第二步,检查
AuthController.login方法的@PostMapping注解。它必须有consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,否则Spring MVC不会将Form Data解析为@RequestParam参数,所有参数都是null,认证必然失败。 - 第三步,检查
AuthenticationManager是否被正确注入。在AuthController里,打印authenticationManager.getClass().getName(),确认它不是null,并且是ProviderManager的实例。如果不是,说明你的SecurityConfig没有被Spring扫描到,或者@EnableWebSecurity配置有误。
6.3 现象:登录成功了,Swagger UI显示“Authorized”,但调用任何接口,都返回403 Forbidden。
排查链路:
- 第一步,看Network里该接口的请求头(Request Headers)。展开Headers,找
Authorization字段。如果不存在,说明Swagger UI没有把Token注入进去。这通常是因为@Operation(security = ...)没有加在该接口上,或者加错了name。 - 第二步,如果
Authorization头存在,值是Bearer xxxxx,但后端还是403,说明Token校验失败。此时,去后端日志里,搜索Invalid JWT signature或JWT expired。如果是签名错误,检查你生成JWT时用的secret key,和验证JWT时用的secret key是否完全一致。我曾在一次部署中,把application.yml里的jwt.secret配成了"my-secret",而代码里写死的是"your-secret-key",导致所有Token都无效。 - 第三步,如果JWT校验通过,但依然403,那就是
@PreAuthorize表达式的问题。在SecurityConfig里,临时把@PreAuthorize注解全部注释掉,只留authenticated(),再试一次。如果能通,说明问题出在你的SpEL表达式上。此时,把@PreAuthorize换成@PreAuthorize("true"),再试。如果还失败,问题就出在SecurityContext的传播上,检查你的AuthenticationManager是否真的把Authentication对象设置进了SecurityContextHolder。
这些问题,每一个都曾让我在深夜对着屏幕抓狂。但每一次解决,都让我对Spring Security和OpenAPI的理解更深一层。技术没有捷径,所谓的“经验”,不过是把同一个坑,多踩几次,然后把填坑的过程,变成肌肉记忆。
我在实际使用中发现,最有效的调试方式,永远是“分层验证”:先用Postman验证登录接口,再用Postman验证带Token的业务接口,最后才让Swagger UI介入。把复杂的集成问题,拆解成一个个原子化的、可独立验证的步骤,是每个资深开发者必备的思维习惯。