Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
public class OAuthProperties {
private Map<String, OAuth2Provider> client = new HashMap<>();
private OAuth2ResourceServerProperties resourceServer = null;
private ResourceServerRbac resourceServerRbac;

@Data
public static class ResourceServerRbac {
private String rolesClaim;
private String usernameClaim;
private String entityTypeClaim;
private String defaultRole;
}

@PostConstruct
public void init() {
Expand Down
109 changes: 70 additions & 39 deletions api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,6 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig {

private final OAuthProperties properties;

/**
* WebClient configured to use system proxy properties (http.proxyHost/https.proxyHost,
* http.proxyPort/https.proxyPort, http.nonProxyHosts/https.nonProxyHosts).
* Created as a bean to ensure system properties are read after context initialization.
*/
@Bean(name = "oauthWebClient")
public WebClient oauthWebClient() {
return WebClient.builder()
Expand All @@ -74,45 +69,66 @@ public WebClient oauthWebClient() {
@Bean
public SecurityWebFilterChain configure(
ServerHttpSecurity http,
OAuthLogoutSuccessHandler logoutHandler,
ReactiveOAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> tokenResponseClient,
ReactiveOAuth2UserService<OidcUserRequest, OidcUser> oidcUserService,
ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService,
@Qualifier("oauthWebClient") WebClient webClient
Optional<OAuthLogoutSuccessHandler> logoutHandler,
Optional<ReactiveOAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>> tokenResponseClient,
Optional<ReactiveOAuth2UserService<OidcUserRequest, OidcUser>> oidcUserService,
Optional<ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User>> oauth2UserService,
@Qualifier("oauthWebClient") WebClient webClient,
AccessControlService accessControlService
) {
log.info("Configuring OAUTH2 authentication.");

var oidcAuthManager =
new OidcAuthorizationCodeReactiveAuthenticationManager(tokenResponseClient, oidcUserService);

oidcAuthManager.setJwtDecoderFactory(clientRegistration ->
NimbusReactiveJwtDecoder.withJwkSetUri(clientRegistration.getProviderDetails().getJwkSetUri())
.webClient(webClient)
.build());

var oauth2AuthManager =
new OAuth2LoginReactiveAuthenticationManager(tokenResponseClient, oauth2UserService);

var delegatingAuthManager =
new DelegatingReactiveAuthenticationManager(oidcAuthManager, oauth2AuthManager);

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

if (tokenResponseClient.isPresent() && oidcUserService.isPresent() && oauth2UserService.isPresent()) {
log.info("OAuth2 client registrations found, enabling interactive login.");

var oidcAuthManager = new OidcAuthorizationCodeReactiveAuthenticationManager(
tokenResponseClient.get(), oidcUserService.get());

oidcAuthManager.setJwtDecoderFactory(clientRegistration ->
NimbusReactiveJwtDecoder.withJwkSetUri(clientRegistration.getProviderDetails().getJwkSetUri())
.webClient(webClient)
.build());

var oauth2AuthManager = new OAuth2LoginReactiveAuthenticationManager(
tokenResponseClient.get(), oauth2UserService.get());

var delegatingAuthManager =
new DelegatingReactiveAuthenticationManager(oidcAuthManager, oauth2AuthManager);

builder.oauth2Login(oauth2 -> oauth2.authenticationManager(delegatingAuthManager));
logoutHandler.ifPresent(handler -> builder.logout(spec -> spec.logoutSuccessHandler(handler)));
} else {
log.info("No OAuth2 client registrations, running in resource-server-only mode.");
}

if (properties.getResourceServer() != null) {
OAuth2ResourceServerProperties resourceServer = properties.getResourceServer();
if (resourceServer.getJwt() != null && resourceServer.getJwt().getJwkSetUri() != null) {
builder.oauth2ResourceServer(c -> c.jwt(j ->
j.jwtDecoder(NimbusReactiveJwtDecoder.withJwkSetUri(resourceServer.getJwt().getJwkSetUri())
.webClient(webClient)
.build())));
var jwtDecoder = NimbusReactiveJwtDecoder
.withJwkSetUri(resourceServer.getJwt().getJwkSetUri())
.webClient(webClient)
.build();
var rbacProps = properties.getResourceServerRbac();

builder.oauth2ResourceServer(c -> c.jwt(j -> {
j.jwtDecoder(jwtDecoder);
if (rbacProps != null) {
j.jwtAuthenticationConverter(new RbacReactiveJwtAuthenticationConverter(
accessControlService,
rbacProps.getRolesClaim(),
rbacProps.getUsernameClaim(),
rbacProps.getEntityTypeClaim(),
rbacProps.getDefaultRole()));
}
}));
} else if (resourceServer.getOpaquetoken() != null
&& resourceServer.getOpaquetoken().getIntrospectionUri() != null) {
OAuth2ResourceServerProperties.Opaquetoken opaquetoken = resourceServer.getOpaquetoken();
Expand All @@ -133,6 +149,9 @@ public SecurityWebFilterChain configure(
@Bean
public ReactiveOAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>
authorizationCodeTokenResponseClient(@Qualifier("oauthWebClient") WebClient webClient) {
if (!hasClientRegistrations()) {
return null;
}
var client = new WebClientReactiveAuthorizationCodeTokenResponseClient();
client.setWebClient(webClient);
return client;
Expand All @@ -141,10 +160,12 @@ public SecurityWebFilterChain configure(
@Bean
public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserService(
AccessControlService acs,
ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService) {
Optional<ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User>> oauth2UserService) {
if (!hasClientRegistrations() || oauth2UserService.isEmpty()) {
return null;
}
final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService();

delegate.setOauth2UserService(oauth2UserService);
delegate.setOauth2UserService(oauth2UserService.get());

return request -> delegate.loadUser(request)
.flatMap(user -> {
Expand All @@ -162,6 +183,9 @@ public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserServic
@Bean
public ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> customOauth2UserService(
AccessControlService acs, @Qualifier("oauthWebClient") WebClient webClient) {
if (!hasClientRegistrations()) {
return null;
}
final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService();
delegate.setWebClient(webClient);

Expand All @@ -179,21 +203,29 @@ public ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> customOauth2User
}

@Bean
public InMemoryReactiveClientRegistrationRepository clientRegistrationRepository() {
public ReactiveClientRegistrationRepository clientRegistrationRepository() {
if (!hasClientRegistrations()) {
return registrationId -> Mono.empty();
}
final OAuth2ClientProperties props = OAuthPropertiesConverter.convertProperties(properties);
final List<ClientRegistration> registrations =
new ArrayList<>(new OAuth2ClientPropertiesMapper(props).asClientRegistrations().values());
if (registrations.isEmpty()) {
throw new IllegalArgumentException("OAuth2 authentication is enabled but no providers specified.");
}
return new InMemoryReactiveClientRegistrationRepository(registrations);
}

@Bean
public ServerLogoutSuccessHandler defaultOidcLogoutHandler(final ReactiveClientRegistrationRepository repository) {
public ServerLogoutSuccessHandler defaultOidcLogoutHandler(
ReactiveClientRegistrationRepository repository) {
if (!hasClientRegistrations()) {
return null;
}
return new OidcClientInitiatedServerLogoutSuccessHandler(repository);
}

private boolean hasClientRegistrations() {
return properties.getClient() != null && !properties.getClient().isEmpty();
}

private ProviderAuthorityExtractor getExtractor(final OAuthProperties.OAuth2Provider provider,
AccessControlService acs) {
Optional<ProviderAuthorityExtractor> extractor = acs.getOauthExtractors()
Expand All @@ -209,4 +241,3 @@ private OAuthProperties.OAuth2Provider getProviderByProviderId(final String prov
}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.kafbat.ui.config.auth;

import java.util.Collection;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;

public class RbacJwtAuthenticationToken extends AbstractAuthenticationToken {

private final RbacJwtUser principal;
private final Jwt jwt;

public RbacJwtAuthenticationToken(RbacJwtUser principal, Jwt jwt,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.jwt = jwt;
setAuthenticated(true);
}

@Override
public Object getCredentials() {
return jwt.getTokenValue();
}

@Override
public RbacJwtUser getPrincipal() {
return principal;
}
}
12 changes: 12 additions & 0 deletions api/src/main/java/io/kafbat/ui/config/auth/RbacJwtUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.kafbat.ui.config.auth;

import java.util.Collection;
import org.springframework.security.oauth2.jwt.Jwt;

public record RbacJwtUser(Jwt jwt, String username, Collection<String> groups) implements RbacUser {

@Override
public String name() {
return username;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package io.kafbat.ui.config.auth;

import io.kafbat.ui.model.rbac.Role;
import io.kafbat.ui.model.rbac.provider.Provider;
import io.kafbat.ui.service.rbac.AccessControlService;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import reactor.core.publisher.Mono;

@Slf4j
public class RbacReactiveJwtAuthenticationConverter
implements Converter<Jwt, Mono<AbstractAuthenticationToken>> {

private final AccessControlService accessControlService;
private final String rolesClaim;
private final String usernameClaim;
private final String entityTypeClaim;
private final String defaultRole;

public RbacReactiveJwtAuthenticationConverter(
AccessControlService accessControlService,
String rolesClaim,
String usernameClaim,
String entityTypeClaim,
String defaultRole) {
this.accessControlService = accessControlService;
this.rolesClaim = rolesClaim;
this.usernameClaim = usernameClaim;
this.entityTypeClaim = entityTypeClaim;
this.defaultRole = defaultRole;
}

@Override
public Mono<AbstractAuthenticationToken> convert(Jwt jwt) {
String username = extractUsername(jwt);
Collection<String> tokenRoles = extractTokenRoles(jwt);
String entityType = extractEntityType(jwt);

log.debug("JWT principal: [{}], token roles: [{}], entity type: [{}]",
username, tokenRoles, entityType);

Set<String> matchedGroups = matchRbacRoles(username, tokenRoles, entityType);

if (matchedGroups.isEmpty() && defaultRole != null) {
log.debug("No RBAC roles matched, applying default role: [{}]", defaultRole);
matchedGroups = Set.of(defaultRole);
}

log.debug("Matched RBAC groups: [{}]", matchedGroups);

RbacJwtUser rbacUser = new RbacJwtUser(jwt, username, matchedGroups);
var authorities = matchedGroups.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());

return Mono.just(new RbacJwtAuthenticationToken(rbacUser, jwt, authorities));
}

private String extractUsername(Jwt jwt) {
if (usernameClaim != null) {
Object claim = jwt.getClaim(usernameClaim);
if (claim != null) {
return claim.toString();
}
}
return jwt.getSubject();
}

@SuppressWarnings("unchecked")
private Collection<String> extractTokenRoles(Jwt jwt) {
if (rolesClaim == null) {
return Collections.emptyList();
}
Object claim = jwt.getClaim(rolesClaim);
if (claim == null) {
return Collections.emptyList();
}
if (claim instanceof Collection<?>) {
return ((Collection<?>) claim).stream()
.map(Object::toString)
.collect(Collectors.toList());

Check warning on line 91 in api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this usage of 'Stream.collect(Collectors.toList())' with 'Stream.toList()' and ensure that the list is unmodified.

See more on https://sonarcloud.io/project/issues?id=kafbat_kafka-ui&issues=AZ4DJCetA48Ae_KO0j-x&open=AZ4DJCetA48Ae_KO0j-x&pullRequest=1840
}
if (claim instanceof String str) {
return Arrays.asList(str.split(","));
}
return Collections.emptyList();
}

private String extractEntityType(Jwt jwt) {
if (entityTypeClaim == null) {
return null;
}
Object claim = jwt.getClaim(entityTypeClaim);
return claim != null ? claim.toString() : null;
}

private Set<String> matchRbacRoles(String username, Collection<String> tokenRoles,
String entityType) {
List<Role> roles = accessControlService.getRoles();

Set<String> usernameMatches = roles.stream()
.filter(r -> r.getSubjects().stream()
.filter(s -> s.getProvider().equals(Provider.OAUTH))
.filter(s -> "user".equals(s.getType()))
.anyMatch(s -> s.matches(username)))
.map(Role::getName)
.collect(Collectors.toSet());

Set<String> roleMatches = roles.stream()
.filter(role -> role.getSubjects().stream()
.filter(s -> s.getProvider().equals(Provider.OAUTH))
.filter(s -> "role".equals(s.getType()))
.anyMatch(subject -> tokenRoles.stream().anyMatch(subject::matches)))
.map(Role::getName)
.collect(Collectors.toSet());

Set<String> entityTypeMatches = Collections.emptySet();
if (entityType != null) {
entityTypeMatches = roles.stream()
.filter(role -> role.getSubjects().stream()
.filter(s -> s.getProvider().equals(Provider.OAUTH))
.filter(s -> "entity-type".equals(s.getType()))
.anyMatch(s -> s.matches(entityType)))
.map(Role::getName)
.collect(Collectors.toSet());
}

Set<String> combined = new HashSet<>(usernameMatches);
combined.addAll(roleMatches);
combined.addAll(entityTypeMatches);
return combined;
}
}
Loading
Loading