diff --git a/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java index 788c33bdd..28c4c2dfa 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java @@ -1,22 +1,38 @@ package io.kafbat.ui.config.auth; +import io.kafbat.ui.service.rbac.AccessControlService; +import io.kafbat.ui.service.rbac.extractor.RbacBasicAuthAuthoritiesExtractor; import io.kafbat.ui.util.StaticFileWebFilter; +import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; 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.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; @Configuration @EnableWebFluxSecurity @ConditionalOnProperty(value = "auth.type", havingValue = "LOGIN_FORM") +@EnableConfigurationProperties(SecurityProperties.class) @Slf4j public class BasicAuthSecurityConfig extends AbstractAuthSecurityConfig { + private static final String NOOP_PASSWORD_PREFIX = "{noop}"; + private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$"); @Bean public SecurityWebFilterChain configure(ServerHttpSecurity http) { @@ -42,4 +58,39 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http) { return builder.build(); } + @Bean + public ReactiveUserDetailsService reactiveUserDetailsService(SecurityProperties properties, + ObjectProvider passwordEncoder, + AccessControlService accessControlService) { + SecurityProperties.User user = properties.getUser(); + + UserDetails userDetails = User.withUsername(user.getName()) + .password(password(user.getPassword(), passwordEncoder.getIfAvailable())) + .roles(StringUtils.toStringArray(user.getRoles())) + .build(); + + if (accessControlService.isRbacEnabled()) { + RbacBasicAuthAuthoritiesExtractor extractor = new RbacBasicAuthAuthoritiesExtractor(accessControlService); + + return new RbacUserDetailsService(new RbacBasicAuthUser(userDetails, extractor.groups(user.getName()))); + } else { + return new MapReactiveUserDetailsService(userDetails); + } + } + + private String password(String password, PasswordEncoder encoder) { + if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) { + return password; + } + + return NOOP_PASSWORD_PREFIX + password; + } + + private record RbacUserDetailsService(RbacBasicAuthUser userDetails) implements ReactiveUserDetailsService { + @Override + public Mono findByUsername(String username) { + return (userDetails.getUsername().equals(username)) ? Mono.just(userDetails) : Mono.empty(); + } + } + } diff --git a/api/src/main/java/io/kafbat/ui/config/auth/RbacBasicAuthUser.java b/api/src/main/java/io/kafbat/ui/config/auth/RbacBasicAuthUser.java new file mode 100644 index 000000000..edb4dd6fd --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/config/auth/RbacBasicAuthUser.java @@ -0,0 +1,60 @@ +package io.kafbat.ui.config.auth; + +import java.util.Collection; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class RbacBasicAuthUser implements UserDetails, RbacUser { + private final UserDetails userDetails; + private final Collection groups; + + public RbacBasicAuthUser(UserDetails userDetails, Collection groups) { + this.userDetails = userDetails; + this.groups = groups; + } + + @Override + public String name() { + return userDetails.getUsername(); + } + + @Override + public Collection groups() { + return groups; + } + + @Override + public Collection getAuthorities() { + return userDetails.getAuthorities(); + } + + @Override + public String getPassword() { + return userDetails.getPassword(); + } + + @Override + public String getUsername() { + return userDetails.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return userDetails.isAccountNonExpired(); + } + + @Override + public boolean isAccountNonLocked() { + return userDetails.isAccountNonLocked(); + } + + @Override + public boolean isCredentialsNonExpired() { + return userDetails.isCredentialsNonExpired(); + } + + @Override + public boolean isEnabled() { + return userDetails.isEnabled(); + } +} diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/provider/Provider.java b/api/src/main/java/io/kafbat/ui/model/rbac/provider/Provider.java index 3fbae2423..baebf3eb4 100644 --- a/api/src/main/java/io/kafbat/ui/model/rbac/provider/Provider.java +++ b/api/src/main/java/io/kafbat/ui/model/rbac/provider/Provider.java @@ -13,7 +13,9 @@ public enum Provider { OAUTH, LDAP, - LDAP_AD; + LDAP_AD, + + LOGIN_FORM; @Nullable public static Provider fromString(String name) { diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacBasicAuthAuthoritiesExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacBasicAuthAuthoritiesExtractor.java new file mode 100644 index 000000000..ec949d976 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacBasicAuthAuthoritiesExtractor.java @@ -0,0 +1,26 @@ +package io.kafbat.ui.service.rbac.extractor; + +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.Collection; +import java.util.stream.Collectors; + +public class RbacBasicAuthAuthoritiesExtractor { + private final AccessControlService accessControlService; + + public RbacBasicAuthAuthoritiesExtractor(AccessControlService accessControlService) { + this.accessControlService = accessControlService; + } + + public Collection groups(String username) { + return accessControlService.getRoles().stream() + .filter(role -> role.getSubjects().stream() + .filter(subj -> Provider.LOGIN_FORM.equals(subj.getProvider())) + .filter(subj -> "user".equals(subj.getType())) + .anyMatch(subj -> username.equals(subj.getValue())) + ) + .map(Role::getName) + .collect(Collectors.toSet()); + } +} diff --git a/api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java b/api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java index 098da29f9..bf35e0168 100644 --- a/api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java +++ b/api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java @@ -30,7 +30,7 @@ public abstract class AbstractActiveDirectoryIntegrationTest { private static final String SESSION = "SESSION"; protected static void checkUserPermissions(WebTestClient client) { - AuthenticationInfoDTO info = authenticationInfo(client, FIRST_USER_WITH_GROUP); + AuthenticationInfoDTO info = authenticationInfo(client, FIRST_USER_WITH_GROUP, PASSWORD); assertNotNull(info); assertTrue(info.getRbacEnabled()); @@ -40,25 +40,26 @@ protected static void checkUserPermissions(WebTestClient client) { assertFalse(permissions.isEmpty()); assertTrue(permissions.stream().anyMatch(permission -> permission.getClusters().contains(LOCAL) && permission.getResource() == ResourceTypeDTO.TOPIC)); - assertEquals(permissions, authenticationInfo(client, SECOND_USER_WITH_GROUP).getUserInfo().getPermissions()); - assertEquals(permissions, authenticationInfo(client, USER_WITHOUT_GROUP).getUserInfo().getPermissions()); + assertEquals(permissions, + authenticationInfo(client, SECOND_USER_WITH_GROUP, PASSWORD).getUserInfo().getPermissions()); + assertEquals(permissions, authenticationInfo(client, USER_WITHOUT_GROUP, PASSWORD).getUserInfo().getPermissions()); } protected static void checkEmptyPermissions(WebTestClient client) { - assertTrue(Objects.requireNonNull(authenticationInfo(client, EMPTY_PERMISSIONS_USER)) + assertTrue(Objects.requireNonNull(authenticationInfo(client, EMPTY_PERMISSIONS_USER, PASSWORD)) .getUserInfo() .getPermissions() .isEmpty() ); } - protected static String session(WebTestClient client, String name) { + private static String session(WebTestClient client, String name, String password) { return Objects.requireNonNull( client .post() .uri("/login") .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData("username", name).with("password", PASSWORD)) + .body(BodyInserters.fromFormData("username", name).with("password", password)) .exchange() .expectStatus() .isFound() @@ -68,11 +69,11 @@ protected static String session(WebTestClient client, String name) { .getValue(); } - protected static AuthenticationInfoDTO authenticationInfo(WebTestClient client, String name) { + public static AuthenticationInfoDTO authenticationInfo(WebTestClient client, String name, String password) { return client .get() .uri("/api/authorization") - .cookie(SESSION, session(client, name)) + .cookie(SESSION, session(client, name, password)) .exchange() .expectStatus() .isOk() diff --git a/api/src/test/java/io/kafbat/ui/BasicAuthIntegrationTest.java b/api/src/test/java/io/kafbat/ui/BasicAuthIntegrationTest.java new file mode 100644 index 000000000..c1eaa18f9 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/BasicAuthIntegrationTest.java @@ -0,0 +1,50 @@ +package io.kafbat.ui; + +import static io.kafbat.ui.AbstractActiveDirectoryIntegrationTest.authenticationInfo; +import static io.kafbat.ui.AbstractIntegrationTest.LOCAL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.kafbat.ui.model.AuthenticationInfoDTO; +import io.kafbat.ui.model.ResourceTypeDTO; +import io.kafbat.ui.model.UserPermissionDTO; +import io.kafbat.ui.model.rbac.permission.TopicAction; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest +@ActiveProfiles("rbac-login-form") +@AutoConfigureWebTestClient(timeout = "60000") +public class BasicAuthIntegrationTest { + @Autowired + private WebTestClient client; + + @Test + void testUserPermissions() { + AuthenticationInfoDTO info = authenticationInfo(client, "admin", "pass"); + + assertNotNull(info); + assertTrue(info.getRbacEnabled()); + + List permissions = info.getUserInfo().getPermissions(); + + assertEquals(1, permissions.size()); + + UserPermissionDTO permission = permissions.getFirst(); + Set actions = permission.getActions().stream() + .map(dto -> TopicAction.valueOf(dto.getValue())) + .collect(Collectors.toSet()); + + assertTrue(permission.getClusters().contains(LOCAL) + && permission.getResource() == ResourceTypeDTO.TOPIC + && actions.equals(Set.of(TopicAction.values()))); + } +} diff --git a/api/src/test/resources/application-rbac-login-form.yml b/api/src/test/resources/application-rbac-login-form.yml new file mode 100644 index 000000000..d40795fe9 --- /dev/null +++ b/api/src/test/resources/application-rbac-login-form.yml @@ -0,0 +1,22 @@ +auth: + type: LOGIN_FORM + +spring: + security: + user: + name: admin + password: pass + +rbac: + roles: + - name: "roleName" + clusters: + - local + subjects: + - provider: login_form + type: user + value: admin + permissions: + - resource: topic + value: ".*" + actions: all