diff --git a/docker/kafka-ui-auth-context.yaml b/docker/kafka-ui-auth-context.yaml new file mode 100644 index 0000000000..77d6fea486 --- /dev/null +++ b/docker/kafka-ui-auth-context.yaml @@ -0,0 +1,59 @@ +--- +version: '2' +services: + + kafka-ui: + container_name: kafka-ui + image: provectuslabs/kafka-ui:latest + ports: + - 8080:8080 + depends_on: + - zookeeper0 + - kafka0 + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka0:29092 + KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper0:2181 + KAFKA_CLUSTERS_0_JMXPORT: 9997 + SERVER_SERVLET_CONTEXT_PATH: /kafkaui + AUTH_ENABLED: "true" + SPRING_SECURITY_USER_NAME: admin + SPRING_SECURITY_USER_PASSWORD: pass + + zookeeper0: + image: confluentinc/cp-zookeeper:5.2.4 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - 2181:2181 + + kafka0: + image: confluentinc/cp-kafka:5.2.4 + depends_on: + - zookeeper0 + ports: + - 9092:9092 + - 9997:9997 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper0:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka0:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + JMX_PORT: 9997 + KAFKA_JMX_OPTS: -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=kafka0 -Dcom.sun.management.jmxremote.rmi.port=9997 + + kafka-init-topics: + image: confluentinc/cp-kafka:5.2.4 + volumes: + - ./message.json:/data/message.json + depends_on: + - kafka0 + command: "bash -c 'echo Waiting for Kafka to be ready... && \ + cub kafka-ready -b kafka0:29092 1 30 && \ + kafka-topics --create --topic second.users --partitions 3 --replication-factor 1 --if-not-exists --zookeeper zookeeper0:2181 && \ + kafka-topics --create --topic second.messages --partitions 2 --replication-factor 1 --if-not-exists --zookeeper zookeeper0:2181 && \ + kafka-topics --create --topic first.messages --partitions 2 --replication-factor 1 --if-not-exists --zookeeper zookeeper0:2181 && \ + kafka-console-producer --broker-list kafka0:29092 -topic second.users < /data/message.json'" diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/Config.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/Config.java index e9d897cf43..aae652cd08 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/Config.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/Config.java @@ -2,20 +2,53 @@ package com.provectus.kafka.ui.config; import com.provectus.kafka.ui.model.JmxConnectionInfo; import com.provectus.kafka.ui.util.JmxPoolFactory; +import java.util.Collections; +import java.util.Map; import javax.management.remote.JMXConnector; +import lombok.AllArgsConstructor; import org.apache.commons.pool2.KeyedObjectPool; import org.apache.commons.pool2.impl.GenericKeyedObjectPool; import org.apache.commons.pool2.impl.GenericKeyedObjectPoolConfig; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.reactive.ContextPathCompositeHandler; +import org.springframework.http.server.reactive.HttpHandler; import org.springframework.jmx.export.MBeanExporter; +import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; @Configuration +@AllArgsConstructor public class Config { + private final ApplicationContext applicationContext; + + private final ServerProperties serverProperties; + + @Bean + public HttpHandler httpHandler(ObjectProvider propsProvider) { + + final String basePath = serverProperties.getServlet().getContextPath(); + + HttpHandler httpHandler = WebHttpHandlerBuilder + .applicationContext(this.applicationContext).build(); + + if (StringUtils.hasText(basePath)) { + Map handlersMap = + Collections.singletonMap(basePath, httpHandler); + return new ContextPathCompositeHandler(handlersMap); + } + return httpHandler; + } + + @Bean public KeyedObjectPool pool() { var pool = new GenericKeyedObjectPool<>(new JmxPoolFactory()); diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CorsGlobalConfiguration.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CorsGlobalConfiguration.java index 6e46b607bc..0128110ab7 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CorsGlobalConfiguration.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CorsGlobalConfiguration.java @@ -1,45 +1,58 @@ package com.provectus.kafka.ui.config; +import lombok.AllArgsConstructor; +import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StringUtils; import org.springframework.web.reactive.config.CorsRegistry; -import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; @Configuration -@EnableWebFlux @Profile("local") +@AllArgsConstructor public class CorsGlobalConfiguration implements WebFluxConfigurer { + private final ServerProperties serverProperties; + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("*") .allowedHeaders("*") - .allowCredentials(true); + .allowCredentials(false); + } + + private String withContext(String pattern) { + final String basePath = serverProperties.getServlet().getContextPath(); + if (StringUtils.hasText(basePath)) { + return basePath + pattern; + } else { + return pattern; + } } @Bean public RouterFunction cssFilesRouter() { return RouterFunctions - .resources("/static/css/**", new ClassPathResource("static/static/css/")); + .resources(withContext("/static/css/**"), new ClassPathResource("static/static/css/")); } @Bean public RouterFunction jsFilesRouter() { return RouterFunctions - .resources("/static/js/**", new ClassPathResource("static/static/js/")); + .resources(withContext("/static/js/**"), new ClassPathResource("static/static/js/")); } @Bean public RouterFunction mediaFilesRouter() { return RouterFunctions - .resources("/static/media/**", new ClassPathResource("static/static/media/")); + .resources(withContext("/static/media/**"), new ClassPathResource("static/static/media/")); } } \ No newline at end of file diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CustomWebFilter.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CustomWebFilter.java index 6dce3b5e01..a74a3fa6a1 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CustomWebFilter.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/CustomWebFilter.java @@ -1,6 +1,5 @@ package com.provectus.kafka.ui.config; -import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; @@ -8,32 +7,25 @@ import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; @Component - public class CustomWebFilter implements WebFilter { - private final ServerProperties serverProperties; - - public CustomWebFilter(ServerProperties serverProperties) { - this.serverProperties = serverProperties; - } - @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - String contextPath = serverProperties.getServlet().getContextPath() != null - ? serverProperties.getServlet().getContextPath() : ""; - final String path = exchange.getRequest().getURI().getPath().replaceAll("/$", ""); - if (path.equals(contextPath) || path.startsWith(contextPath + "/ui")) { + final String basePath = exchange.getRequest().getPath().contextPath().value(); + + final String path = exchange.getRequest().getPath().pathWithinApplication().value(); + + if (path.startsWith("/ui") || path.equals("/")) { return chain.filter( - exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()) - .build() + exchange.mutate().request( + exchange.getRequest().mutate() + .path(basePath + "/index.html") + .contextPath(basePath) + .build() + ).build() ); - } else if (path.startsWith(contextPath)) { - return chain.filter( - exchange.mutate().request(exchange.getRequest().mutate().contextPath(contextPath).build()) - .build() - ); - } + } return chain.filter(exchange); } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/OAuthSecurityConfig.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/OAuthSecurityConfig.java index 3e8bcd478f..4d1a16860f 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/OAuthSecurityConfig.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/OAuthSecurityConfig.java @@ -1,6 +1,7 @@ package com.provectus.kafka.ui.config; -import org.springframework.beans.factory.annotation.Autowired; +import com.provectus.kafka.ui.util.EmptyRedirectStrategy; +import lombok.AllArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; @@ -8,18 +9,20 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; import org.springframework.util.ClassUtils; @Configuration @EnableWebFluxSecurity @ConditionalOnProperty(value = "auth.enabled", havingValue = "true") +@AllArgsConstructor public class OAuthSecurityConfig { public static final String REACTIVE_CLIENT_REGISTRATION_REPOSITORY_CLASSNAME = "org.springframework.security.oauth2.client.registration." + "ReactiveClientRegistrationRepository"; - private static final boolean isOAuth2Present = ClassUtils.isPresent( + private static final boolean IS_OAUTH2_PRESENT = ClassUtils.isPresent( REACTIVE_CLIENT_REGISTRATION_REPOSITORY_CLASSNAME, OAuthSecurityConfig.class.getClassLoader() ); @@ -31,37 +34,45 @@ public class OAuthSecurityConfig { "/resources/**", "/actuator/health", "/actuator/info", + "/auth", "/login", "/logout", "/oauth2/**" }; - @Autowired - ApplicationContext context; + private final ApplicationContext context; @Bean public SecurityWebFilterChain configure(ServerHttpSecurity http) { http.authorizeExchange() - .pathMatchers(AUTH_WHITELIST).permitAll() + .pathMatchers( + AUTH_WHITELIST + ).permitAll() .anyExchange() .authenticated(); - if (isOAuth2Present && OAuth2ClasspathGuard.shouldConfigure(this.context)) { + if (IS_OAUTH2_PRESENT && OAuth2ClasspathGuard.shouldConfigure(this.context)) { OAuth2ClasspathGuard.configure(this.context, http); } else { + final RedirectServerAuthenticationSuccessHandler handler = + new RedirectServerAuthenticationSuccessHandler(); + handler.setRedirectStrategy(new EmptyRedirectStrategy()); + http .httpBasic().and() - .formLogin(); + .formLogin() + .loginPage("/auth") + .authenticationSuccessHandler(handler); } - SecurityWebFilterChain result = http.csrf().disable().build(); - return result; + return http.csrf().disable().build(); } private static class OAuth2ClasspathGuard { static void configure(ApplicationContext context, ServerHttpSecurity http) { http - .oauth2Login().and() + .oauth2Login() + .and() .oauth2Client(); } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ReadOnlyModeFilter.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ReadOnlyModeFilter.java index 35e0f8397b..b998748fcc 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ReadOnlyModeFilter.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ReadOnlyModeFilter.java @@ -31,7 +31,7 @@ public class ReadOnlyModeFilter implements WebFilter { return chain.filter(exchange); } - var path = exchange.getRequest().getURI().getPath(); + var path = exchange.getRequest().getPath().pathWithinApplication().value(); var matcher = CLUSTER_NAME_REGEX.matcher(path); if (!matcher.find()) { return chain.filter(exchange); diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/AuthController.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/AuthController.java new file mode 100644 index 0000000000..b847e58f99 --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/AuthController.java @@ -0,0 +1,100 @@ +package com.provectus.kafka.ui.controller; + +import java.nio.charset.Charset; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +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 +@Log4j2 +public class AuthController { + + @GetMapping(value = "/auth", produces = { "text/html" }) + private 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/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/StaticController.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/StaticController.java index 2b48d53a7a..f8278701c0 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/StaticController.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/StaticController.java @@ -1,6 +1,7 @@ package com.provectus.kafka.ui.controller; import com.provectus.kafka.ui.util.ResourceUtil; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; @@ -11,27 +12,27 @@ import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; 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 @Log4j2 public class StaticController { - private final ServerProperties serverProperties; @Value("classpath:static/index.html") private Resource indexFile; private final AtomicReference renderedIndexFile = new AtomicReference<>(); @GetMapping(value = "/index.html", produces = { "text/html" }) - public Mono> getIndex() { - return Mono.just(ResponseEntity.ok(getRenderedIndexFile())); + public Mono> getIndex(ServerWebExchange exchange) { + return Mono.just(ResponseEntity.ok(getRenderedIndexFile(exchange))); } - public String getRenderedIndexFile() { + public String getRenderedIndexFile(ServerWebExchange exchange) { String rendered = renderedIndexFile.get(); if (rendered == null) { - rendered = buildIndexFile(); + rendered = buildIndexFile(exchange.getRequest().getPath().contextPath().value()); if (renderedIndexFile.compareAndSet(null, rendered)) { return rendered; } else { @@ -43,9 +44,7 @@ public class StaticController { } @SneakyThrows - private String buildIndexFile() { - final String contextPath = serverProperties.getServlet().getContextPath() != null - ? serverProperties.getServlet().getContextPath() : ""; + private String buildIndexFile(String contextPath) { final String staticPath = contextPath + "/static"; return ResourceUtil.readAsString(indexFile) .replace("href=\"./static", "href=\"" + staticPath) diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/EmptyRedirectStrategy.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/EmptyRedirectStrategy.java new file mode 100644 index 0000000000..39a4a7f2eb --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/util/EmptyRedirectStrategy.java @@ -0,0 +1,50 @@ +package com.provectus.kafka.ui.util; + +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public class EmptyRedirectStrategy implements ServerRedirectStrategy { + + private HttpStatus httpStatus = HttpStatus.FOUND; + + private boolean contextRelative = true; + + public Mono sendRedirect(ServerWebExchange exchange, URI location) { + Assert.notNull(exchange, "exchange cannot be null"); + Assert.notNull(location, "location cannot be null"); + return Mono.fromRunnable(() -> { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(this.httpStatus); + response.getHeaders().setLocation(createLocation(exchange, location)); + }); + } + + private URI createLocation(ServerWebExchange exchange, URI location) { + if (!this.contextRelative) { + return location; + } + + String url = location.getPath().isEmpty() ? "/" + : location.toASCIIString(); + + if (url.startsWith("/")) { + String context = exchange.getRequest().getPath().contextPath().value(); + return URI.create(context + url); + } + return location; + } + + public void setHttpStatus(HttpStatus httpStatus) { + Assert.notNull(httpStatus, "httpStatus cannot be null"); + this.httpStatus = httpStatus; + } + + public void setContextRelative(boolean contextRelative) { + this.contextRelative = contextRelative; + } +}