Spring Boot资源服务:SAML断言认证及JWT兑换方案咨询
我有一个整合了SAML认证、OAuth2资源服务与OAuth2授权客户端的Spring Boot(v2.7.10)项目,其@EnableWebSecurity配置如下:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers("/","/login").permitAll().and() .authorizeRequests() .anyRequest().authenticated() .and() .saml2Login().defaultSuccessUrl("/dashboard") .and() .oauth2ResourceServer(oauth2 -> oauth2.jwt()) .oauth2Login( oauth2 -> oauth2. defaultSuccessUrl("/dashboard", true) ) .logout().logoutSuccessUrl("/login").and() .logout(logout -> logout .logoutSuccessUrl("/login") ) .saml2Logout(Customizer.withDefaults()) ; return http.build(); }
当前场景
可通过外部IDP的OAuth2配置生成JWT令牌,也可通过外部IDP的SAML2提供商配置生成并验证SAML2断言;现有一批API通过资源服务以JWT Bearer令牌保护(格式:Authorization: Bearer ey....)。
问题
- Spring Boot中是否可配置为使用请求头中的SAML断言保护API,并由已配置的外部SAML IDP验证?示例请求头:
Authorization: SAML Base64EncodedSAML。 - 若上述方案不可行,能否配置Spring暴露接收SAML断言并返回对应IDP的JWT访问令牌的API?
补充说明:已尝试过SAML Bearer断言兑换JWT令牌的方案,但需Spring Boot专属实现。
问题1:直接用请求头中的SAML断言保护API
Spring Security默认不直接支持Authorization: SAML <Base64断言>格式的认证,但可通过自定义认证过滤器结合Spring Security SAML2的验证能力实现:
自定义SAML Bearer认证过滤器
实现OncePerRequestFilter提取请求头中的SAML断言,解码后发起认证:public class SamlBearerAuthFilter extends OncePerRequestFilter { private final Saml2AuthenticationTokenConverter tokenConverter; private final AuthenticationManager authenticationManager; public SamlBearerAuthFilter(AuthenticationManager authenticationManager, Saml2AuthenticationTokenConverter tokenConverter) { this.authenticationManager = authenticationManager; this.tokenConverter = tokenConverter; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith("SAML ")) { try { String base64Saml = authHeader.substring(5); byte[] samlBytes = Base64.getDecoder().decode(base64Saml); String samlAssertion = new String(samlBytes, StandardCharsets.UTF_8); Saml2AuthenticationToken authRequest = tokenConverter.convert(samlAssertion); Authentication authentication = authenticationManager.authenticate(authRequest); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (IllegalArgumentException | AuthenticationException e) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid SAML assertion"); return; } } filterChain.doFilter(request, response); } }集成到SecurityFilterChain
将自定义过滤器添加到OAuth2资源服务过滤器之前,同时配置SAML2断言验证所需的Bean:@Bean public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager, Saml2AuthenticationTokenConverter tokenConverter) throws Exception { http .csrf().disable() .addFilterBefore(new SamlBearerAuthFilter(authenticationManager, tokenConverter), OAuth2ResourceServerAuthenticationFilter.class) .authorizeRequests() .antMatchers("/","/login").permitAll() .anyRequest().authenticated() .and() .saml2Login().defaultSuccessUrl("/dashboard") .and() .oauth2ResourceServer(oauth2 -> oauth2.jwt()) .oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/dashboard", true)) .logout(logout -> logout.logoutSuccessUrl("/login")) .saml2Logout(Customizer.withDefaults()); return http.build(); } @Bean public Saml2AuthenticationTokenConverter saml2AuthenticationTokenConverter(RelyingPartyRegistrationRepository repo) { return new Saml2AuthenticationTokenConverter(repo); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }关键注意事项
- 确保外部IDP的元数据已通过
application.yml的spring.security.saml2.relyingparty.registration节点正确配置。 - 自定义过滤器需处理断言解码、格式校验等异常,返回标准HTTP错误码。
- 确保外部IDP的元数据已通过
问题2:暴露SAML断言兑换JWT的API
若直接用SAML断言保护API的方案不适用,可实现专属API接收SAML断言,调用外部IDP的令牌端点兑换JWT:
实现令牌兑换接口
利用Spring的RestTemplate调用IDP的令牌端点,使用urn:ietf:params:oauth:grant-type:saml2-bearer授权类型兑换令牌:@RestController @RequestMapping("/api/token") public class SamlTokenExchangeController { private final RestTemplate restTemplate; private final RelyingPartyRegistrationRepository registrationRepo; public SamlTokenExchangeController(RestTemplate restTemplate, RelyingPartyRegistrationRepository registrationRepo) { this.restTemplate = restTemplate; this.registrationRepo = registrationRepo; } @PostMapping("/exchange") public ResponseEntity<Map<String, Object>> exchangeSamlForJwt(@RequestBody Map<String, String> request) { String samlAssertion = request.get("saml_assertion"); if (samlAssertion == null || samlAssertion.isBlank()) { return ResponseEntity.badRequest().body(Collections.singletonMap("error", "SAML assertion is required")); } RelyingPartyRegistration registration = registrationRepo.findByRegistrationId("your-idp-registration-id"); if (registration == null) { return ResponseEntity.badRequest().body(Collections.singletonMap("error", "IDP registration not found")); } MultiValueMap<String, String> tokenParams = new LinkedMultiValueMap<>(); tokenParams.add("grant_type", "urn:ietf:params:oauth:grant-type:saml2-bearer"); tokenParams.add("assertion", samlAssertion); tokenParams.add("client_id", registration.getClientId()); tokenParams.add("client_secret", registration.getClientSecret()); tokenParams.add("scope", "openid api:read"); // 根据IDP要求调整 try { ResponseEntity<Map> response = restTemplate.postForEntity(registration.getProviderDetails().getTokenUri(), tokenParams, Map.class); return ResponseEntity.ok(response.getBody()); } catch (RestClientException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Collections.singletonMap("error", "Failed to exchange SAML assertion: " + e.getMessage())); } } }配置接口安全规则
在SecurityFilterChain中允许匿名访问兑换接口(或限制为可信客户端访问):.authorizeRequests() .antMatchers("/","/login", "/api/token/exchange").permitAll() .anyRequest().authenticated()关键注意事项
- 确认外部IDP支持
urn:ietf:params:oauth:grant-type:saml2-bearer授权类型。 - 客户端凭证(client_id/client_secret)需妥善存储,避免硬编码,可通过Spring Cloud Config或环境变量注入。
- 可添加前置校验逻辑,先验证SAML断言的签名,再提交给IDP兑换令牌。
- 确认外部IDP支持
内容的提问来源于stack exchange,提问作者M Aqib Naeem

