ISSUE-861 Context path fix (#917)

This commit is contained in:
German Osin 2021-10-01 20:26:40 +03:00 committed by GitHub
parent f3419fbb85
commit 683ab231f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 302 additions and 45 deletions

View file

@ -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'"

View file

@ -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<WebFluxProperties> propsProvider) {
final String basePath = serverProperties.getServlet().getContextPath();
HttpHandler httpHandler = WebHttpHandlerBuilder
.applicationContext(this.applicationContext).build();
if (StringUtils.hasText(basePath)) {
Map<String, HttpHandler> handlersMap =
Collections.singletonMap(basePath, httpHandler);
return new ContextPathCompositeHandler(handlersMap);
}
return httpHandler;
}
@Bean
public KeyedObjectPool<JmxConnectionInfo, JMXConnector> pool() {
var pool = new GenericKeyedObjectPool<>(new JmxPoolFactory());

View file

@ -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<ServerResponse> cssFilesRouter() {
return RouterFunctions
.resources("/static/css/**", new ClassPathResource("static/static/css/"));
.resources(withContext("/static/css/**"), new ClassPathResource("static/static/css/"));
}
@Bean
public RouterFunction<ServerResponse> jsFilesRouter() {
return RouterFunctions
.resources("/static/js/**", new ClassPathResource("static/static/js/"));
.resources(withContext("/static/js/**"), new ClassPathResource("static/static/js/"));
}
@Bean
public RouterFunction<ServerResponse> mediaFilesRouter() {
return RouterFunctions
.resources("/static/media/**", new ClassPathResource("static/static/media/"));
.resources(withContext("/static/media/**"), new ClassPathResource("static/static/media/"));
}
}

View file

@ -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<Void> 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);
}

View file

@ -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();
}

View file

@ -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);

View file

@ -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<byte[]> getAuth(ServerWebExchange exchange) {
Mono<CsrfToken> 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<String, String> queryParams = exchange.getRequest()
.getQueryParams();
String contextPath = exchange.getRequest().getPath().contextPath().value();
String page =
"<!DOCTYPE html>\n" + "<html lang=\"en\">\n" + " <head>\n"
+ " <meta charset=\"utf-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, "
+ "shrink-to-fit=no\">\n"
+ " <meta name=\"description\" content=\"\">\n"
+ " <meta name=\"author\" content=\"\">\n"
+ " <title>Please sign in</title>\n"
+ " <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/"
+ "4.0.0-beta/css/bootstrap.min.css\" rel=\"stylesheet\" "
+ "integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" "
+ "crossorigin=\"anonymous\">\n"
+ " <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" "
+ "rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n"
+ " </head>\n"
+ " <body>\n"
+ " <div class=\"container\">\n"
+ formLogin(queryParams, contextPath, csrfTokenHtmlInput)
+ " </div>\n"
+ " </body>\n"
+ "</html>";
return page.getBytes(Charset.defaultCharset());
}
private String formLogin(
MultiValueMap<String, String> queryParams,
String contextPath, String csrfTokenHtmlInput) {
boolean isError = queryParams.containsKey("error");
boolean isLogoutSuccess = queryParams.containsKey("logout");
return
" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + "/auth\">\n"
+ " <h2 class=\"form-signin-heading\">Please sign in</h2>\n"
+ createError(isError)
+ createLogoutSuccess(isLogoutSuccess)
+ " <p>\n"
+ " <label for=\"username\" class=\"sr-only\">Username</label>\n"
+ " <input type=\"text\" id=\"username\" name=\"username\" class=\"form-control\" "
+ "placeholder=\"Username\" required autofocus>\n"
+ " </p>\n" + " <p>\n"
+ " <label for=\"password\" class=\"sr-only\">Password</label>\n"
+ " <input type=\"password\" id=\"password\" name=\"password\" "
+ "class=\"form-control\" placeholder=\"Password\" required>\n"
+ " </p>\n" + csrfTokenHtmlInput
+ " <button class=\"btn btn-lg btn-primary btn-block\" "
+ "type=\"submit\">Sign in</button>\n"
+ " </form>\n";
}
private static String csrfToken(CsrfToken token) {
return " <input type=\"hidden\" name=\""
+ token.getParameterName()
+ "\" value=\""
+ token.getToken()
+ "\">\n";
}
private static String createError(boolean isError) {
return isError
? "<div class=\"alert alert-danger\" role=\"alert\">Invalid credentials</div>"
: "";
}
private static String createLogoutSuccess(boolean isLogoutSuccess) {
return isLogoutSuccess
? "<div class=\"alert alert-success\" role=\"alert\">You have been signed out</div>"
: "";
}
}

View file

@ -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<String> renderedIndexFile = new AtomicReference<>();
@GetMapping(value = "/index.html", produces = { "text/html" })
public Mono<ResponseEntity<String>> getIndex() {
return Mono.just(ResponseEntity.ok(getRenderedIndexFile()));
public Mono<ResponseEntity<String>> 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)

View file

@ -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<Void> 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;
}
}