From 85330e88372653274dce1b70effc1c506a66bdac Mon Sep 17 00:00:00 2001 From: Roman Zabaluev Date: Sat, 28 Dec 2024 08:04:50 +0400 Subject: [PATCH 1/7] BE: Impl custom auth page (#635) Co-authored-by: German Osin --- .java-version | 1 + .../auth/AbstractAuthSecurityConfig.java | 39 +++++++- .../config/auth/BasicAuthSecurityConfig.java | 28 +++--- .../ui/config/auth/LdapSecurityConfig.java | 27 +++-- .../ui/config/auth/OAuthSecurityConfig.java | 12 ++- .../ApplicationConfigController.java | 10 +- .../kafbat/ui/controller/AuthController.java | 99 ------------------- .../controller/AuthenticationController.java | 22 +++++ ...ller.java => AuthorizationController.java} | 2 +- .../ui/service/ApplicationInfoService.java | 46 +++++++++ .../kafbat/ui/util/StaticFileWebFilter.java | 61 ++++++++++++ .../main/resources/swagger/kafbat-ui-api.yaml | 66 ++++++++++++- 12 files changed, 275 insertions(+), 138 deletions(-) create mode 100644 .java-version delete mode 100644 api/src/main/java/io/kafbat/ui/controller/AuthController.java create mode 100644 api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java rename api/src/main/java/io/kafbat/ui/controller/{AccessController.java => AuthorizationController.java} (97%) create mode 100644 api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java diff --git a/.java-version b/.java-version new file mode 100644 index 000000000..aabe6ec39 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java index f23a0dd2a..265bac03f 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java @@ -1,24 +1,53 @@ package io.kafbat.ui.config.auth; +import io.kafbat.ui.util.EmptyRedirectStrategy; +import java.net.URI; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; + abstract class AbstractAuthSecurityConfig { protected AbstractAuthSecurityConfig() { } + protected static final String LOGIN_URL = "/login"; + protected static final String LOGOUT_URL = "/auth?logout"; + protected static final String[] AUTH_WHITELIST = { - "/css/**", - "/js/**", - "/media/**", + /* STATIC */ + "/index.html", + "/assets/**", + "/manifest.json", + "/favicon.svg", + "/favicon/**", + + "/static/**", "/resources/**", + + /* ACTUATOR */ "/actuator/health/**", "/actuator/info", "/actuator/prometheus", - "/auth", + + /* AUTH */ "/login", "/logout", "/oauth2/**", - "/static/**" + "/api/config/authentication", + "/api/authorization" }; + protected RedirectServerAuthenticationSuccessHandler emptyRedirectSuccessHandler() { + final var authHandler = new RedirectServerAuthenticationSuccessHandler(); + authHandler.setRedirectStrategy(new EmptyRedirectStrategy()); + return authHandler; + } + + protected RedirectServerLogoutSuccessHandler redirectLogoutSuccessHandler() { + final var logoutSuccessHandler = new RedirectServerLogoutSuccessHandler(); + logoutSuccessHandler.setLogoutSuccessUrl(URI.create(LOGOUT_URL)); + return logoutSuccessHandler; + } + } 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 7a25fb3a7..db8ef8153 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,6 +1,7 @@ package io.kafbat.ui.config.auth; import io.kafbat.ui.util.EmptyRedirectStrategy; +import io.kafbat.ui.util.StaticFileWebFilter; import java.net.URI; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -8,6 +9,7 @@ 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.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; @@ -20,32 +22,28 @@ @Slf4j public class BasicAuthSecurityConfig extends AbstractAuthSecurityConfig { - public static final String LOGIN_URL = "/auth"; - public static final String LOGOUT_URL = "/auth?logout"; - @Bean public SecurityWebFilterChain configure(ServerHttpSecurity http) { log.info("Configuring LOGIN_FORM authentication."); - final var authHandler = new RedirectServerAuthenticationSuccessHandler(); - authHandler.setRedirectStrategy(new EmptyRedirectStrategy()); - - final var logoutSuccessHandler = new RedirectServerLogoutSuccessHandler(); - logoutSuccessHandler.setLogoutSuccessUrl(URI.create(LOGOUT_URL)); - - - return http.authorizeExchange(spec -> spec + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) - .formLogin(spec -> spec.loginPage(LOGIN_URL).authenticationSuccessHandler(authHandler)) + .formLogin(form -> form + .loginPage(LOGIN_URL) + .authenticationSuccessHandler(emptyRedirectSuccessHandler()) + ) .logout(spec -> spec - .logoutSuccessHandler(logoutSuccessHandler) + .logoutSuccessHandler(redirectLogoutSuccessHandler()) .requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout"))) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + .csrf(ServerHttpSecurity.CsrfSpec::disable); + + builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); + + return builder.build(); } } diff --git a/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java index 1b5a8ca87..4d89a9568 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java @@ -1,9 +1,8 @@ package io.kafbat.ui.config.auth; -import static io.kafbat.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST; - import io.kafbat.ui.service.rbac.AccessControlService; import io.kafbat.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor; +import io.kafbat.ui.util.StaticFileWebFilter; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -14,6 +13,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.ldap.core.DirContextOperations; import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.ldap.core.support.LdapContextSource; @@ -21,8 +21,8 @@ import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter; -import org.springframework.security.config.Customizer; 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.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -36,6 +36,7 @@ import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator; import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; @Configuration @EnableWebFluxSecurity @@ -43,7 +44,7 @@ @EnableConfigurationProperties(LdapProperties.class) @RequiredArgsConstructor @Slf4j -public class LdapSecurityConfig { +public class LdapSecurityConfig extends AbstractAuthSecurityConfig { private final LdapProperties props; @@ -121,16 +122,24 @@ public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) { log.info("Active Directory support for LDAP has been enabled."); } - return http.authorizeExchange(spec -> spec + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) - .formLogin(Customizer.withDefaults()) - .logout(Customizer.withDefaults()) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + .formLogin(form -> form + .loginPage(LOGIN_URL) + .authenticationSuccessHandler(emptyRedirectSuccessHandler()) + ) + .logout(spec -> spec + .logoutSuccessHandler(redirectLogoutSuccessHandler()) + .requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout"))) + .csrf(ServerHttpSecurity.CsrfSpec::disable); + + builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); + + return builder.build(); } private static class UserDetailsMapper extends LdapUserDetailsMapper { diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 09c7df794..4794b83ca 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -3,6 +3,7 @@ import io.kafbat.ui.config.auth.logout.OAuthLogoutSuccessHandler; import io.kafbat.ui.service.rbac.AccessControlService; import io.kafbat.ui.service.rbac.extractor.ProviderAuthorityExtractor; +import io.kafbat.ui.util.StaticFileWebFilter; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -19,6 +20,7 @@ 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.oidc.userinfo.OidcReactiveOAuth2UserService; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; @@ -50,7 +52,7 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSuccessHandler logoutHandler) { log.info("Configuring OAUTH2 authentication."); - return http.authorizeExchange(spec -> spec + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() @@ -58,8 +60,12 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc ) .oauth2Login(Customizer.withDefaults()) .logout(spec -> spec.logoutSuccessHandler(logoutHandler)) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + .csrf(ServerHttpSecurity.CsrfSpec::disable); + + + builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); + + return builder.build(); } @Bean diff --git a/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java b/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java index 5d5d4ed98..e8d763545 100644 --- a/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java +++ b/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java @@ -6,6 +6,7 @@ import io.kafbat.ui.api.ApplicationConfigApi; import io.kafbat.ui.config.ClustersProperties; import io.kafbat.ui.model.ActionDTO; +import io.kafbat.ui.model.AppAuthenticationSettingsDTO; import io.kafbat.ui.model.ApplicationConfigDTO; import io.kafbat.ui.model.ApplicationConfigPropertiesDTO; import io.kafbat.ui.model.ApplicationConfigValidationDTO; @@ -66,6 +67,13 @@ public Mono> getApplicationInfo(ServerWebExch return Mono.just(applicationInfoService.getApplicationInfo()).map(ResponseEntity::ok); } + @Override + public Mono> getAuthenticationSettings( + ServerWebExchange exchange) { + return Mono.just(applicationInfoService.getAuthenticationProperties()) + .map(ResponseEntity::ok); + } + @Override public Mono> getCurrentConfig(ServerWebExchange exchange) { var context = AccessContext.builder() @@ -109,7 +117,7 @@ public Mono> uploadConfigRelatedFile(Flux dynamicConfigOperations.uploadConfigRelatedFile((FilePart) file) - .map(path -> new UploadedFileInfoDTO().location(path.toString())) + .map(path -> new UploadedFileInfoDTO(path.toString())) .map(ResponseEntity::ok)) .doOnEach(sig -> audit(context, sig)); } diff --git a/api/src/main/java/io/kafbat/ui/controller/AuthController.java b/api/src/main/java/io/kafbat/ui/controller/AuthController.java deleted file mode 100644 index e4532dda3..000000000 --- a/api/src/main/java/io/kafbat/ui/controller/AuthController.java +++ /dev/null @@ -1,99 +0,0 @@ -package io.kafbat.ui.controller; - -import java.nio.charset.Charset; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.web.server.csrf.CsrfToken; -import org.springframework.util.MultiValueMap; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -@RestController -@RequiredArgsConstructor -@Slf4j -public class AuthController { - - @GetMapping(value = "/auth", produces = {"text/html"}) - public Mono getAuth(ServerWebExchange exchange) { - Mono token = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()); - return token - .map(AuthController::csrfToken) - .defaultIfEmpty("") - .map(csrfTokenHtmlInput -> createPage(exchange, csrfTokenHtmlInput)); - } - - private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) { - MultiValueMap queryParams = exchange.getRequest() - .getQueryParams(); - String contextPath = exchange.getRequest().getPath().contextPath().value(); - String page = - "\n" + "\n" + " \n" - + " \n" - + " \n" - + " \n" - + " \n" - + " Please sign in\n" - + " \n" - + " \n" - + " \n" - + " \n" - + "
\n" - + formLogin(queryParams, contextPath, csrfTokenHtmlInput) - + "
\n" - + " \n" - + ""; - - return page.getBytes(Charset.defaultCharset()); - } - - private String formLogin( - MultiValueMap queryParams, - String contextPath, String csrfTokenHtmlInput) { - - boolean isError = queryParams.containsKey("error"); - boolean isLogoutSuccess = queryParams.containsKey("logout"); - return - "
\n" - + " \n" - + createError(isError) - + createLogoutSuccess(isLogoutSuccess) - + "

\n" - + " \n" - + " \n" - + "

\n" + "

\n" - + " \n" - + " \n" - + "

\n" + csrfTokenHtmlInput - + " \n" - + "
\n"; - } - - private static String csrfToken(CsrfToken token) { - return " \n"; - } - - private static String createError(boolean isError) { - return isError - ? "
Invalid credentials
" - : ""; - } - - private static String createLogoutSuccess(boolean isLogoutSuccess) { - return isLogoutSuccess - ? "
You have been signed out
" - : ""; - } -} diff --git a/api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java b/api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java new file mode 100644 index 000000000..c94c344c9 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java @@ -0,0 +1,22 @@ +package io.kafbat.ui.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class AuthenticationController { + + private static final String INDEX_HTML = "/static/index.html"; + + @GetMapping(value = "/login", produces = {"text/html"}) + public Mono getLoginPage() { + return Mono.just(new ClassPathResource(INDEX_HTML)); + } + +} diff --git a/api/src/main/java/io/kafbat/ui/controller/AccessController.java b/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java similarity index 97% rename from api/src/main/java/io/kafbat/ui/controller/AccessController.java rename to api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java index e5b1ea438..1ac0aeb85 100644 --- a/api/src/main/java/io/kafbat/ui/controller/AccessController.java +++ b/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java @@ -26,7 +26,7 @@ @RestController @RequiredArgsConstructor @Slf4j -public class AccessController implements AuthorizationApi { +public class AuthorizationController implements AuthorizationApi { private final AccessControlService accessControlService; diff --git a/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java b/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java index 7d380036c..7ee28b62d 100644 --- a/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java +++ b/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java @@ -1,16 +1,23 @@ package io.kafbat.ui.service; +import static io.kafbat.ui.api.model.AuthType.DISABLED; +import static io.kafbat.ui.api.model.AuthType.OAUTH2; import static io.kafbat.ui.model.ApplicationInfoDTO.EnabledFeaturesEnum; import static io.kafbat.ui.util.GithubReleaseInfo.GITHUB_RELEASE_INFO_TIMEOUT; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Streams; +import io.kafbat.ui.model.AppAuthenticationSettingsDTO; import io.kafbat.ui.model.ApplicationInfoBuildDTO; import io.kafbat.ui.model.ApplicationInfoDTO; import io.kafbat.ui.model.ApplicationInfoLatestReleaseDTO; +import io.kafbat.ui.model.AuthTypeDTO; +import io.kafbat.ui.model.OAuthProviderDTO; import io.kafbat.ui.util.DynamicConfigOperations; import io.kafbat.ui.util.GithubReleaseInfo; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Properties; @@ -18,20 +25,27 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.GitProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.stereotype.Service; @Service public class ApplicationInfoService { private final GithubReleaseInfo githubReleaseInfo; + private final ApplicationContext applicationContext; private final DynamicConfigOperations dynamicConfigOperations; private final BuildProperties buildProperties; private final GitProperties gitProperties; public ApplicationInfoService(DynamicConfigOperations dynamicConfigOperations, + ApplicationContext applicationContext, @Autowired(required = false) BuildProperties buildProperties, @Autowired(required = false) GitProperties gitProperties, @Value("${" + GITHUB_RELEASE_INFO_TIMEOUT + ":10}") int githubApiMaxWaitTime) { + this.applicationContext = applicationContext; this.dynamicConfigOperations = dynamicConfigOperations; this.buildProperties = Optional.ofNullable(buildProperties).orElse(new BuildProperties(new Properties())); this.gitProperties = Optional.ofNullable(gitProperties).orElse(new GitProperties(new Properties())); @@ -70,6 +84,38 @@ private List getEnabledFeatures() { return enabledFeatures; } + public AppAuthenticationSettingsDTO getAuthenticationProperties() { + return new AppAuthenticationSettingsDTO() + .authType(AuthTypeDTO.fromValue(getAuthType())) + .oAuthProviders(getOAuthProviders()); + } + + private String getAuthType() { + return Optional.ofNullable(applicationContext.getEnvironment().getProperty("auth.type")) + .orElse(DISABLED.getValue()); + } + + @SuppressWarnings("unchecked") + private List getOAuthProviders() { + if (!getAuthType().equalsIgnoreCase(OAUTH2.getValue())) { + return Collections.emptyList(); + } + var type = ResolvableType.forClassWithGenerics(Iterable.class, ClientRegistration.class); + String[] names = this.applicationContext.getBeanNamesForType(type); + var bean = (Iterable) (names.length == 1 ? this.applicationContext.getBean(names[0]) : null); + + if (bean == null) { + return Collections.emptyList(); + } + + return Streams.stream(bean.iterator()) + .filter(r -> AuthorizationGrantType.AUTHORIZATION_CODE.equals(r.getAuthorizationGrantType())) + .map(r -> new OAuthProviderDTO() + .clientName(r.getClientName()) + .authorizationUri("/oauth2/authorization/" + r.getRegistrationId())) + .toList(); + } + // updating on startup and every hour @Scheduled(fixedRateString = "${github-release-info-update-rate:3600000}") public void updateGithubReleaseInfo() { diff --git a/api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java b/api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java new file mode 100644 index 000000000..1b74bd374 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java @@ -0,0 +1,61 @@ +package io.kafbat.ui.util; + +import java.io.IOException; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +public class StaticFileWebFilter implements WebFilter { + + private static final String INDEX_HTML = "/static/index.html"; + + private final ServerWebExchangeMatcher matcher; + private final String contents; + + public StaticFileWebFilter() { + this("/login", new ClassPathResource(INDEX_HTML)); + } + + public StaticFileWebFilter(String path, ClassPathResource resource) { + this.matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, path); + + try { + this.contents = ResourceUtil.readAsString(resource); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public @NotNull Mono filter(@NotNull ServerWebExchange exchange, WebFilterChain chain) { + return this.matcher.matches(exchange) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) + .flatMap((matchResult) -> this.render(exchange)); + } + + private Mono render(ServerWebExchange exchange) { + String contextPath = exchange.getRequest().getPath().contextPath().value(); + + String contentBody = contents + .replace("\"assets/", "\"" + contextPath + "/assets/") + .replace("PUBLIC-PATH-VARIABLE", contextPath); + + ServerHttpResponse result = exchange.getResponse(); + result.setStatusCode(HttpStatus.OK); + result.getHeaders().setContentType(MediaType.TEXT_HTML); + DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); + return result.writeWith(Mono.just(bufferFactory.wrap(contentBody.getBytes()))); + } + +} diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index 04cc17514..dff80b4ce 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -31,7 +31,6 @@ paths: items: $ref: '#/components/schemas/Cluster' - /api/clusters/{clusterName}/cache: post: tags: @@ -54,7 +53,6 @@ paths: 404: description: Not found - /api/clusters/{clusterName}/brokers: get: tags: @@ -432,7 +430,6 @@ paths: 404: description: Not found - /api/clusters/{clusterName}/topics/{topicName}: get: tags: @@ -2150,7 +2147,7 @@ paths: get: tags: - Authorization - summary: Get user authentication related info + summary: Get user authorization related info operationId: getUserAuthInfo responses: 200: @@ -2220,7 +2217,6 @@ paths: schema: $ref: '#/components/schemas/ApplicationConfigValidation' - /api/config/relatedfiles: post: tags: @@ -2244,6 +2240,40 @@ paths: schema: $ref: '#/components/schemas/UploadedFileInfo' + /api/config/authentication: + get: + tags: + - ApplicationConfig + summary: Get authentication methods enabled for the app and other related settings + operationId: getAuthenticationSettings + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AppAuthenticationSettings' + + /login: + post: + summary: Authenticate + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + '200': + description: OK + '401': + description: Unauthorized + components: schemas: TopicSerdeSuggestion: @@ -2354,6 +2384,32 @@ components: htmlUrl: type: string + AppAuthenticationSettings: + type: object + properties: + authType: + $ref: '#/components/schemas/AuthType' + oAuthProviders: + type: array + items: + $ref: '#/components/schemas/OAuthProvider' + + OAuthProvider: + type: object + properties: + clientName: + type: string + authorizationUri: + type: string + + AuthType: + type: string + enum: + - DISABLED + - OAUTH2 + - LOGIN_FORM + - LDAP + Cluster: type: object properties: From 21edbf7f3227a77a71c77396f4fa7730582ef0a7 Mon Sep 17 00:00:00 2001 From: Renat Kalimulin <103274228+Nilumilak@users.noreply.github.com> Date: Sat, 28 Dec 2024 07:19:29 +0300 Subject: [PATCH 2/7] FE: Impl custom auth page (#402) Co-authored-by: Roman Zabaluev --- .../main/resources/swagger/kafbat-ui-api.yaml | 3 + frontend/public/serviceImage.png | Bin 0 -> 7361 bytes frontend/src/components/App.tsx | 94 ++++++++-------- .../components/AuthPage/AuthPage.styled.tsx | 14 +++ frontend/src/components/AuthPage/AuthPage.tsx | 21 ++++ .../AuthPage/Header/Header.styled.tsx | 33 ++++++ .../src/components/AuthPage/Header/Header.tsx | 81 ++++++++++++++ .../components/AuthPage/Header/HeaderLogo.tsx | 29 +++++ .../SignIn/BasicSignIn/BasicSignIn.styled.tsx | 56 ++++++++++ .../SignIn/BasicSignIn/BasicSignIn.tsx | 101 ++++++++++++++++++ .../OAuthSignIn/AuthCard/AuthCard.styled.tsx | 66 ++++++++++++ .../SignIn/OAuthSignIn/AuthCard/AuthCard.tsx | 41 +++++++ .../SignIn/OAuthSignIn/OAuthSignIn.styled.tsx | 25 +++++ .../SignIn/OAuthSignIn/OAuthSignIn.tsx | 55 ++++++++++ .../AuthPage/SignIn/SignIn.styled.tsx | 19 ++++ .../src/components/AuthPage/SignIn/SignIn.tsx | 27 +++++ .../components/NavBar/UserInfo/UserInfo.tsx | 2 +- .../src/components/common/Button/Button.tsx | 3 +- .../src/components/common/Icons/AlertIcon.tsx | 22 ++++ .../components/common/Icons/CognitoIcon.tsx | 49 +++++++++ .../components/common/Icons/GoogleIcon.tsx | 32 ++++++ .../components/common/Icons/KeycloakIcon.tsx | 21 ++++ .../src/components/common/Icons/OktaIcon.tsx | 20 ++++ .../components/common/Icons/ServiceImage.tsx | 11 ++ .../contexts/GlobalSettingsContext.tsx | 28 +++-- frontend/src/lib/api.ts | 2 + frontend/src/lib/hooks/api/appConfig.ts | 47 ++++++-- frontend/src/theme/theme.ts | 72 +++++++++++++ frontend/vite.config.ts | 16 +++ 29 files changed, 929 insertions(+), 61 deletions(-) create mode 100644 frontend/public/serviceImage.png create mode 100644 frontend/src/components/AuthPage/AuthPage.styled.tsx create mode 100644 frontend/src/components/AuthPage/AuthPage.tsx create mode 100644 frontend/src/components/AuthPage/Header/Header.styled.tsx create mode 100644 frontend/src/components/AuthPage/Header/Header.tsx create mode 100644 frontend/src/components/AuthPage/Header/HeaderLogo.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx create mode 100644 frontend/src/components/AuthPage/SignIn/SignIn.tsx create mode 100644 frontend/src/components/common/Icons/AlertIcon.tsx create mode 100644 frontend/src/components/common/Icons/CognitoIcon.tsx create mode 100644 frontend/src/components/common/Icons/GoogleIcon.tsx create mode 100644 frontend/src/components/common/Icons/KeycloakIcon.tsx create mode 100644 frontend/src/components/common/Icons/OktaIcon.tsx create mode 100644 frontend/src/components/common/Icons/ServiceImage.tsx diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index dff80b4ce..97d0e5b11 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -2256,7 +2256,10 @@ paths: /login: post: + tags: + - Unmapped summary: Authenticate + operationId: authenticate requestBody: required: true content: diff --git a/frontend/public/serviceImage.png b/frontend/public/serviceImage.png new file mode 100644 index 0000000000000000000000000000000000000000..8006b13f5c09b4cee6289790a38df545177b25dc GIT binary patch literal 7361 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 16dd1305d..d15cb95a0 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,5 +1,5 @@ import React, { Suspense, useContext } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useMatch } from 'react-router-dom'; import { accessErrorPage, clusterPath, @@ -24,6 +24,7 @@ import { GlobalSettingsProvider } from './contexts/GlobalSettingsContext'; import { UserInfoRolesAccessProvider } from './contexts/UserInfoRolesAccessContext'; import PageContainer from './PageContainer/PageContainer'; +const AuthPage = React.lazy(() => import('components/AuthPage/AuthPage')); const Dashboard = React.lazy(() => import('components/Dashboard/Dashboard')); const ClusterPage = React.lazy( () => import('components/ClusterPage/ClusterPage') @@ -49,54 +50,59 @@ const queryClient = new QueryClient({ }); const App: React.FC = () => { const { isDarkMode } = useContext(ThemeModeContext); + const isAuthRoute = useMatch('/login'); return ( - - - }> - - - - - - - {['/', '/ui', '/ui/clusters'].map((path) => ( + + {isAuthRoute ? ( + + ) : ( + + }> + + + + + + + {['/', '/ui', '/ui/clusters'].map((path) => ( + } + /> + ))} } + path={getNonExactPath(clusterNewConfigPath)} + element={} /> - ))} - } - /> - } - /> - - } - /> - } /> - } - /> - - - - - - - - - - + } + /> + + } + /> + } /> + } + /> + + + + + + + + + + )} + ); diff --git a/frontend/src/components/AuthPage/AuthPage.styled.tsx b/frontend/src/components/AuthPage/AuthPage.styled.tsx new file mode 100644 index 000000000..16f86f714 --- /dev/null +++ b/frontend/src/components/AuthPage/AuthPage.styled.tsx @@ -0,0 +1,14 @@ +import styled, { css } from 'styled-components'; + +export const AuthPageStyled = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + min-height: 100vh; + background-color: ${theme.auth_page.backgroundColor}; + font-family: ${theme.auth_page.fontFamily}; + overflow-x: hidden; + ` +); diff --git a/frontend/src/components/AuthPage/AuthPage.tsx b/frontend/src/components/AuthPage/AuthPage.tsx new file mode 100644 index 000000000..ceae3069a --- /dev/null +++ b/frontend/src/components/AuthPage/AuthPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useAuthSettings } from 'lib/hooks/api/appConfig'; + +import Header from './Header/Header'; +import SignIn from './SignIn/SignIn'; +import * as S from './AuthPage.styled'; + +function AuthPage() { + const { data } = useAuthSettings(); + + return ( + +
+ {data && ( + + )} + + ); +} + +export default AuthPage; diff --git a/frontend/src/components/AuthPage/Header/Header.styled.tsx b/frontend/src/components/AuthPage/Header/Header.styled.tsx new file mode 100644 index 000000000..4ba86f2bc --- /dev/null +++ b/frontend/src/components/AuthPage/Header/Header.styled.tsx @@ -0,0 +1,33 @@ +import styled, { css } from 'styled-components'; + +export const HeaderStyled = styled.div` + display: grid; + grid-template-columns: repeat(47, 41.11px); + grid-template-rows: repeat(4, 41.11px); + justify-content: center; + margin-bottom: 13.5px; +`; + +export const HeaderCell = styled.div<{ $sections?: number }>( + ({ theme, $sections }) => css` + border: 1.23px solid ${theme.auth_page.header.cellBorderColor}; + border-radius: 75.98px; + ${$sections && `grid-column: span ${$sections};`} + ` +); + +export const StyledSVG = styled.svg` + grid-column: span 3; +`; + +export const StyledRect = styled.rect( + ({ theme }) => css` + fill: ${theme.auth_page.header.LogoBgColor}; + ` +); + +export const StyledPath = styled.path( + ({ theme }) => css` + fill: ${theme.auth_page.header.LogoTextColor}; + ` +); diff --git a/frontend/src/components/AuthPage/Header/Header.tsx b/frontend/src/components/AuthPage/Header/Header.tsx new file mode 100644 index 000000000..16980af29 --- /dev/null +++ b/frontend/src/components/AuthPage/Header/Header.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import * as S from './Header.styled'; +import HeaderLogo from './HeaderLogo'; + +function Header() { + return ( + + + {Array(2).fill()} + + {Array(2).fill()} + + {Array(2).fill()} + {Array(4).fill()} + {Array(2).fill()} + + {Array(2).fill()} + + {Array(3).fill()} + + {Array(2).fill()} + {Array(2).fill()} + {Array(2).fill()} + + + {Array(3).fill()} + {Array(8).fill()} + + {Array(2).fill()} + + {Array(3).fill()} + + {Array(6).fill()} + {Array(3).fill()} + + + {Array(2).fill()} + + + + + + + + {Array(2).fill()} + + + + {Array(3).fill()} + + + {Array(3).fill()} + + {Array(3).fill()} + {Array(3).fill()} + + + + + + {Array(2).fill()} + + {Array(2).fill()} + {Array(5).fill()} + {Array(2).fill()} + + + + {Array(5).fill()} + {Array(2).fill()} + + {Array(2).fill()} + + + + + ); +} + +export default Header; diff --git a/frontend/src/components/AuthPage/Header/HeaderLogo.tsx b/frontend/src/components/AuthPage/Header/HeaderLogo.tsx new file mode 100644 index 000000000..e5d9ca12d --- /dev/null +++ b/frontend/src/components/AuthPage/Header/HeaderLogo.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import * as S from './Header.styled'; + +const HeaderLogo = () => ( + + + + + + + + + + +); + +export default HeaderLogo; diff --git a/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx new file mode 100644 index 000000000..da1388b0a --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx @@ -0,0 +1,56 @@ +import styled from 'styled-components'; + +export const Fieldset = styled.fieldset` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + border: none; + width: 100%; +`; + +export const Form = styled.form` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; + width: 100%; + + ${Fieldset} div { + width: 100%; + } +`; + +export const Field = styled.div` + ${({ theme }) => theme.auth_page.signIn.label}; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 4px; +`; + +export const Label = styled.label` + font-size: 12px; + font-weight: 500; + line-height: 16px; +`; + +export const ErrorMessage = styled.div` + display: flex; + column-gap: 2px; + align-items: center; + justify-content: center; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; + +export const ErrorMessageText = styled.span` + ${({ theme }) => theme.auth_page.signIn.errorMessage}; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; diff --git a/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx new file mode 100644 index 000000000..044f4781b --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Button } from 'components/common/Button/Button'; +import Input from 'components/common/Input/Input'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useAuthenticate } from 'lib/hooks/api/appConfig'; +import AlertIcon from 'components/common/Icons/AlertIcon'; +import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + +import * as S from './BasicSignIn.styled'; + +interface FormValues { + username: string; + password: string; +} + +function BasicSignIn() { + const methods = useForm({ + defaultValues: { username: '', password: '' }, + }); + const navigate = useNavigate(); + const { mutateAsync, isLoading } = useAuthenticate(); + const client = useQueryClient(); + + const onSubmit = async (data: FormValues) => { + await mutateAsync(data, { + onSuccess: async (response) => { + if (response.raw.url.includes('error')) { + methods.setError('root', { message: 'error' }); + } else { + await client.invalidateQueries({ queryKey: ['app', 'info'] }); + navigate('/'); + } + }, + }); + }; + + return ( + + + + {methods.formState.errors.root && ( + + + + Username or password entered incorrectly + + + )} + ( + + Username + + + )} + /> + ( + + Password + + + )} + /> + + + + + ); +} + +export default BasicSignIn; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx new file mode 100644 index 000000000..d1eae050f --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx @@ -0,0 +1,66 @@ +import styled, { css } from 'styled-components'; +import GitHubIcon from 'components/common/Icons/GitHubIcon'; +import { Button } from 'components/common/Button/Button'; + +export const AuthCardStyled = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + width: 400px; + border: 1px solid black; + border: 1px solid ${theme.auth_page.signIn.authCard.borderColor}; + border-radius: ${theme.auth_page.signIn.authCard.borderRadius}; + background-color: ${theme.auth_page.signIn.authCard.backgroundColor}; + ` +); + +export const ServiceData = styled.div( + ({ theme }) => css` + display: flex; + gap: 8px; + align-items: center; + + svg, + img { + margin: 8px; + width: 48px; + height: 48px; + } + + ${GitHubIcon} { + fill: ${theme.auth_page.icons.githubColor}; + } + ` +); + +export const ServiceDataTextContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const ServiceNameStyled = styled.span( + ({ theme }) => css` + color: ${theme.auth_page.signIn.authCard.serviceNamecolor}; + font-size: 16px; + font-weight: 500; + line-height: 24px; + ` +); + +export const ServiceTextStyled = styled.span( + ({ theme }) => css` + color: ${theme.auth_page.signIn.authCard.serviceTextColor}; + font-size: 12px; + font-weight: 500; + line-height: 16px; + ` +); + +export const ServiceButton = styled(Button)` + width: 100%; + border-radius: 8px; + font-size: 14px; + text-decoration: none; +`; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx new file mode 100644 index 000000000..b9a09812b --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx @@ -0,0 +1,41 @@ +import React, { ElementType, useState } from 'react'; +import ServiceImage from 'components/common/Icons/ServiceImage'; + +import * as S from './AuthCard.styled'; + +interface Props { + serviceName: string; + authPath: string | undefined; + Icon?: ElementType; +} + +function AuthCard({ serviceName, authPath, Icon = ServiceImage }: Props) { + const [isLoading, setIsLoading] = useState(false); + + return ( + + + + + {serviceName} + + Use an account issued by the organization + + + + { + setIsLoading(true); + window.location.replace(`${window.basePath}${authPath}`); + }} + inProgress={isLoading} + > + {!isLoading && `Log in with ${serviceName}`} + + + ); +} + +export default AuthCard; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx new file mode 100644 index 000000000..bf238e9b2 --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +export const OAuthSignInStyled = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const ErrorMessage = styled.div` + display: flex; + column-gap: 2px; + align-items: center; + justify-content: center; + font-weight: 400; + font-size: 14px; + line-height: 20px; + margin-bottom: 8px; +`; + +export const ErrorMessageText = styled.span` + ${({ theme }) => theme.auth_page.signIn.errorMessage}; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx new file mode 100644 index 000000000..fca5b4925 --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx @@ -0,0 +1,55 @@ +import React, { ElementType } from 'react'; +import GitHubIcon from 'components/common/Icons/GitHubIcon'; +import GoogleIcon from 'components/common/Icons/GoogleIcon'; +import CognitoIcon from 'components/common/Icons/CognitoIcon'; +import OktaIcon from 'components/common/Icons/OktaIcon'; +import KeycloakIcon from 'components/common/Icons/KeycloakIcon'; +import ServiceImage from 'components/common/Icons/ServiceImage'; +import { OAuthProvider } from 'generated-sources'; +import { useLocation } from 'react-router-dom'; +import AlertIcon from 'components/common/Icons/AlertIcon'; + +import * as S from './OAuthSignIn.styled'; +import AuthCard from './AuthCard/AuthCard'; + +interface Props { + oAuthProviders: OAuthProvider[] | undefined; +} + +const ServiceIconMap: Record = { + github: GitHubIcon, + google: GoogleIcon, + cognito: CognitoIcon, + keycloak: KeycloakIcon, + okta: OktaIcon, + unknownService: ServiceImage, +}; + +function OAuthSignIn({ oAuthProviders }: Props) { + const { search } = useLocation(); + + return ( + + {search.includes('error') && ( + + + Invalid credentials + + )} + {oAuthProviders?.map((provider) => ( + + ))} + + ); +} + +export default OAuthSignIn; diff --git a/frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx b/frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx new file mode 100644 index 000000000..0f24b45fd --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx @@ -0,0 +1,19 @@ +import styled, { css } from 'styled-components'; + +export const SignInStyled = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 320px; + gap: 56px; + flex-grow: 1; +`; + +export const SignInTitle = styled.span( + ({ theme }) => css` + color: ${theme.auth_page.signIn.titleColor}; + font-size: 24px; + font-weight: 600; + ` +); diff --git a/frontend/src/components/AuthPage/SignIn/SignIn.tsx b/frontend/src/components/AuthPage/SignIn/SignIn.tsx new file mode 100644 index 000000000..987ee5ebf --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/SignIn.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { AuthType, OAuthProvider } from 'generated-sources'; + +import BasicSignIn from './BasicSignIn/BasicSignIn'; +import * as S from './SignIn.styled'; +import OAuthSignIn from './OAuthSignIn/OAuthSignIn'; + +interface Props { + authType?: AuthType; + oAuthProviders?: OAuthProvider[]; +} + +function SignInForm({ authType, oAuthProviders }: Props) { + return ( + + Sign in + {(authType === AuthType.LDAP || authType === AuthType.LOGIN_FORM) && ( + + )} + {authType === AuthType.OAUTH2 && ( + + )} + + ); +} + +export default SignInForm; diff --git a/frontend/src/components/NavBar/UserInfo/UserInfo.tsx b/frontend/src/components/NavBar/UserInfo/UserInfo.tsx index dae43364c..b52cc7631 100644 --- a/frontend/src/components/NavBar/UserInfo/UserInfo.tsx +++ b/frontend/src/components/NavBar/UserInfo/UserInfo.tsx @@ -19,7 +19,7 @@ const UserInfo = () => { } > - + Log out diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index 828b5d301..8964b6e17 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -9,6 +9,7 @@ export interface Props ButtonProps { to?: string | object; inProgress?: boolean; + className?: string; } export const Button: FC = ({ @@ -20,7 +21,7 @@ export const Button: FC = ({ }) => { if (to) { return ( - + {children} diff --git a/frontend/src/components/common/Icons/AlertIcon.tsx b/frontend/src/components/common/Icons/AlertIcon.tsx new file mode 100644 index 000000000..3c79f78e6 --- /dev/null +++ b/frontend/src/components/common/Icons/AlertIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const AlertIcon: React.FC = () => { + return ( + + + + ); +}; + +export default AlertIcon; diff --git a/frontend/src/components/common/Icons/CognitoIcon.tsx b/frontend/src/components/common/Icons/CognitoIcon.tsx new file mode 100644 index 000000000..2d0b0d38a --- /dev/null +++ b/frontend/src/components/common/Icons/CognitoIcon.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import styled from 'styled-components'; + +function CognitoIcon() { + return ( + + + + + + + + + + + + + + + ); +} + +export default styled(CognitoIcon)``; diff --git a/frontend/src/components/common/Icons/GoogleIcon.tsx b/frontend/src/components/common/Icons/GoogleIcon.tsx new file mode 100644 index 000000000..2e569dbfe --- /dev/null +++ b/frontend/src/components/common/Icons/GoogleIcon.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styled from 'styled-components'; + +function GoogleIcon() { + return ( + + + + + + + ); +} + +export default styled(GoogleIcon)``; diff --git a/frontend/src/components/common/Icons/KeycloakIcon.tsx b/frontend/src/components/common/Icons/KeycloakIcon.tsx new file mode 100644 index 000000000..e6b45ef69 --- /dev/null +++ b/frontend/src/components/common/Icons/KeycloakIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styled from 'styled-components'; + +function KeycloakIcon() { + return ( + + + + + ); +} + +export default styled(KeycloakIcon)``; diff --git a/frontend/src/components/common/Icons/OktaIcon.tsx b/frontend/src/components/common/Icons/OktaIcon.tsx new file mode 100644 index 000000000..a9d6871b0 --- /dev/null +++ b/frontend/src/components/common/Icons/OktaIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from 'styled-components'; + +function OktaIcon() { + return ( + + + + ); +} + +export default styled(OktaIcon)``; diff --git a/frontend/src/components/common/Icons/ServiceImage.tsx b/frontend/src/components/common/Icons/ServiceImage.tsx new file mode 100644 index 000000000..9311334f1 --- /dev/null +++ b/frontend/src/components/common/Icons/ServiceImage.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +interface Props { + serviceName: string; +} + +function ServiceImage({ serviceName }: Props) { + return {serviceName}; +} + +export default ServiceImage; diff --git a/frontend/src/components/contexts/GlobalSettingsContext.tsx b/frontend/src/components/contexts/GlobalSettingsContext.tsx index 4de05307b..5e906c292 100644 --- a/frontend/src/components/contexts/GlobalSettingsContext.tsx +++ b/frontend/src/components/contexts/GlobalSettingsContext.tsx @@ -1,6 +1,7 @@ import { useAppInfo } from 'lib/hooks/api/appConfig'; import React from 'react'; import { ApplicationInfoEnabledFeaturesEnum } from 'generated-sources'; +import { useNavigate } from 'react-router-dom'; interface GlobalSettingsContextProps { hasDynamicConfig: boolean; @@ -15,13 +16,26 @@ export const GlobalSettingsProvider: React.FC< React.PropsWithChildren > = ({ children }) => { const info = useAppInfo(); - const value = React.useMemo(() => { - const features = info.data?.enabledFeatures || []; - return { - hasDynamicConfig: features.includes( - ApplicationInfoEnabledFeaturesEnum.DYNAMIC_CONFIG - ), - }; + const navigate = useNavigate(); + const [value, setValue] = React.useState({ + hasDynamicConfig: false, + }); + + React.useEffect(() => { + if (info.data?.redirect && !info.isFetching) { + navigate('login'); + return; + } + + const features = info?.data?.response?.enabledFeatures; + + if (features) { + setValue({ + hasDynamicConfig: features.includes( + ApplicationInfoEnabledFeaturesEnum.DYNAMIC_CONFIG + ), + }); + } }, [info.data]); return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 19423d2ac..d6f409ea2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,7 @@ import { AuthorizationApi, ApplicationConfigApi, AclsApi, + UnmappedApi, } from 'generated-sources'; import { BASE_PARAMS } from 'lib/constants'; @@ -27,3 +28,4 @@ export const consumerGroupsApiClient = new ConsumerGroupsApi(apiClientConf); export const authApiClient = new AuthorizationApi(apiClientConf); export const appConfigApiClient = new ApplicationConfigApi(apiClientConf); export const aclApiClient = new AclsApi(apiClientConf); +export const internalApiClient = new UnmappedApi(apiClientConf); diff --git a/frontend/src/lib/hooks/api/appConfig.ts b/frontend/src/lib/hooks/api/appConfig.ts index e3ee0fdcb..a91c6eb4b 100644 --- a/frontend/src/lib/hooks/api/appConfig.ts +++ b/frontend/src/lib/hooks/api/appConfig.ts @@ -1,21 +1,52 @@ -import { appConfigApiClient as api } from 'lib/api'; +import { + appConfigApiClient as appConfig, + internalApiClient as internalApi, +} from 'lib/api'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApplicationConfig, ApplicationConfigPropertiesKafkaClusters, + ApplicationInfo, } from 'generated-sources'; import { QUERY_REFETCH_OFF_OPTIONS } from 'lib/constants'; -export function useAppInfo() { +export function useAuthSettings() { return useQuery( - ['app', 'info'], - () => api.getApplicationInfo(), + ['app', 'authSettings'], + () => appConfig.getAuthenticationSettings(), QUERY_REFETCH_OFF_OPTIONS ); } +export function useAuthenticate() { + return useMutation({ + mutationFn: (params: { username: string; password: string }) => + internalApi.authenticateRaw(params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }), + }); +} + +export function useAppInfo() { + return useQuery(['app', 'info'], async () => { + const data = await appConfig.getApplicationInfoRaw(); + + let response: ApplicationInfo = {}; + try { + response = await data.value(); + } catch { + response = {}; + } + + return { + redirect: data.raw.url.includes('auth'), + response, + }; + }); +} + export function useAppConfig() { - return useQuery(['app', 'config'], () => api.getCurrentConfig()); + return useQuery(['app', 'config'], () => appConfig.getCurrentConfig()); } function aggregateClusters( @@ -47,7 +78,7 @@ export function useUpdateAppConfig({ const client = useQueryClient(); return useMutation( async (cluster: ApplicationConfigPropertiesKafkaClusters) => { - const existingConfig = await api.getCurrentConfig(); + const existingConfig = await appConfig.getCurrentConfig(); const clusters = aggregateClusters( cluster, @@ -63,7 +94,7 @@ export function useUpdateAppConfig({ kafka: { clusters }, }, }; - return api.restartWithConfig({ restartRequest: { config } }); + return appConfig.restartWithConfig({ restartRequest: { config } }); }, { onSuccess: () => client.invalidateQueries(['app', 'config']), @@ -82,7 +113,7 @@ export function useAppConfigFilesUpload() { export function useValidateAppConfig() { return useMutation((config: ApplicationConfigPropertiesKafkaClusters) => - api.validateConfig({ + appConfig.validateConfig({ applicationConfig: { properties: { kafka: { clusters: [config] } } }, }) ); diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index f6cd2bacc..bdfe93271 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -57,6 +57,7 @@ const Colors = { '10': '#FAD1D1', '20': '#F5A3A3', '50': '#E51A1A', + '52': '#E63B19', '55': '#CF1717', '60': '#B81414', }, @@ -79,6 +80,45 @@ const Colors = { const baseTheme = { defaultIconColor: Colors.neutral[50], + auth_page: { + backgroundColor: Colors.brand[0], + fontFamily: 'Inter, sans-serif', + header: { + cellBorderColor: Colors.brand[10], + LogoBgColor: Colors.brand[90], + LogoTextColor: Colors.brand[0], + }, + signIn: { + titleColor: Colors.brand[90], + errorMessage: { + color: Colors.red[52], + }, + label: { + color: Colors.brand[70], + }, + authCard: { + borderRadius: '16px', + borderColor: Colors.brand[10], + backgroundColor: Colors.brand[0], + serviceNamecolor: Colors.brand[90], + serviceTextColor: Colors.brand[50], + }, + }, + footer: { + fontSize: '12px', + span: { + color: Colors.brand[70], + fontWeight: 500, + }, + p: { + color: Colors.brand[50], + fontWeight: 400, + }, + }, + icons: { + githubColor: Colors.brand[90], + }, + }, heading: { h1: { color: Colors.neutral[90], @@ -821,6 +861,38 @@ export type ThemeType = typeof theme; export const darkTheme: ThemeType = { ...baseTheme, + auth_page: { + backgroundColor: Colors.neutral[90], + fontFamily: baseTheme.auth_page.fontFamily, + header: { + cellBorderColor: Colors.brand[80], + LogoBgColor: Colors.brand[0], + LogoTextColor: Colors.brand[90], + }, + signIn: { + ...baseTheme.auth_page.signIn, + titleColor: Colors.brand[0], + label: { + color: Colors.brand[30], + }, + authCard: { + ...baseTheme.auth_page.signIn.authCard, + borderColor: Colors.brand[80], + backgroundColor: Colors.brand[85], + serviceNamecolor: Colors.brand[0], + }, + }, + footer: { + ...baseTheme.auth_page.footer, + span: { + color: Colors.brand[10], + fontWeight: 500, + }, + }, + icons: { + githubColor: Colors.brand[0], + }, + }, logo: { color: '#FDFDFD', }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3a4e861e9..455ef39ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { ViteEjsPlugin } from 'vite-plugin-ejs'; import checker from 'vite-plugin-checker'; +import { IncomingMessage } from 'http'; export default defineConfig(({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; @@ -87,6 +88,21 @@ export default defineConfig(({ mode }) => { ...defaultConfig.server, open: true, proxy: { + '/login': { + target: isProxy, + changeOrigin: true, + secure: false, + bypass: (req: IncomingMessage) => { + if (req.method === 'GET') { + return req.url; + } + }, + }, + '/logout': { + target: isProxy, + changeOrigin: true, + secure: false, + }, '/api': { target: isProxy, changeOrigin: true, From 64f63d102a33dc0fc886fe67526d48178f115a19 Mon Sep 17 00:00:00 2001 From: Yeikel Date: Fri, 27 Dec 2024 23:54:26 -0500 Subject: [PATCH 3/7] BE: Chore: standarize Protobuf import paths (#723) --- .../ui/serdes/builtin/ProtobufFileSerde.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/api/src/main/java/io/kafbat/ui/serdes/builtin/ProtobufFileSerde.java b/api/src/main/java/io/kafbat/ui/serdes/builtin/ProtobufFileSerde.java index 723474cae..2c0939c03 100644 --- a/api/src/main/java/io/kafbat/ui/serdes/builtin/ProtobufFileSerde.java +++ b/api/src/main/java/io/kafbat/ui/serdes/builtin/ProtobufFileSerde.java @@ -64,6 +64,7 @@ import javax.annotation.Nullable; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; @Slf4j @@ -416,7 +417,7 @@ private Map loadFilesWithLocations() { files.filter(p -> !Files.isDirectory(p) && p.toString().endsWith(".proto")) .forEach(path -> { // relative path will be used as "import" statement - String relativePath = baseLocation.relativize(path).toString(); + String relativePath = removeBackSlashes(baseLocation.relativize(path).toString()); var protoFileElement = ProtoParser.Companion.parse( Location.get(baseLocation.toString(), relativePath), readFileAsString(path) @@ -426,6 +427,27 @@ private Map loadFilesWithLocations() { } return filesByLocations; } + + /** + * Replaces backslashes in the given file path with forward slashes if the operating system is Windows. + * + *

This method is designed to standardize file paths by converting Windows-style backslashes (`\`) + * to Linux/Unix-style forward slashes (`/`) when the application is running on a Windows OS. + * On other operating systems, the input path is returned unchanged.

+ * + *

This is needed because imports in Protobuf use forward slashes (`/`) + * which causes a conflict with Windows paths. For example,`language/language.proto` + * would be converted to `language\language.proto` in Windows causing a resolution exception

+ * + * @param path the file path to standardize; must not be {@code null}. + * @return the standardized file path with forward slashes if running on Windows, or the original path otherwise. + */ + private @NotNull String removeBackSlashes(@NotNull final String path) { + if (SystemUtils.IS_OS_WINDOWS) { + return path.replace("\\", "/"); + } + return path; + } } } From 0f0e2a99e0ad5e85b60a014be74237e3819f6948 Mon Sep 17 00:00:00 2001 From: Dmitry Werner Date: Sat, 28 Dec 2024 12:55:02 +0500 Subject: [PATCH 4/7] BE: RBAC: Ignore values for non-applicable resources (#503) --- .../main/java/io/kafbat/ui/model/rbac/AccessContext.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java b/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java index 9ccc10ccf..dbf5c456b 100644 --- a/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java +++ b/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java @@ -69,8 +69,10 @@ public boolean isAccessible(List userPermissions) throws AccessDenie if (name == null && permission.getCompiledValuePattern() == null) { return true; } - Preconditions.checkState(permission.getCompiledValuePattern() != null && name != null); - return permission.getCompiledValuePattern().matcher(name).matches(); + if (permission.getCompiledValuePattern() != null && name != null) { + return permission.getCompiledValuePattern().matcher(name).matches(); + } + return false; }) .flatMap(p -> p.getParsedActions().stream()) .collect(Collectors.toSet()); From d093752b7df445625c24daae57727ac1fb93ce10 Mon Sep 17 00:00:00 2001 From: Roman Zabaluev Date: Mon, 30 Dec 2024 06:22:25 +0400 Subject: [PATCH 5/7] BE: Implement a mechanism to skip SSL verification (#422) --- .../kafbat/ui/config/ClustersProperties.java | 39 ++++++++++++------- .../ui/service/AdminClientServiceImpl.java | 4 +- .../ui/service/ConsumerGroupService.java | 4 +- .../io/kafbat/ui/service/MessagesService.java | 4 +- .../kafbat/ui/service/ksql/KsqlApiClient.java | 4 +- .../ui/util/KafkaClientSslPropertiesUtil.java | 35 +++++++++++++++++ .../ui/util/KafkaServicesValidation.java | 2 +- .../io/kafbat/ui/util/SslPropertiesUtil.java | 23 ----------- .../kafbat/ui/util/WebClientConfigurator.java | 16 ++++++++ .../main/resources/swagger/kafbat-ui-api.yaml | 4 ++ 10 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 api/src/main/java/io/kafbat/ui/util/KafkaClientSslPropertiesUtil.java delete mode 100644 api/src/main/java/io/kafbat/ui/util/SslPropertiesUtil.java diff --git a/api/src/main/java/io/kafbat/ui/config/ClustersProperties.java b/api/src/main/java/io/kafbat/ui/config/ClustersProperties.java index e91a5bc9a..5931602b2 100644 --- a/api/src/main/java/io/kafbat/ui/config/ClustersProperties.java +++ b/api/src/main/java/io/kafbat/ui/config/ClustersProperties.java @@ -35,22 +35,31 @@ public class ClustersProperties { public static class Cluster { String name; String bootstrapServers; + + TruststoreConfig ssl; + String schemaRegistry; SchemaRegistryAuth schemaRegistryAuth; KeystoreConfig schemaRegistrySsl; + String ksqldbServer; KsqldbServerAuth ksqldbServerAuth; KeystoreConfig ksqldbServerSsl; + List kafkaConnect; - MetricsConfigData metrics; - Map properties; - boolean readOnly = false; + List serde; String defaultKeySerde; String defaultValueSerde; - List masking; + + MetricsConfigData metrics; + Map properties; + boolean readOnly = false; + Long pollingThrottleRate; - TruststoreConfig ssl; + + List masking; + AuditProperties audit; } @@ -99,6 +108,16 @@ public static class SchemaRegistryAuth { public static class TruststoreConfig { String truststoreLocation; String truststorePassword; + boolean verifySsl = true; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString(exclude = {"keystorePassword"}) + public static class KeystoreConfig { + String keystoreLocation; + String keystorePassword; } @Data @@ -118,15 +137,6 @@ public static class KsqldbServerAuth { String password; } - @Data - @NoArgsConstructor - @AllArgsConstructor - @ToString(exclude = {"keystorePassword"}) - public static class KeystoreConfig { - String keystoreLocation; - String keystorePassword; - } - @Data public static class Masking { Type type; @@ -182,6 +192,7 @@ private void flattenClusterProperties() { } } + @SuppressWarnings("unchecked") private Map flattenClusterProperties(@Nullable String prefix, @Nullable Map propertiesMap) { Map flattened = new HashMap<>(); diff --git a/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java b/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java index bc175b980..e3613c94e 100644 --- a/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java +++ b/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java @@ -2,7 +2,7 @@ import io.kafbat.ui.config.ClustersProperties; import io.kafbat.ui.model.KafkaCluster; -import io.kafbat.ui.util.SslPropertiesUtil; +import io.kafbat.ui.util.KafkaClientSslPropertiesUtil; import java.io.Closeable; import java.time.Instant; import java.util.Map; @@ -42,7 +42,7 @@ public Mono get(KafkaCluster cluster) { private Mono createAdminClient(KafkaCluster cluster) { return Mono.fromSupplier(() -> { Properties properties = new Properties(); - SslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), properties); + KafkaClientSslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), properties); properties.putAll(cluster.getProperties()); properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, cluster.getBootstrapServers()); properties.putIfAbsent(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, clientTimeout); diff --git a/api/src/main/java/io/kafbat/ui/service/ConsumerGroupService.java b/api/src/main/java/io/kafbat/ui/service/ConsumerGroupService.java index b2d6bd20f..282bdc5b6 100644 --- a/api/src/main/java/io/kafbat/ui/service/ConsumerGroupService.java +++ b/api/src/main/java/io/kafbat/ui/service/ConsumerGroupService.java @@ -10,7 +10,7 @@ import io.kafbat.ui.model.SortOrderDTO; import io.kafbat.ui.service.rbac.AccessControlService; import io.kafbat.ui.util.ApplicationMetrics; -import io.kafbat.ui.util.SslPropertiesUtil; +import io.kafbat.ui.util.KafkaClientSslPropertiesUtil; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; @@ -264,7 +264,7 @@ public EnhancedConsumer createConsumer(KafkaCluster cluster) { public EnhancedConsumer createConsumer(KafkaCluster cluster, Map properties) { Properties props = new Properties(); - SslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), props); + KafkaClientSslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), props); props.putAll(cluster.getProperties()); props.put(ConsumerConfig.CLIENT_ID_CONFIG, "kafbat-ui-consumer-" + System.currentTimeMillis()); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, cluster.getBootstrapServers()); diff --git a/api/src/main/java/io/kafbat/ui/service/MessagesService.java b/api/src/main/java/io/kafbat/ui/service/MessagesService.java index 2f6192e11..c94472d56 100644 --- a/api/src/main/java/io/kafbat/ui/service/MessagesService.java +++ b/api/src/main/java/io/kafbat/ui/service/MessagesService.java @@ -23,7 +23,7 @@ import io.kafbat.ui.model.TopicMessageEventDTO; import io.kafbat.ui.serdes.ConsumerRecordDeserializer; import io.kafbat.ui.serdes.ProducerRecordCreator; -import io.kafbat.ui.util.SslPropertiesUtil; +import io.kafbat.ui.util.KafkaClientSslPropertiesUtil; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -199,7 +199,7 @@ private Mono sendMessageImpl(KafkaCluster cluster, public static KafkaProducer createProducer(KafkaCluster cluster, Map additionalProps) { Properties properties = new Properties(); - SslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), properties); + KafkaClientSslPropertiesUtil.addKafkaSslProperties(cluster.getOriginalProperties().getSsl(), properties); properties.putAll(cluster.getProperties()); properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, cluster.getBootstrapServers()); properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); diff --git a/api/src/main/java/io/kafbat/ui/service/ksql/KsqlApiClient.java b/api/src/main/java/io/kafbat/ui/service/ksql/KsqlApiClient.java index 3a0b46c81..90192eb2d 100644 --- a/api/src/main/java/io/kafbat/ui/service/ksql/KsqlApiClient.java +++ b/api/src/main/java/io/kafbat/ui/service/ksql/KsqlApiClient.java @@ -130,8 +130,8 @@ private Flux executeSelect(String ksql, Map s * Some version of ksqldb (?..0.24) can cut off json streaming without respect proper array ending like

* [{"header":{"queryId":"...","schema":"..."}}, ] * which will cause json parsing error and will be propagated to UI. - * This is a know issue(https://github.com/confluentinc/ksql/issues/8746), but we don't know when it will be fixed. - * To workaround this we need to check DecodingException err msg. + * This is a known issue(...), but we don't know when it will be fixed. + * To work around this we need to check DecodingException err msg. */ private boolean isUnexpectedJsonArrayEndCharException(Throwable th) { return th instanceof DecodingException diff --git a/api/src/main/java/io/kafbat/ui/util/KafkaClientSslPropertiesUtil.java b/api/src/main/java/io/kafbat/ui/util/KafkaClientSslPropertiesUtil.java new file mode 100644 index 000000000..324e2e4d0 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/util/KafkaClientSslPropertiesUtil.java @@ -0,0 +1,35 @@ +package io.kafbat.ui.util; + +import io.kafbat.ui.config.ClustersProperties; +import java.util.Properties; +import javax.annotation.Nullable; +import org.apache.kafka.common.config.SslConfigs; + +public final class KafkaClientSslPropertiesUtil { + + private KafkaClientSslPropertiesUtil() { + } + + public static void addKafkaSslProperties(@Nullable ClustersProperties.TruststoreConfig truststoreConfig, + Properties sink) { + if (truststoreConfig == null) { + return; + } + + if (!truststoreConfig.isVerifySsl()) { + sink.put(SslConfigs.SSL_ENDPOINT_IDENTIFICATION_ALGORITHM_CONFIG, ""); + } + + if (truststoreConfig.getTruststoreLocation() == null) { + return; + } + + sink.put(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, truststoreConfig.getTruststoreLocation()); + + if (truststoreConfig.getTruststorePassword() != null) { + sink.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, truststoreConfig.getTruststorePassword()); + } + + } + +} diff --git a/api/src/main/java/io/kafbat/ui/util/KafkaServicesValidation.java b/api/src/main/java/io/kafbat/ui/util/KafkaServicesValidation.java index 629d0f339..397fa3839 100644 --- a/api/src/main/java/io/kafbat/ui/util/KafkaServicesValidation.java +++ b/api/src/main/java/io/kafbat/ui/util/KafkaServicesValidation.java @@ -65,7 +65,7 @@ public static Mono validateClusterConnection(S @Nullable TruststoreConfig ssl) { Properties properties = new Properties(); - SslPropertiesUtil.addKafkaSslProperties(ssl, properties); + KafkaClientSslPropertiesUtil.addKafkaSslProperties(ssl, properties); properties.putAll(clusterProps); properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); // editing properties to make validation faster diff --git a/api/src/main/java/io/kafbat/ui/util/SslPropertiesUtil.java b/api/src/main/java/io/kafbat/ui/util/SslPropertiesUtil.java deleted file mode 100644 index fda959a2b..000000000 --- a/api/src/main/java/io/kafbat/ui/util/SslPropertiesUtil.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.kafbat.ui.util; - -import io.kafbat.ui.config.ClustersProperties; -import java.util.Properties; -import javax.annotation.Nullable; -import org.apache.kafka.common.config.SslConfigs; - -public final class SslPropertiesUtil { - - private SslPropertiesUtil() { - } - - public static void addKafkaSslProperties(@Nullable ClustersProperties.TruststoreConfig truststoreConfig, - Properties sink) { - if (truststoreConfig != null && truststoreConfig.getTruststoreLocation() != null) { - sink.put(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, truststoreConfig.getTruststoreLocation()); - if (truststoreConfig.getTruststorePassword() != null) { - sink.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, truststoreConfig.getTruststorePassword()); - } - } - } - -} diff --git a/api/src/main/java/io/kafbat/ui/util/WebClientConfigurator.java b/api/src/main/java/io/kafbat/ui/util/WebClientConfigurator.java index 5d364f6dc..1c289f54f 100644 --- a/api/src/main/java/io/kafbat/ui/util/WebClientConfigurator.java +++ b/api/src/main/java/io/kafbat/ui/util/WebClientConfigurator.java @@ -7,6 +7,7 @@ import io.kafbat.ui.exception.ValidationException; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import java.io.FileInputStream; import java.security.KeyStore; import java.util.function.Consumer; @@ -45,6 +46,10 @@ private static ObjectMapper defaultOM() { public WebClientConfigurator configureSsl(@Nullable ClustersProperties.TruststoreConfig truststoreConfig, @Nullable ClustersProperties.KeystoreConfig keystoreConfig) { + if (truststoreConfig != null && !truststoreConfig.isVerifySsl()) { + return configureNoSsl(); + } + return configureSsl( keystoreConfig != null ? keystoreConfig.getKeystoreLocation() : null, keystoreConfig != null ? keystoreConfig.getKeystorePassword() : null, @@ -97,6 +102,17 @@ private WebClientConfigurator configureSsl( return this; } + @SneakyThrows + public WebClientConfigurator configureNoSsl() { + var contextBuilder = SslContextBuilder.forClient(); + contextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE); + + SslContext context = contextBuilder.build(); + + httpClient = httpClient.secure(t -> t.sslContext(context)); + return this; + } + public WebClientConfigurator configureBasicAuth(@Nullable String username, @Nullable String password) { if (username != null && password != null) { builder.defaultHeaders(httpHeaders -> httpHeaders.setBasicAuth(username, password)); diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index 97d0e5b11..315c4a17e 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -4242,6 +4242,10 @@ components: type: string truststorePassword: type: string + verifySsl: + type: boolean + description: Skip SSL verification for the host. + default: true schemaRegistry: type: string schemaRegistryAuth: From 9f79a56d42f88feae7f1d3b020b598427d3ea1b2 Mon Sep 17 00:00:00 2001 From: Roman Zabaluev Date: Tue, 31 Dec 2024 08:55:25 +0400 Subject: [PATCH 6/7] BE: RBAC: Impl Active Directory populator (#717) + BE: RBAC: LDAP: Implement user subject type for LDAP & AD. Resolves #54, resolves #730 --- .../ui/config/auth/LdapSecurityConfig.java | 93 +++++++++++-------- .../ui/service/AdminClientServiceImpl.java | 2 +- ...acActiveDirectoryAuthoritiesExtractor.java | 50 ++++++++++ .../RbacLdapAuthoritiesExtractor.java | 10 +- 4 files changed, 113 insertions(+), 42 deletions(-) create mode 100644 api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java diff --git a/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java index 4d89a9568..9b1445507 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java @@ -1,6 +1,7 @@ package io.kafbat.ui.config.auth; import io.kafbat.ui.service.rbac.AccessControlService; +import io.kafbat.ui.service.rbac.extractor.RbacActiveDirectoryAuthoritiesExtractor; import io.kafbat.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor; import io.kafbat.ui.util.StaticFileWebFilter; import java.util.Collection; @@ -8,6 +9,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; @@ -17,7 +19,6 @@ import org.springframework.ldap.core.DirContextOperations; import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.ldap.core.support.LdapContextSource; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter; @@ -29,10 +30,11 @@ import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider; import org.springframework.security.ldap.authentication.BindAuthenticator; import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; +import org.springframework.security.ldap.authentication.NullLdapAuthoritiesPopulator; import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider; +import org.springframework.security.ldap.authentication.ad.DefaultActiveDirectoryAuthoritiesPopulator; import org.springframework.security.ldap.search.FilterBasedLdapUserSearch; import org.springframework.security.ldap.search.LdapUserSearch; -import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator; import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator; import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -49,14 +51,43 @@ public class LdapSecurityConfig extends AbstractAuthSecurityConfig { private final LdapProperties props; @Bean - public ReactiveAuthenticationManager authenticationManager(LdapContextSource ldapContextSource, - LdapAuthoritiesPopulator authoritiesExtractor, - AccessControlService acs) { + public ReactiveAuthenticationManager authenticationManager(AbstractLdapAuthenticationProvider authProvider) { + return new ReactiveAuthenticationManagerAdapter(new ProviderManager(List.of(authProvider))); + } + + @Bean + public AbstractLdapAuthenticationProvider authenticationProvider(LdapAuthoritiesPopulator authoritiesExtractor, + @Autowired(required = false) BindAuthenticator ba, + AccessControlService acs) { var rbacEnabled = acs.isRbacEnabled(); + + AbstractLdapAuthenticationProvider authProvider; + + if (!props.isActiveDirectory()) { + authProvider = new LdapAuthenticationProvider(ba, authoritiesExtractor); + } else { + authProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(), + props.getUrls()); + authProvider.setUseAuthenticationRequestCredentials(true); + ((ActiveDirectoryLdapAuthenticationProvider) authProvider).setAuthoritiesPopulator(authoritiesExtractor); + } + + if (rbacEnabled) { + authProvider.setUserDetailsContextMapper(new RbacUserDetailsMapper()); + } + + return authProvider; + } + + @Bean + @ConditionalOnProperty(value = "oauth2.ldap.activeDirectory", havingValue = "false") + public BindAuthenticator ldapBindAuthentication(LdapContextSource ldapContextSource) { BindAuthenticator ba = new BindAuthenticator(ldapContextSource); + if (props.getBase() != null) { ba.setUserDnPatterns(new String[] {props.getBase()}); } + if (props.getUserFilterSearchFilter() != null) { LdapUserSearch userSearch = new FilterBasedLdapUserSearch(props.getUserFilterSearchBase(), props.getUserFilterSearchFilter(), @@ -64,24 +95,7 @@ public ReactiveAuthenticationManager authenticationManager(LdapContextSource lda ba.setUserSearch(userSearch); } - AbstractLdapAuthenticationProvider authenticationProvider; - if (!props.isActiveDirectory()) { - authenticationProvider = rbacEnabled - ? new LdapAuthenticationProvider(ba, authoritiesExtractor) - : new LdapAuthenticationProvider(ba); - } else { - authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(), - props.getUrls()); // TODO Issue #3741 - authenticationProvider.setUseAuthenticationRequestCredentials(true); - } - - if (rbacEnabled) { - authenticationProvider.setUserDetailsContextMapper(new UserDetailsMapper()); - } - - AuthenticationManager am = new ProviderManager(List.of(authenticationProvider)); - - return new ReactiveAuthenticationManagerAdapter(am); + return ba; } @Bean @@ -95,24 +109,27 @@ public LdapContextSource ldapContextSource() { } @Bean - public DefaultLdapAuthoritiesPopulator ldapAuthoritiesExtractor(ApplicationContext context, - BaseLdapPathContextSource contextSource, - AccessControlService acs) { - var rbacEnabled = acs != null && acs.isRbacEnabled(); + public LdapAuthoritiesPopulator authoritiesExtractor(ApplicationContext ctx, + BaseLdapPathContextSource ldapCtx, + AccessControlService acs) { + if (!props.isActiveDirectory()) { + if (!acs.isRbacEnabled()) { + return new NullLdapAuthoritiesPopulator(); + } - DefaultLdapAuthoritiesPopulator extractor; + var extractor = new RbacLdapAuthoritiesExtractor(ctx, ldapCtx, props.getGroupFilterSearchBase()); - if (rbacEnabled) { - extractor = new RbacLdapAuthoritiesExtractor(context, contextSource, props.getGroupFilterSearchBase()); + Optional.ofNullable(props.getGroupFilterSearchFilter()).ifPresent(extractor::setGroupSearchFilter); + extractor.setRolePrefix(""); + extractor.setConvertToUpperCase(false); + extractor.setSearchSubtree(true); + + return extractor; } else { - extractor = new DefaultLdapAuthoritiesPopulator(contextSource, props.getGroupFilterSearchBase()); + return acs.isRbacEnabled() + ? new RbacActiveDirectoryAuthoritiesExtractor(ctx) + : new DefaultActiveDirectoryAuthoritiesPopulator(); } - - Optional.ofNullable(props.getGroupFilterSearchFilter()).ifPresent(extractor::setGroupSearchFilter); - extractor.setRolePrefix(""); - extractor.setConvertToUpperCase(false); - extractor.setSearchSubtree(true); - return extractor; } @Bean @@ -142,7 +159,7 @@ public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) { return builder.build(); } - private static class UserDetailsMapper extends LdapUserDetailsMapper { + private static class RbacUserDetailsMapper extends LdapUserDetailsMapper { @Override public UserDetails mapUserFromContext(DirContextOperations ctx, String username, Collection authorities) { diff --git a/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java b/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java index e3613c94e..6c018ba31 100644 --- a/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java +++ b/api/src/main/java/io/kafbat/ui/service/AdminClientServiceImpl.java @@ -53,7 +53,7 @@ private Mono createAdminClient(KafkaCluster cluster) { return AdminClient.create(properties); }).flatMap(ac -> ReactiveAdminClient.create(ac).doOnError(th -> ac.close())) .onErrorMap(th -> new IllegalStateException( - "Error while creating AdminClient for Cluster " + cluster.getName(), th)); + "Error while creating AdminClient for the cluster " + cluster.getName(), th)); } @Override diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java new file mode 100644 index 000000000..cefef5a7e --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java @@ -0,0 +1,50 @@ +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; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.ldap.authentication.ad.DefaultActiveDirectoryAuthoritiesPopulator; +import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator; + +@Slf4j +public class RbacActiveDirectoryAuthoritiesExtractor implements LdapAuthoritiesPopulator { + + private final DefaultActiveDirectoryAuthoritiesPopulator populator = new DefaultActiveDirectoryAuthoritiesPopulator(); + private final AccessControlService acs; + + public RbacActiveDirectoryAuthoritiesExtractor(ApplicationContext context) { + this.acs = context.getBean(AccessControlService.class); + } + + @Override + public Collection getGrantedAuthorities(DirContextOperations userData, String username) { + var adGroups = populator.getGrantedAuthorities(userData, username) + .stream() + .map(GrantedAuthority::getAuthority) + .peek(group -> log.trace("Found AD group [{}] for user [{}]", group, username)) + .collect(Collectors.toSet()); + + return acs.getRoles() + .stream() + .filter(r -> r.getSubjects() + .stream() + .filter(subject -> subject.getProvider().equals(Provider.LDAP_AD)) + .anyMatch(subject -> switch (subject.getType()) { + case "user" -> username.equalsIgnoreCase(subject.getValue()); + case "group" -> adGroups.contains(subject.getValue()); + default -> false; + }) + ) + .map(Role::getName) + .peek(role -> log.trace("Mapped role [{}] for user [{}]", role, username)) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } +} diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java index 3282ab1e2..261b30cfe 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java @@ -19,7 +19,8 @@ public class RbacLdapAuthoritiesExtractor extends NestedLdapAuthoritiesPopulator private final AccessControlService acs; public RbacLdapAuthoritiesExtractor(ApplicationContext context, - BaseLdapPathContextSource contextSource, String groupFilterSearchBase) { + BaseLdapPathContextSource contextSource, + String groupFilterSearchBase) { super(contextSource, groupFilterSearchBase); this.acs = context.getBean(AccessControlService.class); } @@ -37,8 +38,11 @@ protected Set getAdditionalRoles(DirContextOperations user, St .filter(r -> r.getSubjects() .stream() .filter(subject -> subject.getProvider().equals(Provider.LDAP)) - .filter(subject -> subject.getType().equals("group")) - .anyMatch(subject -> ldapGroups.contains(subject.getValue())) + .anyMatch(subject -> switch (subject.getType()) { + case "user" -> username.equalsIgnoreCase(subject.getValue()); + case "group" -> ldapGroups.contains(subject.getValue()); + default -> false; + }) ) .map(Role::getName) .peek(role -> log.trace("Mapped role [{}] for user [{}]", role, username)) From 582e9a9902cb3114d079a2d9ad0a5ed495ceadb2 Mon Sep 17 00:00:00 2001 From: Roman Zabaluev Date: Wed, 1 Jan 2025 17:18:16 +0400 Subject: [PATCH 7/7] Update copyright year --- LICENSE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index bef1db1d9..bedb0fad0 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 CloudHut + Copyright 2025 Kafbat Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,4 +199,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License.