Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ dependencies {

testImplementation libs.okhttp3
testImplementation libs.okhttp3.mockwebserver
testImplementation libs.wiremock
testImplementation libs.prometheus.metrics.core
}

Expand Down
114 changes: 95 additions & 19 deletions api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler;
Expand All @@ -34,9 +40,15 @@
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;

@Configuration
@ConditionalOnProperty(value = "auth.type", havingValue = "OAUTH2")
Expand All @@ -49,33 +61,43 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig {

private final OAuthProperties properties;

/**
* WebClient configured to use system proxy properties (-Dhttps.proxyHost, -Dhttps.proxyPort).
* Created as a bean to ensure system properties are read after context initialization.
*/
@Bean(name = "oauthWebClient")
public WebClient oauthWebClient() {
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(HttpClient.create().proxyWithSystemProperties()))
.build();
}

@Bean
public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSuccessHandler logoutHandler) {
public SecurityWebFilterChain configure(
ServerHttpSecurity http,
OAuthLogoutSuccessHandler logoutHandler,
ReactiveOAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> tokenResponseClient,
ReactiveOAuth2UserService<OidcUserRequest, OidcUser> oidcUserService
) {
log.info("Configuring OAUTH2 authentication.");

var oidcAuthManager =
new OidcAuthorizationCodeReactiveAuthenticationManager(tokenResponseClient, oidcUserService);

var builder = http.authorizeExchange(spec -> spec
.pathMatchers(AUTH_WHITELIST)
.permitAll()
.anyExchange()
.authenticated()
)
.oauth2Login(Customizer.withDefaults())
.oauth2Login(oauth2 -> oauth2.authenticationManager(oidcAuthManager))
.logout(spec -> spec.logoutSuccessHandler(logoutHandler))
.csrf(ServerHttpSecurity.CsrfSpec::disable);

if (properties.getResourceServer() != null) {
OAuth2ResourceServerProperties resourceServer = properties.getResourceServer();
if (resourceServer.getJwt() != null) {
builder.oauth2ResourceServer((c) -> c.jwt((j) -> j.jwkSetUri(resourceServer.getJwt().getJwkSetUri())));
} else if (resourceServer.getOpaquetoken() != null) {
OAuth2ResourceServerProperties.Opaquetoken opaquetoken = resourceServer.getOpaquetoken();
builder.oauth2ResourceServer(
(c) -> c.opaqueToken(
(o) -> o.introspectionUri(opaquetoken.getIntrospectionUri())
.introspectionClientCredentials(opaquetoken.getClientId(), opaquetoken.getClientSecret())
)
);
}
if (getJwkSetUri() != null) {
builder.oauth2ResourceServer(c -> c.jwt(Customizer.withDefaults()));
} else if (getOpaqueTokenConfig() != null) {
builder.oauth2ResourceServer(c -> c.opaqueToken(Customizer.withDefaults()));
}

builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING);
Expand All @@ -84,8 +106,47 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc
}

@Bean
public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserService(AccessControlService acs) {
public ReactiveOAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>
authorizationCodeTokenResponseClient(@Qualifier("oauthWebClient") WebClient webClient) {
var client = new WebClientReactiveAuthorizationCodeTokenResponseClient();
client.setWebClient(webClient);
return client;
}

@Bean
@Primary
public ReactiveJwtDecoder jwtDecoder(@Qualifier("oauthWebClient") WebClient webClient) {
String jwkSetUri = getJwkSetUri();
if (jwkSetUri == null) {
return token -> Mono.error(new IllegalStateException("JWT decoder not configured"));
}
log.info("Configuring JWT decoder with JWKS URI: {}", jwkSetUri);
return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).webClient(webClient).build();
}

@Bean
@Primary
public ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector(@Qualifier("oauthWebClient") WebClient webClient) {
var config = getOpaqueTokenConfig();
if (config == null) {
return token -> Mono.error(new IllegalStateException("Opaque token introspector not configured"));
}
log.info("Configuring opaque token introspector with URI: {}", config.getIntrospectionUri());
return new SpringReactiveOpaqueTokenIntrospector(
config.getIntrospectionUri(),
webClient.mutate()
.defaultHeaders(h -> h.setBasicAuth(config.getClientId(), config.getClientSecret()))
.build());
}

@Bean
public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserService(
AccessControlService acs,
ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService) {
final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService();

delegate.setOauth2UserService(oauth2UserService);

return request -> delegate.loadUser(request)
.flatMap(user -> {
var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId());
Expand All @@ -100,8 +161,11 @@ public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserServic
}

@Bean
public ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> customOauth2UserService(AccessControlService acs) {
public ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> customOauth2UserService(
AccessControlService acs, @Qualifier("oauthWebClient") WebClient webClient) {
final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService();
delegate.setWebClient(webClient);

return request -> delegate.loadUser(request)
.flatMap(user -> {
var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId());
Expand Down Expand Up @@ -131,7 +195,19 @@ public ServerLogoutSuccessHandler defaultOidcLogoutHandler(final ReactiveClientR
return new OidcClientInitiatedServerLogoutSuccessHandler(repository);
}

@Nullable
private String getJwkSetUri() {
var rs = properties.getResourceServer();
return rs != null && rs.getJwt() != null ? rs.getJwt().getJwkSetUri() : null;
}

private OAuth2ResourceServerProperties.Opaquetoken getOpaqueTokenConfig() {
var rs = properties.getResourceServer();
if (rs == null || rs.getOpaquetoken() == null) {
return null;
}
return rs.getOpaquetoken().getIntrospectionUri() != null ? rs.getOpaquetoken() : null;
}

private ProviderAuthorityExtractor getExtractor(final OAuthProperties.OAuth2Provider provider,
AccessControlService acs) {
Optional<ProviderAuthorityExtractor> extractor = acs.getOauthExtractors()
Expand Down
Loading
Loading