diff --git a/api/pom.xml b/api/pom.xml index dd2c3a378..fac019ab5 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -212,6 +212,19 @@ ${okhttp3.mockwebserver.version} test + + org.apache.kafka + kafka-clients + ${confluent.version}-ccs + test + test + + + org.bouncycastle + bcpkix-jdk18on + 1.80 + test + org.springframework.boot 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 4b7473942..4267a4b0e 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 @@ -3,10 +3,13 @@ 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.CustomSslSocketFactory; import io.kafbat.ui.util.StaticFileWebFilter; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -47,6 +50,9 @@ @RequiredArgsConstructor @Slf4j public class LdapSecurityConfig extends AbstractAuthSecurityConfig { + private static final Map BASE_ENV_PROPS = Map.of( + "java.naming.ldap.factory.socket", CustomSslSocketFactory.class.getName() + ); private final LdapProperties props; @@ -63,13 +69,10 @@ public AbstractLdapAuthenticationProvider authenticationProvider(LdapAuthorities AbstractLdapAuthenticationProvider authProvider; - if (!props.isActiveDirectory()) { - authProvider = new LdapAuthenticationProvider(ba, authoritiesExtractor); + if (props.isActiveDirectory()) { + authProvider = activeDirectoryProvider(authoritiesExtractor); } else { - authProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(), - props.getUrls()); - authProvider.setUseAuthenticationRequestCredentials(true); - ((ActiveDirectoryLdapAuthenticationProvider) authProvider).setAuthoritiesPopulator(authoritiesExtractor); + authProvider = new LdapAuthenticationProvider(ba, authoritiesExtractor); } if (rbacEnabled) { @@ -159,6 +162,22 @@ public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) { return builder.build(); } + private ActiveDirectoryLdapAuthenticationProvider activeDirectoryProvider(LdapAuthoritiesPopulator populator) { + ActiveDirectoryLdapAuthenticationProvider provider = new ActiveDirectoryLdapAuthenticationProvider( + props.getActiveDirectoryDomain(), + props.getUrls() + ); + + provider.setUseAuthenticationRequestCredentials(true); + provider.setAuthoritiesPopulator(populator); + + if (Stream.of(props.getUrls().split(",")).anyMatch(url -> url.startsWith("ldaps://"))) { + provider.setContextEnvironmentProperties(BASE_ENV_PROPS); + } + + return provider; + } + private static class RbacUserDetailsMapper extends LdapUserDetailsMapper { @Override public UserDetails mapUserFromContext(DirContextOperations ctx, String username, diff --git a/api/src/main/java/io/kafbat/ui/util/CustomSslSocketFactory.java b/api/src/main/java/io/kafbat/ui/util/CustomSslSocketFactory.java new file mode 100644 index 000000000..b5fee8cae --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/util/CustomSslSocketFactory.java @@ -0,0 +1,69 @@ +package io.kafbat.ui.util; + +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.security.SecureRandom; +import javax.net.SocketFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; + +public class CustomSslSocketFactory extends SSLSocketFactory { + private final SSLSocketFactory socketFactory; + + public CustomSslSocketFactory() { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, InsecureTrustManagerFactory.INSTANCE.getTrustManagers(), new SecureRandom()); + + socketFactory = sslContext.getSocketFactory(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static SocketFactory getDefault() { + return new CustomSslSocketFactory(); + } + + @Override + public String[] getDefaultCipherSuites() { + return socketFactory.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return socketFactory.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket socket, String string, int i, boolean bln) throws IOException { + return socketFactory.createSocket(socket, string, i, bln); + } + + @Override + public Socket createSocket(String string, int i) throws IOException { + return socketFactory.createSocket(string, i); + } + + @Override + public Socket createSocket(String string, int i, InetAddress ia, int i1) throws IOException { + return socketFactory.createSocket(string, i, ia, i1); + } + + @Override + public Socket createSocket(InetAddress ia, int i) throws IOException { + return socketFactory.createSocket(ia, i); + } + + @Override + public Socket createSocket(InetAddress ia, int i, InetAddress ia1, int i1) throws IOException { + return socketFactory.createSocket(ia, i, ia1, i1); + } + + @Override + public Socket createSocket() throws IOException { + return socketFactory.createSocket(); + } +} diff --git a/api/src/test/java/io/kafbat/ui/ActiveDirectoryIntegrationTest.java b/api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java similarity index 55% rename from api/src/test/java/io/kafbat/ui/ActiveDirectoryIntegrationTest.java rename to api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java index 80c3abe33..098da29f9 100644 --- a/api/src/test/java/io/kafbat/ui/ActiveDirectoryIntegrationTest.java +++ b/api/src/test/java/io/kafbat/ui/AbstractActiveDirectoryIntegrationTest.java @@ -1,7 +1,6 @@ package io.kafbat.ui; import static io.kafbat.ui.AbstractIntegrationTest.LOCAL; -import static io.kafbat.ui.container.ActiveDirectoryContainer.DOMAIN; import static io.kafbat.ui.container.ActiveDirectoryContainer.EMPTY_PERMISSIONS_USER; import static io.kafbat.ui.container.ActiveDirectoryContainer.FIRST_USER_WITH_GROUP; import static io.kafbat.ui.container.ActiveDirectoryContainer.PASSWORD; @@ -12,52 +11,26 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import io.kafbat.ui.container.ActiveDirectoryContainer; import io.kafbat.ui.model.AuthenticationInfoDTO; import io.kafbat.ui.model.ResourceTypeDTO; import io.kafbat.ui.model.UserPermissionDTO; import java.util.List; import java.util.Objects; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContextInitializer; -import org.springframework.context.ConfigurableApplicationContext; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; @SpringBootTest @ActiveProfiles("rbac-ad") @AutoConfigureWebTestClient(timeout = "60000") -@ContextConfiguration(initializers = {ActiveDirectoryIntegrationTest.Initializer.class}) -public class ActiveDirectoryIntegrationTest { +public abstract class AbstractActiveDirectoryIntegrationTest { private static final String SESSION = "SESSION"; - private static final ActiveDirectoryContainer ACTIVE_DIRECTORY = new ActiveDirectoryContainer(); - - @Autowired - private WebTestClient webTestClient; - - @BeforeAll - public static void setup() { - ACTIVE_DIRECTORY.start(); - } - - @AfterAll - public static void shutdown() { - ACTIVE_DIRECTORY.stop(); - } - - @Test - public void testUserPermissions() { - AuthenticationInfoDTO info = authenticationInfo(FIRST_USER_WITH_GROUP); + protected static void checkUserPermissions(WebTestClient client) { + AuthenticationInfoDTO info = authenticationInfo(client, FIRST_USER_WITH_GROUP); assertNotNull(info); assertTrue(info.getRbacEnabled()); @@ -67,22 +40,21 @@ public void testUserPermissions() { assertFalse(permissions.isEmpty()); assertTrue(permissions.stream().anyMatch(permission -> permission.getClusters().contains(LOCAL) && permission.getResource() == ResourceTypeDTO.TOPIC)); - assertEquals(permissions, authenticationInfo(SECOND_USER_WITH_GROUP).getUserInfo().getPermissions()); - assertEquals(permissions, authenticationInfo(USER_WITHOUT_GROUP).getUserInfo().getPermissions()); + assertEquals(permissions, authenticationInfo(client, SECOND_USER_WITH_GROUP).getUserInfo().getPermissions()); + assertEquals(permissions, authenticationInfo(client, USER_WITHOUT_GROUP).getUserInfo().getPermissions()); } - @Test - public void testEmptyPermissions() { - assertTrue(Objects.requireNonNull(authenticationInfo(EMPTY_PERMISSIONS_USER)) + protected static void checkEmptyPermissions(WebTestClient client) { + assertTrue(Objects.requireNonNull(authenticationInfo(client, EMPTY_PERMISSIONS_USER)) .getUserInfo() .getPermissions() .isEmpty() ); } - private String session(String name) { + protected static String session(WebTestClient client, String name) { return Objects.requireNonNull( - webTestClient + client .post() .uri("/login") .contentType(MediaType.APPLICATION_FORM_URLENCODED) @@ -96,11 +68,11 @@ private String session(String name) { .getValue(); } - private AuthenticationInfoDTO authenticationInfo(String name) { - return webTestClient + protected static AuthenticationInfoDTO authenticationInfo(WebTestClient client, String name) { + return client .get() .uri("/api/authorization") - .cookie(SESSION, session(name)) + .cookie(SESSION, session(client, name)) .exchange() .expectStatus() .isOk() @@ -108,13 +80,4 @@ private AuthenticationInfoDTO authenticationInfo(String name) { .getResponseBody() .blockFirst(); } - - public static class Initializer implements ApplicationContextInitializer { - @Override - public void initialize(@NotNull ConfigurableApplicationContext context) { - System.setProperty("spring.ldap.urls", ACTIVE_DIRECTORY.getLdapUrl()); - System.setProperty("oauth2.ldap.activeDirectory", "true"); - System.setProperty("oauth2.ldap.activeDirectory.domain", DOMAIN); - } - } } diff --git a/api/src/test/java/io/kafbat/ui/ActiveDirectoryLdapTest.java b/api/src/test/java/io/kafbat/ui/ActiveDirectoryLdapTest.java new file mode 100644 index 000000000..4d32513f7 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/ActiveDirectoryLdapTest.java @@ -0,0 +1,51 @@ +package io.kafbat.ui; + +import static io.kafbat.ui.container.ActiveDirectoryContainer.DOMAIN; + +import io.kafbat.ui.container.ActiveDirectoryContainer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; + +@ContextConfiguration(initializers = {ActiveDirectoryLdapTest.Initializer.class}) +public class ActiveDirectoryLdapTest extends AbstractActiveDirectoryIntegrationTest { + private static final ActiveDirectoryContainer ACTIVE_DIRECTORY = new ActiveDirectoryContainer(false); + + @Autowired + private WebTestClient webTestClient; + + @BeforeAll + public static void setup() { + ACTIVE_DIRECTORY.start(); + } + + @AfterAll + public static void shutdown() { + ACTIVE_DIRECTORY.stop(); + } + + @Test + public void testUserPermissions() { + checkUserPermissions(webTestClient); + } + + @Test + public void testEmptyPermissions() { + checkEmptyPermissions(webTestClient); + } + + public static class Initializer implements ApplicationContextInitializer { + @Override + public void initialize(@NotNull ConfigurableApplicationContext context) { + System.setProperty("spring.ldap.urls", ACTIVE_DIRECTORY.getLdapUrl()); + System.setProperty("oauth2.ldap.activeDirectory", "true"); + System.setProperty("oauth2.ldap.activeDirectory.domain", DOMAIN); + } + } +} diff --git a/api/src/test/java/io/kafbat/ui/ActiveDirectoryLdapsTest.java b/api/src/test/java/io/kafbat/ui/ActiveDirectoryLdapsTest.java new file mode 100644 index 000000000..86d46961d --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/ActiveDirectoryLdapsTest.java @@ -0,0 +1,110 @@ +package io.kafbat.ui; + +import static io.kafbat.ui.container.ActiveDirectoryContainer.CONTAINER_CERT_PATH; +import static io.kafbat.ui.container.ActiveDirectoryContainer.CONTAINER_KEY_PATH; +import static io.kafbat.ui.container.ActiveDirectoryContainer.DOMAIN; +import static io.kafbat.ui.container.ActiveDirectoryContainer.PASSWORD; +import static java.nio.file.Files.writeString; +import static org.testcontainers.utility.MountableFile.forHostPath; + +import io.kafbat.ui.container.ActiveDirectoryContainer; +import java.io.File; +import java.io.StringWriter; +import java.net.InetAddress; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Map; +import org.apache.kafka.common.config.types.Password; +import org.apache.kafka.test.TestSslUtils; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.testcontainers.shaded.org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator; +import org.testcontainers.shaded.org.bouncycastle.openssl.jcajce.JcaPKCS8Generator; +import org.testcontainers.shaded.org.bouncycastle.util.io.pem.PemWriter; + +@ContextConfiguration(initializers = {ActiveDirectoryLdapsTest.Initializer.class}) +public class ActiveDirectoryLdapsTest extends AbstractActiveDirectoryIntegrationTest { + private static final ActiveDirectoryContainer ACTIVE_DIRECTORY = new ActiveDirectoryContainer(true); + + private static File certPem = null; + private static File privateKeyPem = null; + + @Autowired + private WebTestClient webTestClient; + + @BeforeAll + public static void setup() throws Exception { + generateCerts(); + + ACTIVE_DIRECTORY.withCopyFileToContainer(forHostPath(certPem.getAbsolutePath()), CONTAINER_CERT_PATH); + ACTIVE_DIRECTORY.withCopyFileToContainer(forHostPath(privateKeyPem.getAbsolutePath()), CONTAINER_KEY_PATH); + + ACTIVE_DIRECTORY.start(); + } + + @AfterAll + public static void shutdown() { + ACTIVE_DIRECTORY.stop(); + } + + @Test + public void testUserPermissions() { + checkUserPermissions(webTestClient); + } + + @Test + public void testEmptyPermissions() { + checkEmptyPermissions(webTestClient); + } + + private static void generateCerts() throws Exception { + File truststore = File.createTempFile("truststore", ".jks"); + + truststore.deleteOnExit(); + + String host = "localhost"; + KeyPair clientKeyPair = TestSslUtils.generateKeyPair("RSA"); + + X509Certificate clientCert = new TestSslUtils.CertificateBuilder(365, "SHA256withRSA") + .sanDnsNames(host) + .sanIpAddress(InetAddress.getByName(host)) + .generate("O=Samba Administration, OU=Samba, CN=" + host, clientKeyPair); + + TestSslUtils.createTrustStore(truststore.getPath(), new Password(PASSWORD), Map.of("client", clientCert)); + + certPem = File.createTempFile("cert", ".pem"); + writeString(certPem.toPath(), certOrKeyToString(clientCert)); + + privateKeyPem = File.createTempFile("key", ".pem"); + writeString(privateKeyPem.toPath(), certOrKeyToString(clientKeyPair.getPrivate())); + } + + private static String certOrKeyToString(Object certOrKey) throws Exception { + StringWriter sw = new StringWriter(); + try (PemWriter pw = new PemWriter(sw)) { + if (certOrKey instanceof X509Certificate) { + pw.writeObject(new JcaMiscPEMGenerator(certOrKey)); + } else { + pw.writeObject(new JcaPKCS8Generator((PrivateKey) certOrKey, null)); + } + } + return sw.toString(); + } + + public static class Initializer implements ApplicationContextInitializer { + @Override + public void initialize(@NotNull ConfigurableApplicationContext context) { + System.setProperty("spring.ldap.urls", ACTIVE_DIRECTORY.getLdapUrl()); + System.setProperty("oauth2.ldap.activeDirectory", "true"); + System.setProperty("oauth2.ldap.activeDirectory.domain", DOMAIN); + } + } +} diff --git a/api/src/test/java/io/kafbat/ui/container/ActiveDirectoryContainer.java b/api/src/test/java/io/kafbat/ui/container/ActiveDirectoryContainer.java index 55bc3a186..af1f42a68 100644 --- a/api/src/test/java/io/kafbat/ui/container/ActiveDirectoryContainer.java +++ b/api/src/test/java/io/kafbat/ui/container/ActiveDirectoryContainer.java @@ -14,6 +14,8 @@ public class ActiveDirectoryContainer extends GenericContainer