From 5075d3fc3a320bdc7ad66d130dc53f492ac85168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=B6=9B?= Date: Sat, 19 Sep 2026 11:59:38 +0800 Subject: [PATCH] Require scoped OAuth2 for actuator endpoints --- .../config/WebSecurityConfig.java | 22 ++++++++++++++----- .../config/WebSecurityConfigTest.java | 21 ++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/opensabre/authorization/config/WebSecurityConfig.java b/src/main/java/io/github/opensabre/authorization/config/WebSecurityConfig.java index 2ccef72..fe35fc6 100644 --- a/src/main/java/io/github/opensabre/authorization/config/WebSecurityConfig.java +++ b/src/main/java/io/github/opensabre/authorization/config/WebSecurityConfig.java @@ -13,6 +13,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; @@ -25,9 +26,12 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import java.util.stream.Stream; + @Slf4j @EnableWebSecurity @@ -73,13 +77,11 @@ public SecurityFilterChain defaultSecurityFilterChain( .requestMatchers("/login/captcha/image") .permitAll() .requestMatchers("/actuator/internalTokenKeyStatus") - .permitAll() - // The management port is private to the deployment network. Only the - // aggregate scrape endpoint is exposed to Prometheus without a user token. - .requestMatchers("/actuator/prometheus") - .permitAll() + .hasAuthority(ActuatorMonitoringAccess.AUTHORITY) .requestMatchers(ActuatorMonitoringAccess.metricPathArray()) .hasAuthority(ActuatorMonitoringAccess.AUTHORITY) + .requestMatchers("/actuator/**") + .hasAuthority("SCOPE_actuator.read") .requestMatchers( "/authorizations", "/authorizations/**", "/authorization-consents", "/authorization-consents/**") @@ -96,6 +98,9 @@ public SecurityFilterChain defaultSecurityFilterChain( BearerTokenAuthenticationFilter.class) .addFilterBefore(loginCaptchaAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .userDetailsService(userDetailsService); + httpSecurity.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + PathPatternRequestMatcher.withDefaults().matcher("/actuator/**"))); // 管理台以 DELETE /logout 发起注销,成功后由处理器写入审计日志并返回 204。 httpSecurity.logout(logout -> logout .logoutRequestMatcher(PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.DELETE, "/logout")) @@ -145,8 +150,13 @@ public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); authoritiesConverter.setAuthoritiesClaimName("roles"); authoritiesConverter.setAuthorityPrefix(""); + JwtGrantedAuthoritiesConverter scopeAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter(); - authenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + authenticationConverter.setJwtGrantedAuthoritiesConverter(jwt -> + Stream.concat(authoritiesConverter.convert(jwt).stream(), + scopeAuthoritiesConverter.convert(jwt).stream()) + .distinct() + .toList()); return authenticationConverter; } } diff --git a/src/test/java/io/github/opensabre/authorization/config/WebSecurityConfigTest.java b/src/test/java/io/github/opensabre/authorization/config/WebSecurityConfigTest.java index 9ff974f..6616299 100644 --- a/src/test/java/io/github/opensabre/authorization/config/WebSecurityConfigTest.java +++ b/src/test/java/io/github/opensabre/authorization/config/WebSecurityConfigTest.java @@ -47,6 +47,27 @@ void shouldRestoreAuthoritiesFromJwtRolesClaim() { .contains("ADMIN", "IT"); } + @Test + void shouldRestoreActuatorScopeWithoutGrantingApplicationRoles() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("opensabre-prometheus") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("scope", "actuator.read") + .build(); + + var authentication = new WebSecurityConfig() + .jwtAuthenticationConverter() + .convert(jwt); + + assertThat(authentication).isNotNull(); + assertThat(authentication.getAuthorities()) + .extracting("authority") + .contains("SCOPE_actuator.read") + .doesNotContain("ADMIN"); + } + @Test void shouldConfigureDaoAuthenticationProvider() { // Verify that DaoAuthenticationProvider is configured