diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthServletHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthServletHandler.java index 096e5b177be..357f72972c2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthServletHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthServletHandler.java @@ -1,5 +1,8 @@ package org.openmetadata.service.security.auth; +import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; +import static jakarta.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE; +import static jakarta.ws.rs.core.Response.Status.UNAUTHORIZED; import static org.openmetadata.service.security.SecurityUtil.writeJsonResponse; import jakarta.servlet.http.HttpServletRequest; @@ -16,6 +19,7 @@ import org.openmetadata.schema.auth.TokenRefreshRequest; import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.OpenMetadataApplicationConfig; import org.openmetadata.service.auth.JwtResponse; +import org.openmetadata.service.exception.CustomExceptionMessage; import org.openmetadata.service.security.AuthServeletHandler; @Slf4j @@ -109,9 +113,21 @@ public class LdapAuthServletHandler implements AuthServeletHandler { resp.setContentType("application/json"); writeJsonResponse(resp, JsonUtils.pojoToJson(responseToClient)); + } catch (CustomExceptionMessage e) { + LOG.error("LDAP login error: {}", e.getMessage(), e); + int statusCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + if (e.getResponse().getStatus() == SERVICE_UNAVAILABLE.getStatusCode()) { + statusCode = HttpServletResponse.SC_SERVICE_UNAVAILABLE; + } else if (e.getResponse().getStatus() == UNAUTHORIZED.getStatusCode()) { + statusCode = HttpServletResponse.SC_UNAUTHORIZED; + } else if (e.getResponse().getStatus() == FORBIDDEN.getStatusCode()) { + statusCode = HttpServletResponse.SC_FORBIDDEN; + } + + sendError(resp, statusCode, e.getMessage()); } catch (Exception e) { - LOG.error("Error handling LDAP login", e); - sendError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + LOG.error("Unexpected error handling LDAP login", e); + sendError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Authentication service error"); } } @@ -225,7 +241,7 @@ public class LdapAuthServletHandler implements AuthServeletHandler { private void sendError(HttpServletResponse resp, int status, String message) { try { resp.setStatus(status); - writeJsonResponse(resp, String.format("{\"error\":\"%s\"}", message)); + writeJsonResponse(resp, String.format("{\"message\":\"%s\"}", message)); } catch (IOException e) { LOG.error("Error writing error response", e); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java index 857bc7ba316..a8cd0639883 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java @@ -3,6 +3,7 @@ package org.openmetadata.service.security.auth; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static jakarta.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE; import static jakarta.ws.rs.core.Response.Status.UNAUTHORIZED; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.schema.auth.TokenType.REFRESH_TOKEN; @@ -81,6 +82,8 @@ import org.springframework.util.CollectionUtils; @Slf4j public class LdapAuthenticator implements AuthenticatorHandler { static final String LDAP_ERR_MSG = "[LDAP] Issue in creating a LookUp Connection "; + private static final int MAX_RETRIES = 3; + private static final int BASE_DELAY_MS = 500; private RoleRepository roleRepository; private UserRepository userRepository; private TokenRepository tokenRepository; @@ -197,29 +200,74 @@ public class LdapAuthenticator implements AuthenticatorHandler { @Override public void validatePassword(String userDn, String reqPassword, User dummy) throws TemplateException, IOException { - // performed in LDAP , the storedUser's name set as DN of the User in Ldap + // Retry configuration for connection establishment + final int maxRetries = 3; + final int baseDelayMs = 500; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + performLdapBind(userDn, reqPassword, dummy); + return; // Success + } catch (CustomExceptionMessage e) { + handleRetryableException(e, attempt, "LDAP connection failed for authentication"); + } + } + + throw new CustomExceptionMessage( + SERVICE_UNAVAILABLE, + "LDAP_CONNECTION_ERROR", + "Unable to connect to authentication server after " + maxRetries + " attempts."); + } + + private void performLdapBind(String userDn, String reqPassword, User dummy) + throws TemplateException, IOException { BindResult bindingResult = null; + LDAPConnection userConnection = null; try { - bindingResult = ldapLookupConnectionPool.bind(userDn, reqPassword); + // Create a new connection for user authentication with proper SSL/TLS support + if (Boolean.TRUE.equals(ldapConfiguration.getSslEnabled())) { + // LDAPS (LDAP over SSL) - same configuration as connection pool + LDAPConnectionOptions connectionOptions = new LDAPConnectionOptions(); + LdapUtil ldapUtil = new LdapUtil(); + SSLUtil sslUtil = + new SSLUtil(ldapUtil.getLdapSSLConnection(ldapConfiguration, connectionOptions)); + userConnection = + new LDAPConnection( + sslUtil.createSSLSocketFactory(), + connectionOptions, + ldapConfiguration.getHost(), + ldapConfiguration.getPort()); + } else { + userConnection = + new LDAPConnection(ldapConfiguration.getHost(), ldapConfiguration.getPort()); + } + + // Perform the bind operation + bindingResult = userConnection.bind(userDn, reqPassword); if (Objects.equals(bindingResult.getResultCode().getName(), ResultCode.SUCCESS.getName())) { return; } - } catch (Exception ex) { - if (bindingResult != null - && Objects.equals( - bindingResult.getResultCode().getName(), ResultCode.INVALID_CREDENTIALS.getName())) { + + if (Objects.equals( + bindingResult.getResultCode().getName(), ResultCode.INVALID_CREDENTIALS.getName())) { recordFailedLoginAttempt(dummy.getEmail(), dummy.getName()); throw new CustomExceptionMessage( UNAUTHORIZED, INVALID_USER_OR_PASSWORD, INVALID_EMAIL_PASSWORD); } + } catch (Exception ex) { + handleLdapException(ex); + } finally { + if (userConnection != null) { + userConnection.close(); + } } - if (bindingResult != null) { - throw new CustomExceptionMessage( - INTERNAL_SERVER_ERROR, INVALID_USER_OR_PASSWORD, bindingResult.getResultCode().getName()); - } else { - throw new CustomExceptionMessage( - INTERNAL_SERVER_ERROR, INVALID_USER_OR_PASSWORD, INVALID_EMAIL_PASSWORD); - } + + LOG.error( + "LDAP bind failed with unexpected result: {}", bindingResult.getResultCode().getName()); + throw new CustomExceptionMessage( + INTERNAL_SERVER_ERROR, + "LDAP_AUTH_ERROR", + "Authentication failed: " + bindingResult.getResultCode().getName()); } @Override @@ -237,6 +285,24 @@ public class LdapAuthenticator implements AuthenticatorHandler { } private String getUserDnFromLdap(String email) { + // Retry configuration (will be made configurable in future) + final int maxRetries = 3; + final int baseDelayMs = 500; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + return performLdapUserSearch(email); + } catch (CustomExceptionMessage e) { + handleRetryableException(e, attempt, "LDAP connection failed for user lookup"); + } + } + throw new CustomExceptionMessage( + SERVICE_UNAVAILABLE, + "LDAP_CONNECTION_ERROR", + "Unable to connect to authentication server after " + maxRetries + " attempts."); + } + + private String performLdapUserSearch(String email) { try { Filter emailFilter = Filter.createEqualityFilter(ldapConfiguration.getMailAttributeName(), email); @@ -270,7 +336,26 @@ public class LdapAuthenticator implements AuthenticatorHandler { INTERNAL_SERVER_ERROR, INVALID_USER_OR_PASSWORD, INVALID_EMAIL_PASSWORD); } } catch (LDAPException ex) { - throw new CustomExceptionMessage(INTERNAL_SERVER_ERROR, "LDAP_ERROR", ex.getMessage()); + ResultCode resultCode = ex.getResultCode(); + String errorMessage = ex.getMessage(); + + // Check if it's a connection/network error + if (resultCode == ResultCode.CONNECT_ERROR + || resultCode == ResultCode.SERVER_DOWN + || resultCode == ResultCode.UNAVAILABLE + || resultCode == ResultCode.TIMEOUT + || errorMessage.contains("Connection refused") + || errorMessage.contains("Connection reset") + || errorMessage.contains("No route to host")) { + LOG.error("LDAP connection error for user lookup: {}", errorMessage); + throw new CustomExceptionMessage( + SERVICE_UNAVAILABLE, + "LDAP_CONNECTION_ERROR", + "Unable to connect to authentication server. Please try again later."); + } + LOG.error("LDAP error during user lookup: {}", errorMessage); + throw new CustomExceptionMessage( + INTERNAL_SERVER_ERROR, "LDAP_ERROR", "Authentication server error: " + errorMessage); } } @@ -486,4 +571,59 @@ public class LdapAuthenticator implements AuthenticatorHandler { return newRefreshToken; } + + private void handleRetryableException(CustomExceptionMessage e, int attempt, String logMessage) { + if (e.getMessage().contains("Unable to connect to authentication server") + && attempt < MAX_RETRIES) { + int delayMs = BASE_DELAY_MS * attempt; + LOG.warn( + "{} (attempt {}/{}). Retrying in {}ms...", logMessage, attempt, MAX_RETRIES, delayMs); + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + LOG.error("LDAP retry interrupted"); + throw e; + } + } else { + throw e; + } + } + + private void handleLdapException(Exception ex) throws TemplateException, IOException { + if (ex instanceof LDAPException ldapEx) { + ResultCode resultCode = ldapEx.getResultCode(); + String errorMessage = ldapEx.getMessage(); + + // Check if it's a connection/network error + if (resultCode == ResultCode.CONNECT_ERROR + || resultCode == ResultCode.SERVER_DOWN + || resultCode == ResultCode.UNAVAILABLE + || resultCode == ResultCode.TIMEOUT + || errorMessage.contains("Connection refused") + || errorMessage.contains("Connection reset") + || errorMessage.contains("No route to host")) { + LOG.error("LDAP connection error during authentication: {}", errorMessage); + throw new CustomExceptionMessage( + SERVICE_UNAVAILABLE, + "LDAP_CONNECTION_ERROR", + "Unable to connect to authentication server. Please try again later."); + } + LOG.error("LDAP error during authentication: {}", errorMessage); + throw new CustomExceptionMessage( + INTERNAL_SERVER_ERROR, "LDAP_ERROR", "Authentication server error: " + errorMessage); + } else if (ex instanceof GeneralSecurityException) { + LOG.error("SSL/TLS error during LDAP authentication", ex); + throw new CustomExceptionMessage( + INTERNAL_SERVER_ERROR, + "LDAP_SSL_ERROR", + "SSL/TLS configuration error: " + ex.getMessage()); + } else { + LOG.error("Unexpected error during LDAP authentication", ex); + throw new CustomExceptionMessage( + INTERNAL_SERVER_ERROR, + "LDAP_UNEXPECTED_ERROR", + "Unexpected authentication error: " + ex.getMessage()); + } + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/LdapAuthCompleteFlowTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/LdapAuthCompleteFlowTest.java new file mode 100644 index 00000000000..4b3d0194c7b --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/auth/LdapAuthCompleteFlowTest.java @@ -0,0 +1,412 @@ +package org.openmetadata.service.security.auth; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.Invocation; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; +import java.time.Duration; +import java.util.*; +import lombok.extern.slf4j.Slf4j; +import org.glassfish.jersey.client.ClientProperties; +import org.junit.jupiter.api.*; +import org.openmetadata.schema.auth.LoginRequest; +import org.openmetadata.service.OpenMetadataApplicationTest; +import org.openmetadata.service.util.TestUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class LdapAuthCompleteFlowTest extends OpenMetadataApplicationTest { + + private static final String AUTH_LOGIN_ENDPOINT = "/api/v1/auth/login"; + private static final String AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout"; + + private static GenericContainer ldapContainer; + private static String ldapHost; + private static int ldapPort; + + private static final String TEST_USER_EMAIL = "testuser@testcompany.com"; + private static final String TEST_USER_PASSWORD = "testpass123"; + + @BeforeAll + @Override + public void createApplication() throws Exception { + startLdapContainer(); + super.createApplication(); + } + + private void startLdapContainer() throws Exception { + LOG.info("Starting LDAP container..."); + + ldapContainer = + new GenericContainer<>(DockerImageName.parse("osixia/openldap:1.5.0")) + .withExposedPorts(389) + .withEnv("LDAP_ORGANISATION", "TestCompany") + .withEnv("LDAP_DOMAIN", "testcompany.com") + .withEnv("LDAP_ADMIN_PASSWORD", "adminpassword") + .withEnv("LDAP_CONFIG_PASSWORD", "config") + .withEnv("LDAP_READONLY_USER", "false") + .withEnv("LDAP_TLS", "false") + .withCommand("--copy-service", "--loglevel", "debug") + .withStartupTimeout(Duration.ofMinutes(2)); + + ldapContainer.start(); + ldapHost = ldapContainer.getHost(); + ldapPort = ldapContainer.getMappedPort(389); + + LOG.info("LDAP container started at {}:{}", ldapHost, ldapPort); + + Thread.sleep(10000); + + loadTestLdapData(); + } + + private void loadTestLdapData() throws Exception { + LOG.info("Loading test LDAP data..."); + + LOG.info("Step 1: Creating organizational units"); + String ouLdif = + "dn: ou=users,dc=testcompany,dc=com\n" + + "objectClass: organizationalUnit\n" + + "ou: users\n\n" + + "dn: ou=groups,dc=testcompany,dc=com\n" + + "objectClass: organizationalUnit\n" + + "ou: groups"; + + execLdapAdd(ouLdif, "organizational units"); + + LOG.info("Step 2: Creating test user"); + String userLdif = + "dn: uid=testuser,ou=users,dc=testcompany,dc=com\n" + + "objectClass: inetOrgPerson\n" + + "objectClass: posixAccount\n" + + "objectClass: shadowAccount\n" + + "uid: testuser\n" + + "sn: User\n" + + "givenName: Test\n" + + "cn: Test User\n" + + "displayName: Test User\n" + + "uidNumber: 10001\n" + + "gidNumber: 5000\n" + + "userPassword: testpass123\n" + + "loginShell: /bin/bash\n" + + "homeDirectory: /home/testuser\n" + + "mail: testuser@testcompany.com"; + + execLdapAdd(userLdif, "test user"); + + LOG.info("Step 3: Creating admin user"); + String adminLdif = + "dn: uid=admin,ou=users,dc=testcompany,dc=com\n" + + "objectClass: inetOrgPerson\n" + + "objectClass: posixAccount\n" + + "objectClass: shadowAccount\n" + + "uid: admin\n" + + "sn: Admin\n" + + "givenName: System\n" + + "cn: System Admin\n" + + "displayName: System Admin\n" + + "uidNumber: 10000\n" + + "gidNumber: 5000\n" + + "userPassword: admin123\n" + + "loginShell: /bin/bash\n" + + "homeDirectory: /home/admin\n" + + "mail: admin@testcompany.com"; + + execLdapAdd(adminLdif, "admin user"); + + LOG.info("Step 4: Creating groups"); + String groupsLdif = + "dn: cn=DataConsumer,ou=groups,dc=testcompany,dc=com\n" + + "objectClass: groupOfNames\n" + + "cn: DataConsumer\n" + + "description: Data Consumer group\n" + + "member: uid=testuser,ou=users,dc=testcompany,dc=com\n\n" + + "dn: cn=admin,ou=groups,dc=testcompany,dc=com\n" + + "objectClass: groupOfNames\n" + + "cn: admin\n" + + "description: Administrator group\n" + + "member: uid=admin,ou=users,dc=testcompany,dc=com"; + + execLdapAdd(groupsLdif, "groups"); + + LOG.info("✓ All test LDAP data loaded successfully"); + } + + private void execLdapAdd(String ldifContent, String description) throws Exception { + String[] ldapAddCommand = { + "/bin/bash", + "-c", + "echo '" + + ldifContent + + "' | ldapadd -x -H ldap://localhost:389 -D 'cn=admin,dc=testcompany,dc=com' -w adminpassword" + }; + + org.testcontainers.containers.Container.ExecResult result = + ldapContainer.execInContainer(ldapAddCommand); + + if (result.getExitCode() != 0) { + LOG.error("Failed to add {}: {}", description, result.getStderr()); + throw new RuntimeException( + "Failed to load " + description + ". Exit code: " + result.getExitCode()); + } + LOG.info("✓ Added {}", description); + } + + @BeforeAll + void setupLdapConfiguration() throws Exception { + Thread.sleep(3000); + + LOG.info("Application running on port: {}", APP.getLocalPort()); + LOG.info("LDAP server running at: {}:{}", ldapHost, ldapPort); + + updateAuthenticationConfigToLdap(); + LOG.info("Test setup complete - LDAP authentication configured"); + } + + private void updateAuthenticationConfigToLdap() throws Exception { + LOG.info("Updating authentication configuration to LDAP..."); + + Map ldapConfig = new HashMap<>(); + ldapConfig.put("host", ldapHost); + ldapConfig.put("port", ldapPort); + ldapConfig.put("dnAdminPrincipal", "cn=admin,dc=testcompany,dc=com"); + ldapConfig.put("dnAdminPassword", "adminpassword"); + ldapConfig.put("userBaseDN", "ou=users,dc=testcompany,dc=com"); + ldapConfig.put("groupBaseDN", "ou=groups,dc=testcompany,dc=com"); + ldapConfig.put("mailAttributeName", "mail"); + ldapConfig.put("groupAttributeName", "objectClass"); + ldapConfig.put("groupAttributeValue", "groupOfNames"); + ldapConfig.put("groupMemberAttributeName", "member"); + ldapConfig.put("allAttributeName", "*"); + ldapConfig.put("roleAdminName", "admin"); + ldapConfig.put("maxPoolSize", 10); + ldapConfig.put("sslEnabled", false); + + Map> roleMapping = new HashMap<>(); + roleMapping.put("cn=admin,ou=groups,dc=testcompany,dc=com", Arrays.asList("admin")); + roleMapping.put( + "cn=DataConsumer,ou=groups,dc=testcompany,dc=com", Arrays.asList("DataConsumer")); + + ldapConfig.put("authRolesMapping", new ObjectMapper().writeValueAsString(roleMapping)); + ldapConfig.put("authReassignRoles", Arrays.asList("*")); + + Map authConfig = new HashMap<>(); + authConfig.put("provider", "ldap"); + authConfig.put("providerName", "LDAP"); + authConfig.put("publicKeyUrls", new ArrayList<>()); + authConfig.put("clientId", "open-metadata"); + authConfig.put("authority", "http://localhost:" + APP.getLocalPort()); + authConfig.put("callbackUrl", "http://localhost:" + APP.getLocalPort() + "/callback"); + authConfig.put("jwtPrincipalClaims", Arrays.asList("email", "preferred_username", "sub")); + authConfig.put("enableSelfSignup", true); + authConfig.put("ldapConfiguration", ldapConfig); + + Map authorizerConfig = new HashMap<>(); + authorizerConfig.put("className", "org.openmetadata.service.security.DefaultAuthorizer"); + authorizerConfig.put("containerRequestFilter", "org.openmetadata.service.security.JwtFilter"); + authorizerConfig.put("adminPrincipals", Arrays.asList("admin")); + authorizerConfig.put("testPrincipals", new ArrayList<>()); + authorizerConfig.put("allowedEmailRegistrationDomains", Arrays.asList("all")); + authorizerConfig.put("principalDomain", "open-metadata.org"); + authorizerConfig.put("allowedDomains", new ArrayList<>()); + authorizerConfig.put("enforcePrincipalDomain", false); + authorizerConfig.put("enableSecureSocketConnection", false); + authorizerConfig.put("useRolesFromProvider", false); + + Map securityConfig = new HashMap<>(); + securityConfig.put("authenticationConfiguration", authConfig); + securityConfig.put("authorizerConfiguration", authorizerConfig); + + Invocation.Builder request = + APP.client() + .target(getServerUrl() + "/api/v1/system/security/config") + .request(MediaType.APPLICATION_JSON); + + for (Map.Entry entry : TestUtils.ADMIN_AUTH_HEADERS.entrySet()) { + request = request.header(entry.getKey(), entry.getValue()); + } + + Response response = request.put(Entity.json(securityConfig)); + + LOG.info("Auth config update response status: {}", response.getStatus()); + + if (response.getStatus() == 200 || response.getStatus() == 201) { + LOG.info("✓ Successfully updated authentication configuration to LDAP"); + Thread.sleep(3000); + } else { + String error = response.readEntity(String.class); + LOG.error("Failed to update authentication configuration: {}", error); + fail("Failed to update authentication configuration - Status: " + response.getStatus()); + } + } + + @Test + @Order(1) + void testLdapContainerStartup() { + LOG.info("Testing LDAP container startup"); + assertNotNull(ldapContainer); + assertTrue(ldapContainer.isRunning()); + assertNotNull(ldapHost); + assertTrue(ldapPort > 0); + LOG.info("LDAP container is running at {}:{}", ldapHost, ldapPort); + } + + @Test + @Order(3) + void testSuccessfulLoginWithValidCredentials() throws Exception { + LOG.info("Testing successful LDAP login with valid credentials"); + + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(TEST_USER_EMAIL); + loginRequest.setPassword(Base64.getEncoder().encodeToString(TEST_USER_PASSWORD.getBytes())); + + Response response = + APP.client() + .target(getServerUrl() + AUTH_LOGIN_ENDPOINT) + .request(MediaType.APPLICATION_JSON) + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .post(Entity.json(loginRequest)); + + LOG.info("Login response status: {}", response.getStatus()); + assertEquals(200, response.getStatus(), "Login should succeed with valid credentials"); + + String responseBody = response.readEntity(String.class); + LOG.info("Login response: {}", responseBody); + + Map jwtResponse = new ObjectMapper().readValue(responseBody, Map.class); + + assertNotNull(jwtResponse.get("accessToken"), "Access token should be present"); + assertEquals("Bearer", jwtResponse.get("tokenType"), "Token type should be Bearer"); + assertNotNull(jwtResponse.get("expiryDuration"), "Expiry duration should be present"); + assertNull(jwtResponse.get("refreshToken"), "Refresh token should not be sent to client"); + + Map cookies = response.getCookies(); + LOG.info("Session cookies: {}", cookies.keySet()); + } + + @Test + @Order(4) + void testFailedLoginWithInvalidPassword() throws Exception { + LOG.info("Testing failed LDAP login with invalid password"); + + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(TEST_USER_EMAIL); + loginRequest.setPassword(Base64.getEncoder().encodeToString("wrongpassword".getBytes())); + + Response response = + APP.client() + .target(getServerUrl() + AUTH_LOGIN_ENDPOINT) + .request(MediaType.APPLICATION_JSON) + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .post(Entity.json(loginRequest)); + + LOG.info("Login response status: {}", response.getStatus()); + assertNotEquals(200, response.getStatus(), "Login should fail with invalid password"); + assertTrue( + response.getStatus() == 401 || response.getStatus() == 500, + "Should return 401 or 500 for invalid credentials"); + } + + @Test + @Order(5) + void testFailedLoginWithNonExistentUser() throws Exception { + LOG.info("Testing failed LDAP login with non-existent user"); + + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail("nonexistent@testcompany.com"); + loginRequest.setPassword(Base64.getEncoder().encodeToString("password".getBytes())); + + Response response = + APP.client() + .target(getServerUrl() + AUTH_LOGIN_ENDPOINT) + .request(MediaType.APPLICATION_JSON) + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .post(Entity.json(loginRequest)); + + LOG.info("Login response status: {}", response.getStatus()); + assertNotEquals(200, response.getStatus(), "Login should fail with non-existent user"); + } + + @Test + @Order(7) + void testLogout() throws Exception { + LOG.info("Testing logout flow"); + + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(TEST_USER_EMAIL); + loginRequest.setPassword(Base64.getEncoder().encodeToString(TEST_USER_PASSWORD.getBytes())); + + Response loginResponse = + APP.client() + .target(getServerUrl() + AUTH_LOGIN_ENDPOINT) + .request(MediaType.APPLICATION_JSON) + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .post(Entity.json(loginRequest)); + + assertEquals(200, loginResponse.getStatus(), "Login should succeed"); + + Map cookies = loginResponse.getCookies(); + + Invocation.Builder logoutRequest = + APP.client() + .target(getServerUrl() + AUTH_LOGOUT_ENDPOINT) + .request(MediaType.APPLICATION_JSON) + .property(ClientProperties.FOLLOW_REDIRECTS, false); + + for (NewCookie cookie : cookies.values()) { + logoutRequest = logoutRequest.cookie(cookie); + } + + Response logoutResponse = logoutRequest.get(); + + LOG.info("Logout response status: {}", logoutResponse.getStatus()); + assertEquals(200, logoutResponse.getStatus(), "Logout should succeed"); + } + + @Test + @Order(9) + void testMultipleLoginAttempts() throws Exception { + LOG.info("Testing multiple failed login attempts"); + + String testEmail = "multitest@testcompany.com"; + + for (int i = 0; i < 3; i++) { + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(testEmail); + loginRequest.setPassword(Base64.getEncoder().encodeToString("wrongpassword".getBytes())); + + Response response = + APP.client() + .target(getServerUrl() + AUTH_LOGIN_ENDPOINT) + .request(MediaType.APPLICATION_JSON) + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .post(Entity.json(loginRequest)); + + LOG.info("Attempt {} - Response status: {}", i + 1, response.getStatus()); + assertNotEquals(200, response.getStatus(), "Login should fail with wrong password"); + + Thread.sleep(500); + } + + LoginAttemptCache.getInstance().recordSuccessfulLogin(testEmail); + } + + private String getServerUrl() { + return "http://localhost:" + APP.getLocalPort(); + } + + @AfterAll + void cleanup() { + if (ldapContainer != null && ldapContainer.isRunning()) { + LOG.info("Stopping LDAP container"); + ldapContainer.stop(); + } + } +}