diff --git a/.gitignore b/.gitignore index 3a6c5f5b8..cff38ddf7 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ target/ coverage/ dist/ node_modules/ +.pnpm-store/ *.tsbuildinfo package-lock.json diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index aeeee7a33..040211b1d 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -103,6 +103,11 @@ h2 test + + org.testcontainers + junit-jupiter + test + @@ -111,6 +116,33 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + + default-test + + + **/auth/ldap/LdapIntegrationTest.java + + + + + ldap-container-test + test + + test + + + + **/auth/ldap/LdapIntegrationTest.java + + false + + + + diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LdapAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LdapAuthController.java new file mode 100644 index 000000000..c7ff481c1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LdapAuthController.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.LdapBindRequest; +import com.iflytek.skillhub.service.LdapBindingAppService; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Endpoints for binding an LDAP identity to the currently authenticated account. + */ +@RestController +@RequestMapping("/api/v1/auth/ldap") +public class LdapAuthController extends BaseApiController { + + private final LdapBindingAppService ldapBindingAppService; + + public LdapAuthController(ApiResponseFactory responseFactory, + LdapBindingAppService ldapBindingAppService) { + super(responseFactory); + this.ldapBindingAppService = ldapBindingAppService; + } + + @PostMapping("/bind") + public ApiResponse bind(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody LdapBindRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + ldapBindingAppService.bindLdapIdentity(principal.userId(), request.username(), request.password()); + return ok("response.success", null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LdapBindRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LdapBindRequest.java new file mode 100644 index 000000000..52ef501ca --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LdapBindRequest.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * Binds an LDAP identity to the currently authenticated account using the user's LDAP + * credentials as proof of directory-identity ownership. + */ +public record LdapBindRequest( + @NotBlank(message = "LDAP 用户名不能为空") + String username, + @NotBlank(message = "LDAP 密码不能为空") + String password +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LdapBindingAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LdapBindingAppService.java new file mode 100644 index 000000000..249deb853 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LdapBindingAppService.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; +import com.iflytek.skillhub.auth.ldap.LdapAuthService.LdapIdentity; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.Locale; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Explicit LDAP identity-binding flow: a signed-in user proves ownership of a directory identity + * with their LDAP credentials and attaches it to their current account. This is the + * self-service counterpart of the first-login email-conflict refusal — instead of silently + * inheriting an existing account, the user consciously binds the LDAP identity to it. + */ +@Service +public class LdapBindingAppService { + + private static final String LDAP_PROVIDER = "ldap"; + + private final ObjectProvider ldapAuthServiceProvider; + private final IdentityBindingRepository identityBindingRepository; + private final UserAccountRepository userAccountRepository; + + public LdapBindingAppService(ObjectProvider ldapAuthServiceProvider, + IdentityBindingRepository identityBindingRepository, + UserAccountRepository userAccountRepository) { + this.ldapAuthServiceProvider = ldapAuthServiceProvider; + this.identityBindingRepository = identityBindingRepository; + this.userAccountRepository = userAccountRepository; + } + + @Transactional + public void bindLdapIdentity(String currentUserId, String username, String password) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + LdapIdentity identity = ldapAuthService.resolveIdentity(username, password); + + var existingBinding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, identity.subject()); + if (existingBinding.isPresent()) { + if (!existingBinding.get().getUserId().equals(currentUserId)) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.bindingTaken"); + } + // Already bound to the current account — idempotent success. + return; + } + + String email = identity.email(); + if (email != null && !email.isEmpty()) { + userAccountRepository.findByEmailIgnoreCase(email.toLowerCase(Locale.ROOT)) + .filter(existing -> !existing.getId().equals(currentUserId)) + .ifPresent(existing -> { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + }); + } + + try { + identityBindingRepository.save( + new IdentityBinding(currentUserId, LDAP_PROVIDER, identity.subject(), identity.username())); + } catch (DataIntegrityViolationException e) { + // A concurrent bind for the same subject won the (provider_code, subject) race. + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.bindingTaken"); + } + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a592b0359..fdb40e34f 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -104,6 +104,29 @@ skillhub: code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M} email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local} email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub} + ldap: + # LDAP authentication configuration + # Set enabled to true and configure url/base/username/password to enable LDAP authentication + enabled: ${SKILLHUB_LDAP_ENABLED:false} + url: ${SKILLHUB_LDAP_URL:} + base: ${SKILLHUB_LDAP_BASE:} + username: ${SKILLHUB_LDAP_USERNAME:} + password: ${SKILLHUB_LDAP_PASSWORD:} + user-search-attribute: ${SKILLHUB_LDAP_USER_SEARCH_ATTRIBUTE:uid} + user-search-base: ${SKILLHUB_LDAP_USER_SEARCH_BASE:} + # Stable directory identifier used as the LDAP identity subject (entryUUID for OpenLDAP, objectGUID for AD) + subject-attribute: ${SKILLHUB_LDAP_SUBJECT_ATTRIBUTE:entryUUID} + display-name-attribute: ${SKILLHUB_LDAP_DISPLAY_NAME_ATTRIBUTE:displayName} + display-name-fallback-attribute: ${SKILLHUB_LDAP_DISPLAY_NAME_FALLBACK_ATTRIBUTE:cn} + email-attribute: ${SKILLHUB_LDAP_EMAIL_ATTRIBUTE:mail} + connect-timeout-millis: ${SKILLHUB_LDAP_CONNECT_TIMEOUT_MILLIS:5000} + read-timeout-millis: ${SKILLHUB_LDAP_READ_TIMEOUT_MILLIS:10000} + # Custom trust store for LDAPS certificate validation (internal/self-signed CAs). + # Installed at application startup by merging into the JVM-wide trust store (defaults are + # preserved). Leave empty to use the JVM default trust store. + tls-trust-store: ${SKILLHUB_LDAP_TLS_TRUST_STORE:} + tls-trust-store-password: ${SKILLHUB_LDAP_TLS_TRUST_STORE_PASSWORD:} + tls-trust-store-type: ${SKILLHUB_LDAP_TLS_TRUST_STORE_TYPE:JKS} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: @@ -206,6 +229,8 @@ management: health: mail: enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false} + ldap: + enabled: false endpoints: web: exposure: diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__identity_binding_cascade_delete.sql b/server/skillhub-app/src/main/resources/db/migration/V44__identity_binding_cascade_delete.sql new file mode 100644 index 000000000..e4c6284ec --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__identity_binding_cascade_delete.sql @@ -0,0 +1,7 @@ +-- identity_binding.user_id references user_account(id) without ON DELETE CASCADE. When an +-- account is removed, its bindings would otherwise survive and block the identity from being +-- provisioned again. Cascade the deletion so bindings never outlive their account. +ALTER TABLE identity_binding DROP CONSTRAINT identity_binding_user_id_fkey; +ALTER TABLE identity_binding + ADD CONSTRAINT identity_binding_user_id_fkey + FOREIGN KEY (user_id) REFERENCES user_account(id) ON DELETE CASCADE; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe4..d3fedf15b 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -46,6 +46,14 @@ error.auth.direct.providerUnsupported=Unsupported direct authentication provider error.auth.sessionBootstrap.disabled=Session bootstrap is disabled error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found +error.auth.ldap.disabled=LDAP authentication is not enabled +error.auth.ldap.userNotFound=Invalid username or password +error.auth.ldap.invalidCredentials=Invalid username or password +error.auth.ldap.invalidConfiguration=LDAP authentication is misconfigured. Please contact an administrator +error.auth.ldap.directoryUnavailable=The directory server is temporarily unavailable. Please try again later +error.auth.ldap.tlsError=Failed to establish a secure connection to the directory server. Please check the TLS certificate configuration +error.auth.ldap.emailConflict=This email is already associated with an existing account. Please contact an administrator +error.auth.ldap.bindingTaken=This LDAP identity is already bound to another account error.badRequest=Invalid request error.forbidden=Forbidden error.request.timeout=Request timed out diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef095637..24b4fc14d 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -46,6 +46,14 @@ error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} error.auth.sessionBootstrap.disabled=会话引导能力未启用 error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 +error.auth.ldap.disabled=LDAP 认证未启用 +error.auth.ldap.userNotFound=用户名或密码错误 +error.auth.ldap.invalidCredentials=用户名或密码错误 +error.auth.ldap.invalidConfiguration=LDAP 认证配置有误,请联系管理员处理 +error.auth.ldap.directoryUnavailable=目录服务器暂时不可用,请稍后重试 +error.auth.ldap.tlsError=无法与目录服务器建立安全连接,请检查 TLS 证书配置 +error.auth.ldap.emailConflict=该邮箱已关联已有账号,请联系管理员处理 +error.auth.ldap.bindingTaken=该 LDAP 身份已绑定到其他账号 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 error.request.timeout=请求超时 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/ConcurrentLdapFirstLoginTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/ConcurrentLdapFirstLoginTest.java new file mode 100644 index 000000000..360e4dd4a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/ConcurrentLdapFirstLoginTest.java @@ -0,0 +1,166 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +/** + * Concurrency coverage for LDAP first-login provisioning: two simultaneous first logins for the + * same LDAP subject must resolve to a single account and a single identity binding. One login + * wins the {@code (provider_code, subject)} unique-constraint race; the loser re-resolves the + * existing identity in a fresh transaction and still succeeds. + */ +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers(disabledWithoutDocker = true) +class ConcurrentLdapFirstLoginTest { + + private static final String BASE_DN = "dc=example,dc=org"; + private static final String BIND_DN = "cn=admin," + BASE_DN; + + @Container + static final GenericContainer> LDAP = new GenericContainer<>("osixia/openldap:1.5.0") + .withEnv("LDAP_ORGANISATION", "Example Inc") + .withEnv("LDAP_DOMAIN", "example.org") + .withEnv("LDAP_ADMIN_PASSWORD", "admin") + .withExposedPorts(389); + + @DynamicPropertySource + static void ldapProperties(DynamicPropertyRegistry registry) { + registry.add("skillhub.ldap.enabled", () -> "true"); + registry.add("skillhub.ldap.url", () -> "ldap://" + LDAP.getHost() + ":" + LDAP.getMappedPort(389)); + registry.add("skillhub.ldap.base", () -> BASE_DN); + registry.add("skillhub.ldap.username", () -> BIND_DN); + registry.add("skillhub.ldap.password", () -> "admin"); + } + + @Autowired + private LocalAuthService localAuthService; + + @Autowired + private UserAccountRepository userAccountRepository; + + @Autowired + private IdentityBindingRepository identityBindingRepository; + + @Autowired + private NamespaceRepository namespaceRepository; + + @BeforeEach + void ensureGlobalNamespace() { + namespaceRepository.findBySlug("global") + .orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap"))); + } + + @BeforeAll + static void seedDirectory() throws Exception { + LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif"); + awaitLdapReady(); + ExecResult add = LDAP.execInContainer("ldapadd", "-x", "-H", "ldap://localhost", + "-D", BIND_DN, "-w", "admin", "-f", "/tmp/seed-users.ldif"); + assertThat(add.getExitCode()) + .as("ldapadd failed: %s", add.getStdout() + add.getStderr()) + .isZero(); + } + + private static void awaitLdapReady() throws Exception { + long deadline = System.currentTimeMillis() + 30_000; + while (System.currentTimeMillis() < deadline) { + try { + ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost", + "-b", BASE_DN, "-D", BIND_DN, "-w", "admin", "(objectClass=*)", "dn"); + if (r.getExitCode() == 0) { + return; + } + } catch (Exception ignored) { + } + Thread.sleep(500); + } + throw new IllegalStateException("OpenLDAP did not become ready"); + } + + @Test + void concurrentFirstLogin_sameSubject_singleAccountAndBothSucceed() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Callable login = () -> localAuthService.login("alice", "alice123"); + Future first = pool.submit(login); + Future second = pool.submit(login); + + PlatformPrincipal r1 = first.get(); + PlatformPrincipal r2 = second.get(); + + // Both concurrent first logins must succeed and resolve to the same account; + // exactly one identity binding and one account exist afterwards. + assertThat(r1.userId()).isEqualTo(r2.userId()); + assertThat(identityBindingRepository.findAll()).hasSize(1); + assertThat(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).isPresent(); + } finally { + pool.shutdownNow(); + } + } + + @Test + void concurrentFirstLogin_differentSubjectsSameEmail_oneAccountAndOneConflict() throws Exception { + // Two distinct LDAP subjects share one email and log in for the first time at the same + // moment. The email-collision check must be serialized: exactly one provisioning succeeds + // and the other receives 409 — never two accounts with the same email. + long bindingsBefore = identityBindingRepository.findAll().size(); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future eve = pool.submit(() -> (Object) localAuthService.login("eve", "eve123")); + Future frank = pool.submit(() -> (Object) localAuthService.login("frank", "frank123")); + + List results = List.of(unwrap(eve), unwrap(frank)); + long successes = results.stream().filter(PlatformPrincipal.class::isInstance).count(); + long conflicts = results.stream() + .filter(t -> t instanceof AuthFlowException e && e.getStatus() == HttpStatus.CONFLICT) + .count(); + + assertThat(successes).as("exactly one of the two first logins succeeds").isEqualTo(1); + assertThat(conflicts).as("the other login is refused with 409").isEqualTo(1); + assertThat(userAccountRepository.findByEmailIgnoreCase("shared@example.com")) + .as("the successful login provisioned exactly one account for the shared email") + .isPresent(); + assertThat(identityBindingRepository.findAll()) + .as("only the successful subject is bound (one new binding, none for the 409 loser)") + .hasSize((int) bindingsBefore + 1); + } finally { + pool.shutdownNow(); + } + } + + private static Object unwrap(Future> future) throws Exception { + try { + return future.get(); + } catch (ExecutionException e) { + return e.getCause(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryUnavailableTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryUnavailableTest.java new file mode 100644 index 000000000..b43b9d4a7 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryUnavailableTest.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import jakarta.persistence.EntityManager; +import java.io.IOException; +import java.net.ServerSocket; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Integration coverage for the "directory unavailable" error classification required by the + * PR #437 review. A real JNDI connection is attempted against a guaranteed-closed localhost + * port, so the CommunicationException path (not a mocked exception) is exercised end to end. + * + * This test intentionally does not use Testcontainers: it needs no directory, and keeping it + * standalone lets it run in any environment, including ones without a Docker daemon. + */ +class LdapDirectoryUnavailableTest { + + @Test + void login_whenDirectoryUnreachable_returnsServiceUnavailable() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://127.0.0.1:" + freePort()); + props.setBase("dc=example,dc=org"); + + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(props.getUrl()); + contextSource.setPooled(false); + contextSource.afterPropertiesSet(); + + LdapAuthService svc = new LdapAuthService( + props, + contextSource, + mock(UserAccountRepository.class), + mock(UserRoleBindingRepository.class), + mock(GlobalNamespaceMembershipService.class), + mock(IdentityBindingRepository.class), + mock(EntityManager.class), + mock(PlatformTransactionManager.class)); + + assertThatThrownBy(() -> svc.login("alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.directoryUnavailable"); + }); + } + + /** + * Reserves an ephemeral port and releases it, leaving a port that is (almost certainly) + * closed for the subsequent connection attempt. + */ + private static int freePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate an ephemeral port", e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java new file mode 100644 index 000000000..90d46f904 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration coverage for "startup with LDAP disabled" required by the PR #437 review: the full + * Spring context must boot without a directory configured, the conditional {@link LdapAuthService} + * bean must be absent, and the local login fallback must degrade to invalid credentials instead of + * failing on a missing LDAP bean. + */ +@SpringBootTest(properties = "skillhub.ldap.enabled=false") +@ActiveProfiles("test") +class LdapDisabledStartupTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private LdapProperties ldapProperties; + + @Autowired + private LocalAuthService localAuthService; + + @Test + void contextStartsWithoutLdapAuthServiceBean() { + assertThat(ldapProperties.isEnabled()).isFalse(); + // The bean is created only when skillhub.ldap.enabled=true; with LDAP disabled the + // context must start without it (LocalAuthService consumes it via ObjectProvider). + assertThat(context.getBeansOfType(LdapAuthService.class)).isEmpty(); + assertThat(localAuthService).isNotNull(); + } + + @Test + void localLogin_withoutLdapBean_fallsBackToInvalidCredentials() { + assertThatThrownBy(() -> localAuthService.login("no-such-user", "wrong-password")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()) + .isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java new file mode 100644 index 000000000..873e0ce06 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java @@ -0,0 +1,420 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.service.LdapBindingAppService; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import jakarta.persistence.EntityManager; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +/** + * End-to-end LDAP coverage against a real OpenLDAP directory (Testcontainers) for the behavior + * required by PR #437 review: first-login provisioning, repeat-login identity stability, + * no-email placeholder handling, email-collision refusal, invalid credentials, attribute + * synchronization, and LDAPS TLS-failure classification. + * + * The default {@code subject-attribute=entryUUID} is intentionally left untouched: OpenLDAP + * exposes entryUUID only as an operational attribute, which previously made every login fail + * with a 503 unless the operator changed the subject attribute. + */ +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers(disabledWithoutDocker = true) +class LdapIntegrationTest { + + private static final String BASE_DN = "dc=example,dc=org"; + private static final String BIND_DN = "cn=admin," + BASE_DN; + private static final String BIND_PASSWORD = "admin"; + + /** + * The image's baked-in TLS certificates expired in 2026, which makes any LDAPS success path + * impossible. Generate a fresh CA and server certificate with the JDK's keytool before the + * container starts, and let {@code withCopyFileToContainer} install them over the baked-in + * files. The container's entrypoint only generates certificates when the files are absent. + */ + private static final Path TLS_CERTS_DIR = prepareTlsCertificates(); + + /** + * The JDK LDAP provider resolves LDAPS trust from the JVM-wide SSL configuration and offers + * no per-connection trust-store injection point, so the test CA must be installed through the + * {@code javax.net.ssl.trustStore*} system properties before any JNDI connection (and thus + * before the JSSE default SSLContext is cached). The static initializer runs at class load, + * before the Spring context and the LDAP container are created. + */ + static { + try { + Path truststore = Files.createTempFile("ldap-truststore", ".p12"); + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (InputStream in = Files.newInputStream(TLS_CERTS_DIR.resolve("ca.crt"))) { + ks.setCertificateEntry("ldap-ca", cf.generateCertificate(in)); + } + try (OutputStream out = Files.newOutputStream(truststore)) { + ks.store(out, "changeit".toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", truststore.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", "changeit"); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + } catch (Exception e) { + throw new ExceptionInInitializerError(e); + } + } + + @Container + static final GenericContainer> LDAP = new GenericContainer<>("osixia/openldap:1.5.0") + .withEnv("LDAP_ORGANISATION", "Example Inc") + .withEnv("LDAP_DOMAIN", "example.org") + .withEnv("LDAP_ADMIN_PASSWORD", BIND_PASSWORD) + // The image defaults to olcTLSVerifyClient=demand, which requires client certificates + // during the TLS handshake. The tests exercise server-certificate validation only. + .withEnv("LDAP_TLS_VERIFY_CLIENT", "never") + .withExposedPorts(389, 636) + .withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ca.crt")), + "/container/service/slapd/assets/certs/ca.crt") + .withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.crt")), + "/container/service/slapd/assets/certs/ldap.crt") + .withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.key")), + "/container/service/slapd/assets/certs/ldap.key"); + + private static Path prepareTlsCertificates() { + try { + Path dir = Files.createTempDirectory("ldap-tls-certs"); + runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "ca", + "-dname", "CN=SkillHub Test CA", "-validity", "3650", "-keyalg", "RSA", + "-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "ca.p12", + "-storepass", "changeit", "-keypass", "changeit", + "-ext", "BasicConstraints=ca:true")); + runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "server", + "-dname", "CN=ldap.example.org", "-validity", "3650", "-keyalg", "RSA", + "-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "server.p12", + "-storepass", "changeit", "-keypass", "changeit")); + runKeytool(dir, List.of("keytool", "-certreq", "-alias", "server", + "-keystore", "server.p12", "-storepass", "changeit", "-file", "server.csr")); + runKeytool(dir, List.of("keytool", "-gencert", "-alias", "ca", + "-keystore", "ca.p12", "-storepass", "changeit", "-infile", "server.csr", + "-rfc", "-validity", "3650", + "-ext", "BasicConstraints=ca:false", + "-ext", "KeyUsage=digitalSignature,keyEncipherment", + "-ext", "ExtendedKeyUsage=serverAuth", + "-outfile", "server.crt")); + runKeytool(dir, List.of("keytool", "-exportcert", "-alias", "ca", + "-keystore", "ca.p12", "-storepass", "changeit", "-rfc", "-file", "ca.crt")); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(dir.resolve("server.p12"))) { + ks.load(in, "changeit".toCharArray()); + } + PrivateKey key = (PrivateKey) ks.getKey("server", "changeit".toCharArray()); + String pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(key.getEncoded()) + + "\n-----END PRIVATE KEY-----\n"; + Files.writeString(dir.resolve("ldap.key"), pem); + // slapd runs as the openldap user; the key must be world-readable inside the container. + Files.setPosixFilePermissions(dir.resolve("ldap.key"), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--")); + Files.copy(dir.resolve("server.crt"), dir.resolve("ldap.crt")); + return dir; + } catch (Exception e) { + throw new IllegalStateException("Failed to prepare LDAPS test certificates", e); + } + } + + private static void runKeytool(Path dir, List command) throws Exception { + Process process = new ProcessBuilder(command) + .directory(dir.toFile()) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int exit = process.waitFor(); + if (exit != 0) { + throw new IllegalStateException(command.get(0) + " failed (" + exit + "): " + output); + } + } + + @DynamicPropertySource + static void ldapProperties(DynamicPropertyRegistry registry) { + registry.add("skillhub.ldap.enabled", () -> "true"); + registry.add("skillhub.ldap.url", () -> "ldap://" + LDAP.getHost() + ":" + LDAP.getMappedPort(389)); + registry.add("skillhub.ldap.base", () -> BASE_DN); + registry.add("skillhub.ldap.username", () -> BIND_DN); + registry.add("skillhub.ldap.password", () -> BIND_PASSWORD); + registry.add("skillhub.ldap.user-search-attribute", () -> "uid"); + // subject-attribute intentionally stays at its default (entryUUID). + } + + @Autowired + private LocalAuthService localAuthService; + + @Autowired + private UserAccountRepository userAccountRepository; + + @Autowired + private IdentityBindingRepository identityBindingRepository; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private LdapBindingAppService ldapBindingAppService; + + @BeforeEach + void ensureGlobalNamespace() { + namespaceRepository.findBySlug("global") + .orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap"))); + } + + @BeforeAll + static void seedDirectory() throws Exception { + // The OpenLDAP container's certificate is issued for cn=ldap.example.org, while the test + // connects through the container's mapped address. The JNDI LDAP provider performs + // endpoint identification (hostname verification) by default, which would reject the + // address even with a trusted CA. Disable endpoint identification for this test JVM so + // the custom-truststore success path exercises certificate-chain validation only. + System.setProperty("com.sun.jndi.ldap.object.disableEndpointIdentification", "true"); + LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif"); + LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/modify-dave.ldif"), "/tmp/modify-dave.ldif"); + awaitLdapReady(); + ExecResult add = LDAP.execInContainer("ldapadd", "-x", "-H", "ldap://localhost", + "-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/seed-users.ldif"); + assertThat(add.getExitCode()) + .as("ldapadd failed: %s", add.getStdout() + add.getStderr()) + .isZero(); + } + + @AfterAll + static void restoreEndpointIdentification() { + System.clearProperty("com.sun.jndi.ldap.object.disableEndpointIdentification"); + } + + private static void awaitLdapReady() throws Exception { + long deadline = System.currentTimeMillis() + 30_000; + Exception last = null; + while (System.currentTimeMillis() < deadline) { + try { + ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost", + "-b", BASE_DN, "-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "dn"); + if (r.getExitCode() == 0) { + return; + } + last = new IllegalStateException("ldapsearch exit " + r.getExitCode() + + ": " + r.getStdout() + r.getStderr()); + } catch (Exception e) { + last = e; + } + Thread.sleep(500); + } + throw new IllegalStateException("OpenLDAP did not become ready", last); + } + + @Test + void firstLogin_withDefaultEntryUuidSubject_provisionsAccountAndBinding() throws Exception { + PlatformPrincipal principal = localAuthService.login("alice", "alice123"); + + assertThat(principal.userId()).isNotBlank(); + Optional account = userAccountRepository.findById(principal.userId()); + assertThat(account).isPresent(); + assertThat(account.get().getEmail()).isEqualTo("alice@example.com"); + assertThat(account.get().getDisplayName()).isEqualTo("Alice Smith"); + + String entryUuid = directoryEntryUuid("alice"); + Optional binding = + identityBindingRepository.findByProviderCodeAndSubject("ldap", entryUuid); + assertThat(binding).as("identity binding keyed by OpenLDAP entryUUID").isPresent(); + } + + @Test + void repeatLogin_returnsSameAccount_andDoesNotDuplicate() throws Exception { + PlatformPrincipal first = localAuthService.login("alice", "alice123"); + PlatformPrincipal second = localAuthService.login("alice", "alice123"); + + assertThat(second.userId()).isEqualTo(first.userId()); + Optional account = userAccountRepository.findByEmailIgnoreCase("alice@example.com"); + assertThat(account).isPresent(); + assertThat(account.get().getId()).isEqualTo(first.userId()); + // Exactly one binding exists for this subject; no duplicate provisioning happened. + Optional binding = + identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("alice")); + assertThat(binding).isPresent(); + assertThat(binding.get().getUserId()).isEqualTo(first.userId()); + } + + @Test + void noEmailUser_usesPlaceholderEmail_andCnFallback() { + PlatformPrincipal principal = localAuthService.login("bob", "bob123"); + + assertThat(principal.email()).isEqualTo("ldap:bob@internal"); + // bob has no displayName in the directory; the configured cn fallback must apply. + assertThat(principal.displayName()).isEqualTo("Bob Jones"); + } + + @Test + void emailCollision_returnsConflict_andDoesNotTakeOverExistingAccount() throws Exception { + UserAccount local = new UserAccount("usr_local_carol", "Carol Local", "carol@example.com", null); + userAccountRepository.save(local); + + assertThatThrownBy(() -> localAuthService.login("carol", "carol123")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + }); + + // The existing account is untouched and no LDAP binding was written. + Optional untouched = userAccountRepository.findById(local.getId()); + assertThat(untouched).isPresent(); + assertThat(untouched.get().getDisplayName()).isEqualTo("Carol Local"); + assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("carol"))) + .isEmpty(); + } + + @Test + void wrongPassword_returnsUnauthorized() { + assertThatThrownBy(() -> localAuthService.login("alice", "wrong-password")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void emptyPassword_returnsUnauthorized_notServerError() { + // Direct service-level coverage: a null password must be classified as a credential + // failure (401), never a NullPointerException/500. (The HTTP layer already rejects + // blank passwords via @NotBlank; this guards service-level callers.) + assertThatThrownBy(() -> localAuthService.login("alice", null)) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void attributeChanges_areRefreshedOnNextLogin() throws Exception { + PlatformPrincipal before = localAuthService.login("dave", "dave123"); + assertThat(before.displayName()).isEqualTo("Dave Miller"); + + ExecResult mod = LDAP.execInContainer("ldapmodify", "-x", "-H", "ldap://localhost", + "-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/modify-dave.ldif"); + assertThat(mod.getExitCode()) + .as("ldapmodify failed: %s", mod.getStdout() + mod.getStderr()) + .isZero(); + + PlatformPrincipal after = localAuthService.login("dave", "dave123"); + assertThat(after.userId()).isEqualTo(before.userId()); + assertThat(after.displayName()).isEqualTo("Dave D. Miller"); + } + + @Test + void ldaps_withCustomTrustStore_authenticatesSuccessfully() throws Exception { + // The test CA is installed JVM-wide by the static initializer (the JDK LDAP provider has + // no per-connection trust-store injection point). This test verifies the full LDAPS chain + // (search, bind, attribute read) succeeds with that trust store in place. + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldaps://" + LDAP.getHost() + ":" + LDAP.getMappedPort(636)); + props.setBase(BASE_DN); + props.setUsername(BIND_DN); + props.setPassword(BIND_PASSWORD); + + UserAccountRepository userRepo = mock(UserAccountRepository.class); + when(userRepo.findByEmailIgnoreCase(any())).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + IdentityBindingRepository bindingRepo = mock(IdentityBindingRepository.class); + when(bindingRepo.findByProviderCodeAndSubject(any(), any())).thenReturn(Optional.empty()); + + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(props.getUrl()); + contextSource.setUserDn(BIND_DN); + contextSource.setPassword(BIND_PASSWORD); + contextSource.setPooled(false); + contextSource.afterPropertiesSet(); + + LdapAuthService svc = new LdapAuthService(props, + contextSource, + userRepo, + mock(UserRoleBindingRepository.class), + mock(GlobalNamespaceMembershipService.class), + bindingRepo, + mock(EntityManager.class), + mock(PlatformTransactionManager.class)); + + PlatformPrincipal principal = svc.login("alice", "alice123"); + assertThat(principal.userId()).isNotBlank(); + assertThat(principal.email()).isEqualTo("alice@example.com"); + } + + @Test + void explicitBind_attachesLdapIdentity_thenLdapLoginResolvesToBoundAccount() throws Exception { + // Self-service binding: a local account proves ownership of the LDAP identity with the + // directory password, and subsequent LDAP logins resolve to that account. + UserAccount local = new UserAccount("usr_grace_bind", "Grace Local", "grace@example.com", null); + userAccountRepository.save(local); + + ldapBindingAppService.bindLdapIdentity(local.getId(), "grace", "grace123"); + + assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("grace"))) + .as("binding is persisted for the LDAP subject") + .isPresent() + .get() + .extracting(IdentityBinding::getUserId) + .isEqualTo(local.getId()); + + PlatformPrincipal principal = localAuthService.login("grace", "grace123"); + assertThat(principal.userId()).isEqualTo(local.getId()); + assertThat(principal.displayName()).isEqualTo("Grace Smith"); + } + + private static String directoryEntryUuid(String uid) throws Exception { + ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost", + "-b", "uid=" + uid + "," + BASE_DN, "-s", "base", + "-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "entryUUID"); + assertThat(r.getExitCode()) + .as("ldapsearch failed: %s", r.getStdout() + r.getStderr()) + .isZero(); + return r.getStdout().lines() + .filter(line -> line.startsWith("entryUUID:")) + .map(line -> line.substring("entryUUID:".length()).trim()) + .findFirst() + .orElseThrow(() -> new AssertionError("entryUUID not returned by ldapsearch")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java new file mode 100644 index 000000000..5b646e020 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java @@ -0,0 +1,126 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; +import com.iflytek.skillhub.auth.ldap.LdapAuthService.LdapIdentity; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; + +class LdapBindingAppServiceTest { + + private static final String CURRENT_USER = "usr_current"; + + private LdapAuthService ldapAuthService; + private IdentityBindingRepository identityBindingRepository; + private UserAccountRepository userAccountRepository; + private LdapBindingAppService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ldapAuthService = mock(LdapAuthService.class); + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(ldapAuthService); + identityBindingRepository = mock(IdentityBindingRepository.class); + userAccountRepository = mock(UserAccountRepository.class); + service = new LdapBindingAppService(provider, identityBindingRepository, userAccountRepository); + } + + @Test + void bind_createsBindingForCurrentAccount() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice")); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.empty()); + when(userAccountRepository.findByEmailIgnoreCase("alice@example.com")) + .thenReturn(Optional.empty()); + + service.bindLdapIdentity(CURRENT_USER, "alice", "secret"); + + verify(identityBindingRepository).save(any(IdentityBinding.class)); + } + + @Test + void bind_whenSubjectBelongsToAnotherAccount_throwsConflict() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", null, "Alice")); + IdentityBinding other = new IdentityBinding("usr_other", "ldap", "entry-uuid-1", "alice"); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.of(other)); + + assertThatThrownBy(() -> service.bindLdapIdentity(CURRENT_USER, "alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.bindingTaken"); + }); + verify(identityBindingRepository, never()).save(any()); + } + + @Test + void bind_whenSubjectAlreadyBoundToCurrentAccount_isIdempotent() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice")); + IdentityBinding own = new IdentityBinding(CURRENT_USER, "ldap", "entry-uuid-1", "alice"); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.of(own)); + when(userAccountRepository.findByEmailIgnoreCase("alice@example.com")) + .thenReturn(Optional.of(new UserAccount(CURRENT_USER, "Alice", "alice@example.com", null))); + + service.bindLdapIdentity(CURRENT_USER, "alice", "secret"); + + verify(identityBindingRepository, never()).save(any()); + } + + @Test + void bind_whenEmailBelongsToAnotherAccount_throwsConflict() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice")); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.empty()); + when(userAccountRepository.findByEmailIgnoreCase("alice@example.com")) + .thenReturn(Optional.of(new UserAccount("usr_other", "Other", "alice@example.com", null))); + + assertThatThrownBy(() -> service.bindLdapIdentity(CURRENT_USER, "alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + }); + verify(identityBindingRepository, never()).save(any()); + } + + @Test + void bind_whenLdapDisabled_throwsServiceUnavailable() { + ObjectProvider emptyProvider = mock(ObjectProvider.class); + when(emptyProvider.getIfAvailable()).thenReturn(null); + LdapBindingAppService disabledService = + new LdapBindingAppService(emptyProvider, identityBindingRepository, userAccountRepository); + + assertThatThrownBy(() -> disabledService.bindLdapIdentity(CURRENT_USER, "alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.disabled"); + }); + } +} diff --git a/server/skillhub-app/src/test/resources/ldap/modify-dave.ldif b/server/skillhub-app/src/test/resources/ldap/modify-dave.ldif new file mode 100644 index 000000000..935d42ca7 --- /dev/null +++ b/server/skillhub-app/src/test/resources/ldap/modify-dave.ldif @@ -0,0 +1,4 @@ +dn: uid=dave,dc=example,dc=org +changetype: modify +replace: displayName +displayName: Dave D. Miller diff --git a/server/skillhub-app/src/test/resources/ldap/seed-users.ldif b/server/skillhub-app/src/test/resources/ldap/seed-users.ldif new file mode 100644 index 000000000..50f668360 --- /dev/null +++ b/server/skillhub-app/src/test/resources/ldap/seed-users.ldif @@ -0,0 +1,81 @@ +dn: uid=alice,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: alice +cn: Alice Smith +sn: Smith +displayName: Alice Smith +mail: alice@example.com +userPassword: alice123 + +dn: uid=bob,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: bob +cn: Bob Jones +sn: Jones +userPassword: bob123 + +dn: uid=carol,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: carol +cn: Carol King +sn: King +displayName: Carol King +mail: carol@example.com +userPassword: carol123 + +dn: uid=dave,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: dave +cn: Dave Miller +sn: Miller +displayName: Dave Miller +mail: dave@example.com +userPassword: dave123 + +dn: uid=eve,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: eve +cn: Eve Adams +sn: Adams +displayName: Eve Adams +mail: shared@example.com +userPassword: eve123 + +dn: uid=frank,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: frank +cn: Frank Brown +sn: Brown +displayName: Frank Brown +mail: shared@example.com +userPassword: frank123 + +dn: uid=grace,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: grace +cn: Grace Smith +sn: Smith +displayName: Grace Smith +mail: grace@example.com +userPassword: grace123 diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 3bee6a9e9..4de33034d 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -44,6 +44,10 @@ spring-boot-configuration-processor true + + org.springframework.ldap + spring-ldap-core + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java new file mode 100644 index 000000000..000a1d475 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.auth.config; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.ldap.core.support.LdapContextSource; + +/** + * LDAP connection configuration, created only when {@code skillhub.ldap.enabled=true}. + * + * The context source is the single place that maps {@link LdapProperties} onto JNDI + * connection settings (URL, base, bind credentials, connect/read timeouts). A custom + * trust store for LDAPS is installed JVM-wide before the context starts by + * {@link LdapTrustStoreEnvironmentPostProcessor} (the JDK LDAP provider has no per-context + * trust-store injection point). + */ +@Configuration +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAutoConfiguration { + + @Bean + public LdapContextSource ldapContextSource(LdapProperties ldapProperties) { + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(ldapProperties.getUrl()); + // The search base is applied explicitly by LdapAuthService (user-search-base + base), so + // the context source must stay root-relative; otherwise the base would be applied twice + // and every search would fail. + if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) { + contextSource.setUserDn(ldapProperties.getUsername()); + contextSource.setPassword(ldapProperties.getPassword()); + } + contextSource.setPooled(false); + + Map baseEnvironment = new HashMap<>(); + baseEnvironment.put("com.sun.jndi.ldap.connect.timeout", + String.valueOf(ldapProperties.getConnectTimeoutMillis())); + baseEnvironment.put("com.sun.jndi.ldap.read.timeout", + String.valueOf(ldapProperties.getReadTimeoutMillis())); + contextSource.setBaseEnvironmentProperties(baseEnvironment); + + contextSource.afterPropertiesSet(); + return contextSource; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java new file mode 100644 index 000000000..a9ff8d33d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java @@ -0,0 +1,246 @@ +package com.iflytek.skillhub.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +/** + * Configuration properties for LDAP authentication. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.ldap") +public class LdapProperties { + + /** + * Whether LDAP authentication is enabled. + */ + private boolean enabled = false; + + /** + * LDAP server URL (e.g., ldap://localhost:389). + */ + private String url; + + /** + * Base DN for LDAP searches (e.g., dc=example,dc=com). + */ + private String base; + + /** + * DN of the user to bind for LDAP searches. + */ + private String username; + + /** + * Password for the LDAP bind user. + */ + private String password; + + /** + * LDAP attribute to use for username lookup (e.g., uid, sAMAccountName). + */ + private String userSearchAttribute = "uid"; + + /** + * Search base for user lookup (relative to base). + */ + private String userSearchBase = ""; + + /** + * Stable directory identifier attribute used as the LDAP identity subject. + * OpenLDAP uses "entryUUID", Active Directory uses "objectGUID". + */ + private String subjectAttribute = "entryUUID"; + + /** + * LDAP attribute mapped to the local display name. + */ + private String displayNameAttribute = "displayName"; + + /** + * Fallback LDAP attribute for the display name when the primary + * {@link #displayNameAttribute} is absent or empty. Defaults to {@code cn} + * (common name), the conventional fallback for directories that do not + * populate a dedicated display name. + */ + private String displayNameFallbackAttribute = "cn"; + + /** + * LDAP attribute mapped to the local email. + */ + private String emailAttribute = "mail"; + + /** + * LDAP connection timeout in milliseconds. + */ + private int connectTimeoutMillis = 5000; + + /** + * LDAP read timeout in milliseconds. + */ + private int readTimeoutMillis = 10000; + + /** + * Path to a custom trust store used for LDAPS certificate validation. When empty, + * the JVM default trust store is used. Configure this for directories signed by + * internal/self-signed CAs. + */ + private String tlsTrustStorePath = ""; + + /** + * Password for the custom trust store. Only used when {@link #tlsTrustStorePath} is set. + */ + private String tlsTrustStorePassword = ""; + + /** + * Trust store type (JKS, PKCS12). Defaults to JKS for compatibility. + */ + private String tlsTrustStoreType = "JKS"; + + @PostConstruct + void validate() { + if (!enabled) { + return; + } + if (url == null || url.isBlank()) { + throw new IllegalStateException("skillhub.ldap.url must be configured when LDAP is enabled"); + } + if (base == null || base.isBlank()) { + throw new IllegalStateException("skillhub.ldap.base must be configured when LDAP is enabled"); + } + if (connectTimeoutMillis <= 0) { + throw new IllegalStateException("skillhub.ldap.connect-timeout-millis must be a positive number"); + } + if (readTimeoutMillis <= 0) { + throw new IllegalStateException("skillhub.ldap.read-timeout-millis must be a positive number"); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getBase() { + return base; + } + + public void setBase(String base) { + this.base = base; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getUserSearchAttribute() { + return userSearchAttribute; + } + + public void setUserSearchAttribute(String userSearchAttribute) { + this.userSearchAttribute = userSearchAttribute; + } + + public String getUserSearchBase() { + return userSearchBase; + } + + public void setUserSearchBase(String userSearchBase) { + this.userSearchBase = userSearchBase; + } + + public String getSubjectAttribute() { + return subjectAttribute; + } + + public void setSubjectAttribute(String subjectAttribute) { + this.subjectAttribute = subjectAttribute; + } + + public String getDisplayNameAttribute() { + return displayNameAttribute; + } + + public void setDisplayNameAttribute(String displayNameAttribute) { + this.displayNameAttribute = displayNameAttribute; + } + + public String getDisplayNameFallbackAttribute() { + return displayNameFallbackAttribute; + } + + public void setDisplayNameFallbackAttribute(String displayNameFallbackAttribute) { + this.displayNameFallbackAttribute = displayNameFallbackAttribute; + } + + public String getEmailAttribute() { + return emailAttribute; + } + + public void setEmailAttribute(String emailAttribute) { + this.emailAttribute = emailAttribute; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public int getReadTimeoutMillis() { + return readTimeoutMillis; + } + + public void setReadTimeoutMillis(int readTimeoutMillis) { + this.readTimeoutMillis = readTimeoutMillis; + } + + public String getTlsTrustStorePath() { + return tlsTrustStorePath; + } + + public void setTlsTrustStorePath(String tlsTrustStorePath) { + this.tlsTrustStorePath = tlsTrustStorePath; + } + + public String getTlsTrustStorePassword() { + return tlsTrustStorePassword; + } + + public void setTlsTrustStorePassword(String tlsTrustStorePassword) { + this.tlsTrustStorePassword = tlsTrustStorePassword; + } + + public String getTlsTrustStoreType() { + return tlsTrustStoreType; + } + + public void setTlsTrustStoreType(String tlsTrustStoreType) { + this.tlsTrustStoreType = tlsTrustStoreType; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java new file mode 100644 index 000000000..212df2248 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.auth.config; + +import com.iflytek.skillhub.auth.ldap.LdapTrustStoreInstaller; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Installs the custom LDAPS trust store before the Spring context (and therefore any TLS + * connection) is created. Runs only when {@code skillhub.ldap.enabled=true} and + * {@code skillhub.ldap.tls-trust-store} is set. + */ +public class LdapTrustStoreEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (!Boolean.parseBoolean(environment.getProperty("skillhub.ldap.enabled", "false"))) { + return; + } + String path = environment.getProperty("skillhub.ldap.tls-trust-store", ""); + if (path == null || path.isBlank()) { + return; + } + String password = environment.getProperty("skillhub.ldap.tls-trust-store-password", ""); + String type = environment.getProperty("skillhub.ldap.tls-trust-store-type", "JKS"); + LdapTrustStoreInstaller.install(path, password, type); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java new file mode 100644 index 000000000..33536c4d0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java @@ -0,0 +1,780 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import jakarta.persistence.EntityManager; +import javax.net.ssl.SSLException; +import javax.naming.AuthenticationException; +import javax.naming.CommunicationException; +import javax.naming.NamingException; +import java.util.regex.Pattern; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.LdapName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.TransactionDefinition; + +/** + * Handles LDAP authentication for enterprise directory integration. + * + * Identity is anchored on a stable directory identifier (entryUUID/objectGUID) via + * {@link IdentityBinding} (provider="ldap"), not on the user's email. This prevents + * silent account merging when an LDAP user's email collides with an existing + * local/OAuth account, and avoids duplicate accounts for email-less users. + */ +@Service +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAuthService { + + /** + * An LDAP identity resolved and verified against the directory without provisioning a local + * account. Used by the explicit bind flow to attach an LDAP identity to an existing account. + */ + public record LdapIdentity(String username, String subject, String email, String displayName) { + } + + private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class); + private static final String LDAP_PROVIDER = "ldap"; + // Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters. + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{3,64}$"); + // LDAP attribute names only allow ASCII letters, digits, and hyphens. + private static final Pattern ATTRIBUTE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$"); + + /** + * Signals that a concurrent first login wrote the same LDAP subject binding first. The + * provisioning (sub-)transaction has already been rolled back cleanly; callers must + * re-resolve the identity by subject in a fresh transaction. + */ + private static final class LdapBindingRaceException extends RuntimeException { + LdapBindingRaceException(Throwable cause) { + super(cause); + } + } + + private final LdapProperties ldapProperties; + private final LdapContextSource ldapContextSource; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final IdentityBindingRepository identityBindingRepository; + private final EntityManager entityManager; + /** + * Programmatic REQUIRES_NEW template for account/binding provisioning. A separate physical + * transaction is required so a failed concurrent insert (which marks its transaction + * rollback-only) can be rolled back cleanly and the identity re-resolved in a fresh + * transaction. Spring's annotation-driven propagation cannot be used here because the + * provisioning methods are invoked internally (self-invocation bypasses the proxy). + */ + private final TransactionTemplate ldapProvisioningTx; + + /** + * Striped monitors that serialize first-login email-collision checks across concurrent + * requests in this JVM. {@code user_account.email} intentionally has no UNIQUE constraint + * (other identity flows may share an email), so the application-level check-and-insert for + * the same email must be serialized to stop two distinct LDAP subjects from provisioning two + * accounts with the same email at the same time. A fixed stripe count keeps memory constant. + * Multi-instance deployments need an equivalent cross-node lock (database advisory lock or a + * unique index with the other flows migrated) on top of this. + */ + private final Object[] emailLockStripes = new Object[64]; + + public LdapAuthService(LdapProperties ldapProperties, + LdapContextSource ldapContextSource, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, + IdentityBindingRepository identityBindingRepository, + EntityManager entityManager, + PlatformTransactionManager transactionManager) { + this.ldapProperties = ldapProperties; + this.ldapContextSource = ldapContextSource; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.identityBindingRepository = identityBindingRepository; + this.entityManager = entityManager; + this.ldapProvisioningTx = new TransactionTemplate(transactionManager); + this.ldapProvisioningTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + for (int i = 0; i < emailLockStripes.length; i++) { + emailLockStripes[i] = new Object(); + } + } + + /** + * Authenticates a user against the LDAP server. + * If the user doesn't exist in the local database, creates a new user based on LDAP attributes. + * + * @param username the username + * @param password the password + * @return PlatformPrincipal if authentication succeeds + * @throws AuthFlowException if authentication fails + */ + public PlatformPrincipal login(String username, String password) { + log.debug("Starting LDAP authentication for username: {}", username); + + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + + // Validate all LDAP attribute names that flow into JNDI calls to prevent filter/attribute + // injection via operator misconfiguration. These names are operator-controlled, not user input. + validateAttributeNames(); + + log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}", + safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase(), + ldapProperties.getUserSearchBase(), + ldapProperties.getUserSearchAttribute()); + + // First, try to find the user in LDAP and authenticate + Attributes userAttributes = authenticateAndFetch(username, password); + + // Find or create local user account anchored on the stable LDAP subject. Account and + // binding creation run in their own (sub-)transaction; a concurrent first login for the + // same subject is recovered by re-resolving the identity in a fresh transaction. When the + // directory entry carries an email, the check-and-insert of that email is additionally + // serialized per email (striped monitor) so two different subjects cannot both pass the + // collision check and provision duplicate accounts simultaneously. + log.debug("Finding or creating local user account for username: {}", username); + String email = getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()); + UserAccount user = (email == null || email.isEmpty()) + ? provisionUser(username, userAttributes) + : provisionUserSerializedByEmail(username, userAttributes, email); + + // Check if user can login (status check) + log.debug("Checking user status for user: {}, status: {}", username, user.getStatus()); + ensureUserCanLogin(user); + + log.debug("LDAP authentication successful for username: {}", username); + return buildPrincipal(user); + } + + /** + * Resolves and verifies an LDAP identity (search, bind, attribute read) without provisioning + * a local account or identity binding. Serves the explicit account-binding flow: the caller + * has already authenticated (or is being authenticated) and uses the LDAP credentials to + * prove ownership of the directory identity. + */ + public LdapIdentity resolveIdentity(String username, String password) { + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + validateAttributeNames(); + Attributes userAttributes = authenticateAndFetch(username, password); + String subject = getAttributeValue(userAttributes, ldapProperties.getSubjectAttribute()); + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + return new LdapIdentity( + username, + subject, + getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()), + resolveDisplayName(userAttributes, username) + ); + } + + /** + * Finds the user entry, verifies the password via a directory bind, and fetches the entry + * attributes. All errors are classified with the same semantics for login and binding. + */ + private Attributes authenticateAndFetch(String username, String password) { + String userDn = findUserDn(username); + log.debug("LDAP findUserDn result for {}: {}", username, userDn != null); + + if (userDn == null) { + log.warn("User {} not found in LDAP directory", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.userNotFound"); + } + + log.debug("Attempting LDAP bind for user DN: {}", userDn); + boolean authenticated = authenticateLdap(userDn, password); + log.debug("LDAP bind result for {}: {}", username, authenticated); + + if (!authenticated) { + log.warn("LDAP authentication failed for username: {}", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } + + log.debug("Fetching user attributes from LDAP for DN: {}", userDn); + Attributes userAttributes = getUserAttributes(userDn); + if (userAttributes == null) { + log.error("Failed to fetch user attributes from LDAP for DN: {}", userDn); + // Bind already succeeded, so the credentials are valid. This is a transient directory + // failure; surface a 503 with the directoryUnavailable message instead of masking it + // as a 401 "invalid credentials" (which would mislead the user about the password). + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } + return userAttributes; + } + + /** + * Provisions the local account and identity binding in a REQUIRES_NEW sub-transaction, + * recovering from a concurrent same-subject first login by re-resolving the existing account. + */ + private UserAccount provisionUser(String username, Attributes userAttributes) { + try { + return ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes)); + } catch (LdapBindingRaceException e) { + log.warn("Concurrent first login detected for LDAP subject of username {}; resolving existing account", username); + return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username)); + } + } + + /** + * Serializes the check-and-insert of a non-empty LDAP email so concurrent first logins from + * different subjects sharing one email cannot both provision an account. The monitor is held + * until the provisioning sub-transaction commits, so the second caller observes the first + * account and receives the regular 409 email-conflict result. + */ + private UserAccount provisionUserSerializedByEmail(String username, Attributes userAttributes, String email) { + Object stripe = emailLockStripes[Math.floorMod(email.toLowerCase(Locale.ROOT).hashCode(), emailLockStripes.length)]; + synchronized (stripe) { + return provisionUser(username, userAttributes); + } + } + + /** + * Ensures the user account status allows login. + */ + private void ensureUserCanLogin(UserAccount user) { + if (user.getStatus() == UserStatus.DISABLED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); + } + if (user.getStatus() == UserStatus.PENDING) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); + } + if (user.getStatus() == UserStatus.MERGED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + } + } + + /** + * Finds the DN (Distinguished Name) of a user in LDAP. + */ + private String findUserDn(String username) { + // LDAP injection prevention: validate username before search + if (!isValidUsername(username)) { + log.warn("Invalid username format for LDAP search: {}", username); + return null; + } + String searchAttr = ldapProperties.getUserSearchAttribute(); + + DirContext ctx = null; + javax.naming.NamingEnumeration results = null; + try { + ctx = createLdapContext(); + String searchFilter = "(" + searchAttr + "={0})"; + String searchBase = ldapProperties.getUserSearchBase().isEmpty() + ? ldapProperties.getBase() + : ldapProperties.getUserSearchBase() + "," + ldapProperties.getBase(); + + SearchControls searchControls = new SearchControls(); + searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE); + searchControls.setReturningAttributes(new String[0]); + + results = ctx.search(searchBase, searchFilter, new Object[]{username}, searchControls); + + if (results.hasMore()) { + SearchResult result = results.next(); + return result.getNameInNamespace(); + } + return null; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (AuthenticationException e) { + log.warn("LDAP bind authentication failed while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } catch (NamingException e) { + log.warn("LDAP naming error while searching for user {}: {}", username, e.getMessage()); + return null; + } finally { + // Close NamingEnumeration to prevent resource leaks + if (results != null) { + try { + results.close(); + } catch (Exception e) { + log.warn("Failed to close LDAP search results", e); + } + } + closeContext(ctx); + } + } + + /** + * Creates an LDAP context for searching. + */ + private DirContext createLdapContext() throws NamingException { + // Bind-account context for directory searches/reads; delegates to the shared factory. + return createLdapContext(null, null); + } + + /** + * Creates an LDAP context authenticated with the given principal/credentials. When both are + {@code null}, falls back to the configured bind account (or anonymous if none is set). This is + the single place that obtains contexts, so connection/timeout/TLS settings configured on the + shared {@link LdapContextSource} stay consistent across search, bind, and attribute-read + operations. + */ + private DirContext createLdapContext(String principal, String credentials) throws NamingException { + try { + // Explicit principal/credentials take precedence; otherwise use the configured bind account. + String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername(); + String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword(); + if (bindPrincipal != null && !bindPrincipal.isEmpty()) { + return ldapContextSource.getContext(bindPrincipal, bindCredentials); + } + // No bind account configured: anonymous read context for directory searches/reads. + return ldapContextSource.getReadOnlyContext(); + } catch (org.springframework.ldap.CommunicationException e) { + // Spring LDAP wraps JNDI failures into unchecked org.springframework.ldap.* exceptions; + // translate them back so the callers' javax.naming.* classification stays unchanged. + throw (javax.naming.CommunicationException) new javax.naming.CommunicationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.AuthenticationException e) { + throw (javax.naming.AuthenticationException) new javax.naming.AuthenticationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.NameNotFoundException e) { + throw (javax.naming.NameNotFoundException) new javax.naming.NameNotFoundException(e.getMessage()) + .initCause(e); + } + } + + /** + * Closes an LDAP context. + */ + private void closeContext(DirContext ctx) { + if (ctx != null) { + try { + ctx.close(); + } catch (NamingException e) { + // Ignore + } + } + } + + /** + * Safely extracts host:port from LDAP URL for logging, avoiding credential exposure. + * Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host + */ + public static String safeLogHost(String url) { + if (url == null || url.isEmpty()) { + return ""; + } + try { + String withoutProtocol = url.replaceFirst("^ldaps?://", ""); + int atIndex = withoutProtocol.indexOf('@'); + if (atIndex > 0) { + withoutProtocol = withoutProtocol.substring(atIndex + 1); + } + // IPv6 literal: ldap://[::1]:389 — keep the bracketed address plus port + int bracketEnd = withoutProtocol.indexOf(']'); + if (bracketEnd > 0) { + return withoutProtocol.substring(0, Math.min(bracketEnd + 1, withoutProtocol.length())); + } + int slashIndex = withoutProtocol.indexOf('/'); + String hostPort = slashIndex > 0 ? withoutProtocol.substring(0, slashIndex) : withoutProtocol; + return hostPort; + } catch (Exception e) { + return "[url-parse-error]"; + } + } + + /** + * Returns whether the throwable chain indicates a TLS/trust failure (LDAPS handshake or + * certificate validation). JNDI wraps TLS failures in a {@link CommunicationException}, so + * without this check a certificate problem is indistinguishable from an unreachable directory. + */ + static boolean isTlsFailure(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof SSLException || c instanceof CertificateException + || c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) { + return true; + } + } + return false; + } + + /** + * Authenticates a user against the LDAP server using their DN and password. + */ + private boolean authenticateLdap(String userDn, String password) { + // Reject null/empty passwords explicitly: a null value would otherwise reach + // Hashtable.put (NPE -> 500), and an empty password could be accepted by directories + // that allow anonymous/weak binds. Treat both as credential failures (401). + if (password == null || password.isEmpty()) { + log.debug("LDAP bind rejected: empty password for DN {}", userDn); + return false; + } + DirContext ctx = null; + try { + // Bind as the authenticating user to verify credentials (shared factory handles env). + ctx = createLdapContext(userDn, password); + return true; + } catch (AuthenticationException e) { + // Invalid credentials — expected, return false to signal auth failure + return false; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (NamingException e) { + log.warn("LDAP naming error while authenticating DN {}: {}", userDn, e.getMessage()); + return false; + } finally { + closeContext(ctx); + } + } + + /** + * Retrieves user attributes from LDAP. + */ + private Attributes getUserAttributes(String userDn) { + DirContext ctx = null; + try { + // Read attributes via the bind-account context (shared factory handles env/timeout). + ctx = createLdapContext(); + Attributes attrs; + try { + // Request user attributes ("*") and operational attributes ("+") so stable + // directory identifiers (OpenLDAP entryUUID, AD objectGUID) are included in the + // response. Without the explicit request, operational attributes are omitted and + // the subject key would be null on every login. + attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"}); + } catch (NamingException e) { + // Some directories reject the "*"/"+" attribute-request syntax; fall back to the + // default attribute set for protocol/request-level failures only. Connection and + // authentication failures must not trigger a retry — the outer catch blocks + // classify them as TLS error vs directory-unavailable vs bind failure. + if (e instanceof CommunicationException || e instanceof AuthenticationException) { + throw e; + } + attrs = ctx.getAttributes(new LdapName(userDn)); + } + return attrs; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (Exception e) { + log.warn("Failed to fetch user attributes from LDAP for DN {}: {}", userDn, e.getMessage()); + return null; + } finally { + closeContext(ctx); + } + } + + /** + * Finds an existing LDAP user or creates a new one based on LDAP attributes. + * + * Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID) + * via {@link IdentityBinding}, not on the user's email. This prevents: + * + * Silent account merging when an LDAP email collides with a local/OAuth account + * Duplicate accounts for email-less LDAP users on repeated logins + * + */ + UserAccount findOrCreateLdapUser(String username, Attributes attributes) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + // Bind already succeeded. The directory entry lacks the configured subject attribute, + // which is a configuration/schema issue the user cannot fix. Surface a 503 with the + // invalidConfiguration message instead of a 401 that would look like a wrong password. + // Operators can locate the cause via the log line above. + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + + // Anchor on the stable LDAP subject: an existing binding means this identity is already known. + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + + if (binding != null) { + // Returning user — refresh attributes from the directory on each login. + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // The bound account no longer exists (e.g. deleted by an administrator). The stale + // binding would otherwise block this subject forever with a 500. Remove it and fall + // through to the first-login provisioning path, which creates a fresh account for + // the directory identity. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + } + + // First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth + // account that happens to share the same email — that would be a privilege escalation. + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(); + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null) { + // The email already belongs to another account. Refuse to silently create a second + // account (two distinct LDAP subjects sharing one email would both map to it, and + // user_account.email has no UNIQUE constraint, so this would otherwise happen + // silently). This covers both cross-provider collisions and the same-issuer case + // (a different LDAP subject under the same email). If an entry's stable subject + // legitimately changes (e.g. after an AD objectGUID migration), an administrator + // must remove the stale binding before the new subject can log in. + log.warn("LDAP user {} email {} collides with an existing account {} (subject differs); refusing to create a duplicate account", + username, normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + + // Create a new user account. The placeholder email is only used to satisfy the NOT NULL + // constraint and never serves as an identity key. + String normalizedEmail = email != null && !email.isEmpty() + ? email.toLowerCase() + : LDAP_PROVIDER + ":" + username + "@internal"; + + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + displayName, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + user = userAccountRepository.save(user); + globalNamespaceMembershipService.ensureMember(user.getId()); + + try { + IdentityBinding newBinding = new IdentityBinding(user.getId(), LDAP_PROVIDER, subject, username); + // saveAndFlush surfaces the unique (provider_code, subject) constraint immediately, so + // a concurrent first login for the same subject is detected here and the whole + // sub-transaction (account + membership + binding) is rolled back. + identityBindingRepository.saveAndFlush(newBinding); + } catch (DataIntegrityViolationException e) { + // A concurrent first login for the same subject committed its binding first. Clear the + // failed persist state (otherwise Hibernate throws AssertionFailure while rolling back + // the session), then signal the race so the identity is re-resolved in a fresh + // transaction. The insert failure already marked this sub-transaction rollback-only, + // so it can never be committed with the re-resolved state. + entityManager.clear(); + throw new LdapBindingRaceException(e); + } + + return user; + } + + /** + * Re-resolves a returning LDAP user in a fresh transaction after a concurrent first-login + * race. Called only when the racing transaction has committed its binding, so the lookup is + * guaranteed to hit the existing account. + */ + UserAccount resolveReturningUser(Attributes attributes, String username) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + if (binding == null) { + // The racing transaction rolled back after all (rare), or the binding was cleaned up + // concurrently. Fall back to the regular provisioning path. + throw new LdapBindingRaceException(new IllegalStateException("No binding found after race for " + subject)); + } + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // Stale binding for a deleted account: remove it and retry provisioning from scratch. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + throw new LdapBindingRaceException(new IllegalStateException("Stale binding removed for " + subject)); + } + + private String resolveDisplayName(Attributes attributes, String username) { + String displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameAttribute()); + if (displayName == null || displayName.isEmpty()) { + displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameFallbackAttribute()); + } + if (displayName == null || displayName.isEmpty()) { + displayName = username; + } + return displayName; + } + + private void updateFromAttributes(UserAccount user, String displayName, String email) { + if (displayName != null && !displayName.isEmpty()) { + user.setDisplayName(displayName); + } + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(Locale.ROOT); + // A bound user may update their own email, but must never silently adopt an email + // that already belongs to a different account (same rule as first login). + if (user.getEmail() == null || !user.getEmail().equalsIgnoreCase(normalizedEmail)) { + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null && !existingByEmail.getId().equals(user.getId())) { + log.warn("LDAP user {} email {} collides with another account {} on refresh; refusing to update", + user.getDisplayName(), normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + user.setEmail(normalizedEmail); + } + } + + /** + * Gets a string attribute value from LDAP attributes. + */ + private String getAttributeValue(Attributes attributes, String attrName) { + try { + Attribute attr = attributes.get(attrName); + if (attr != null && attr.get() != null) { + Object value = attr.get(); + // Active Directory stores stable identifiers such as objectGUID / objectSid as + // binary (OctetString). JNDI returns these as byte[], whose toString() yields an + // unstable "[B@" — making every login look like a new identity. + // Convert binary values to a stable hexadecimal representation (mixed-endian GUID + // layout for 16-byte values) so the subject key remains stable across logins. + if (value instanceof byte[] bytes) { + return toStableGuidString(bytes); + } + return value.toString(); + } + } catch (Exception e) { + // Ignore and return null + } + return null; + } + + /** + * Converts a binary attribute value into a stable string suitable for use as an identity + * subject. Active Directory objectGUID is a 16-byte mixed-endian GUID; rearranging it into + * the canonical 8-4-4-4-12 hex layout yields the same string .NET/AD display, which is stable + * across JVM restarts and connections. Non-16-byte binaries fall back to plain hex so the + * value is still deterministic. + */ + private static String toStableGuidString(byte[] bytes) { + if (bytes.length == 16) { + // AD objectGUID layout: little-endian uint32, little-endian uint16 x2, big-endian rest. + return String.format("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[3] & 0xff, bytes[2] & 0xff, bytes[1] & 0xff, bytes[0] & 0xff, + bytes[5] & 0xff, bytes[4] & 0xff, + bytes[7] & 0xff, bytes[6] & 0xff, + bytes[8] & 0xff, bytes[9] & 0xff, + bytes[10] & 0xff, bytes[11] & 0xff, bytes[12] & 0xff, + bytes[13] & 0xff, bytes[14] & 0xff, bytes[15] & 0xff); + } + // Non-GUID binary attribute: deterministic plain hex so the value stays stable. + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * Builds a PlatformPrincipal from a UserAccount. + */ + private PlatformPrincipal buildPrincipal(UserAccount user) { + Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + "ldap", + roles + ); + } + + /** + * Validates username to prevent LDAP injection attacks. + * Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), + * 3-64 characters. + */ + private boolean isValidUsername(String username) { + if (username == null || username.isEmpty()) { + return false; + } + return USERNAME_PATTERN.matcher(username).matches(); + } + + /** + * Validates all operator-configured LDAP attribute names that flow into JNDI calls. + * Rejects names that are null or fail the LDAP attribute-name pattern, preventing + * filter/attribute injection via misconfiguration before any directory call is made. + */ + private void validateAttributeNames() { + String[] attrNames = { + ldapProperties.getUserSearchAttribute(), + ldapProperties.getSubjectAttribute(), + ldapProperties.getDisplayNameAttribute(), + ldapProperties.getDisplayNameFallbackAttribute(), + ldapProperties.getEmailAttribute() + }; + for (String name : attrNames) { + if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) { + log.error("Invalid LDAP attribute name configured: {}", name); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.invalidConfiguration"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java new file mode 100644 index 000000000..9f7e5ec4d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.ldap; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import java.util.UUID; + +/** + * Installs a custom trust store for LDAPS by merging it with the JVM's default trust store and + * pointing the {@code javax.net.ssl.trustStore*} system properties at the merged store. + * + * This is the only reliable injection point for LDAPS trust in a JNDI-based client: the JDK + * LDAP provider builds its sockets from the JVM-wide SSL configuration and ignores both + * {@code javax.net.ssl.trustStore*} context-environment entries and the + * {@code java.naming.ldap.factory.socket} property (verified against the JDK 21 implementation), + * and Spring LDAP 3.x no longer offers a per-context socket factory. Because the JSSE default + * SSLContext is cached on first use, the installer must run before any TLS connection is made; + * it is invoked from an {@code EnvironmentPostProcessor} during application startup. + * + * Merging (rather than replacing) keeps public-CA connectivity intact: the resulting store + * contains the JVM defaults plus the configured internal CA. + */ +public final class LdapTrustStoreInstaller { + + private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit"; + + private LdapTrustStoreInstaller() { + } + + /** + * Merges the configured custom trust store into the JVM default trust store and installs the + * result through the {@code javax.net.ssl.trustStore*} system properties. + * + * @param customPath the custom trust store path + * @param customPassword the custom trust store password (may be empty) + * @param customType the custom trust store type (JKS, PKCS12, ...) + */ + public static void install(String customPath, String customPassword, String customType) { + try { + KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType()); + merged.load(null, null); + copyCertificateEntries(loadDefaultTrustStore(), merged); + copyCertificateEntries(loadCustomTrustStore(customPath, customPassword, customType), merged); + + String password = "skillhub-" + UUID.randomUUID(); + Path file = Files.createTempFile("skillhub-truststore", ".p12"); + try (OutputStream out = Files.newOutputStream(file)) { + merged.store(out, password.toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", file.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", password); + System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType()); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to install LDAPS trust store from " + customPath, e); + } + } + + private static KeyStore loadDefaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + String systemPassword = System.getProperty("javax.net.ssl.trustStorePassword", + DEFAULT_TRUSTSTORE_PASSWORD); + String systemType = System.getProperty("javax.net.ssl.trustStoreType", + KeyStore.getDefaultType()); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(systemType); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, systemPassword.toCharArray()); + } + return keyStore; + } + + private static KeyStore loadCustomTrustStore(String path, String password, String type) throws Exception { + KeyStore keyStore = KeyStore.getInstance(type); + try (InputStream in = Files.newInputStream(Path.of(path))) { + keyStore.load(in, password.toCharArray()); + } + return keyStore; + } + + private static void copyCertificateEntries(KeyStore source, KeyStore target) throws Exception { + Enumeration aliases = source.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (source.isCertificateEntry(alias)) { + target.setCertificateEntry(alias, source.getCertificate(alias)); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867b..512515617 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.local; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -16,9 +18,14 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; /** @@ -28,6 +35,8 @@ @Service public class LocalAuthService { + private static final Logger log = LoggerFactory.getLogger(LocalAuthService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; @@ -44,6 +53,15 @@ public class LocalAuthService { private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final LdapProperties ldapProperties; + private final ObjectProvider ldapAuthServiceProvider; + /** + * Transaction boundary for the local-credential login path. The {@code login} method itself + * is intentionally NOT transactional: the LDAP fallback performs directory network calls + * (up to connect+read timeout) and must not hold a database connection/transaction open + * while blocked on the directory. Only the local credential database work is wrapped. + */ + private final TransactionTemplate transactionTemplate; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -51,7 +69,10 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LdapProperties ldapProperties, + ObjectProvider ldapAuthServiceProvider, + PlatformTransactionManager transactionManager) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +80,9 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.ldapProperties = ldapProperties; + this.ldapAuthServiceProvider = ldapAuthServiceProvider; + this.transactionTemplate = new TransactionTemplate(transactionManager); } /** @@ -107,33 +131,72 @@ public PlatformPrincipal register(String username, String password, String email /** * Authenticates a local account and returns the principal snapshot used to * establish a web session. + * If the user is not found locally, falls back to LDAP authentication if enabled. */ - @Transactional public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); - LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) - .orElse(null); + LocalCredential credential = transactionTemplate.execute(status -> + credentialRepository.findByUsernameIgnoreCase(normalizedUsername).orElse(null)); if (credential == null) { + // Blur timing to prevent username enumeration passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + + // Fallback to LDAP authentication if enabled + if (ldapProperties.isEnabled()) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username); + throw invalidCredentials(); + } + log.debug("Local user not found, attempting LDAP authentication for username: {}", username); + log.debug("LDAP enabled: {}, host: {}, base: {}", + ldapProperties.isEnabled(), + LdapAuthService.safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase()); + try { + PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password); + log.debug("LDAP authentication successful for username: {}", username); + return ldapPrincipal; + } catch (AuthFlowException e) { + log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage()); + // Propagate account-state, availability, and email-conflict errors instead of + // masking them as invalid credentials, so the frontend can show the right message. + // A 409 emailConflict is only reached after a successful LDAP bind, so the + // credentials are valid; masking it as 401 would mislead the user into thinking + // their password is wrong. Only genuine credential failures (userNotFound / + // invalidCredentials) fall back to the generic response to avoid enumeration. + HttpStatus status = e.getStatus(); + if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE + || status == HttpStatus.CONFLICT) { + throw e; + } + throw invalidCredentials(); + } + } else { + log.debug("LDAP authentication is disabled, rejecting login for username: {}", username); + } + throw invalidCredentials(); } - UserAccount user = userAccountRepository.findById(credential.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + return transactionTemplate.execute(status -> { + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); - ensureUserCanLogin(user); - ensureNotLocked(credential); + ensureUserCanLogin(user); + ensureNotLocked(credential); - if (!passwordEncoder.matches(password, credential.getPasswordHash())) { - handleFailedLogin(credential); - throw invalidCredentials(); - } + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } - credential.setFailedAttempts(0); - credential.setLockedUntil(null); - credentialRepository.save(credential); - return buildPrincipal(user); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + }); } /** diff --git a/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 000000000..e5ec5e04e --- /dev/null +++ b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java new file mode 100644 index 000000000..9c8915aec --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Startup configuration validation for the LDAP properties block. + */ +class LdapPropertiesTest { + + @Test + void validate_requiresUrlAndBase_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("skillhub.ldap.url"); + } + + @Test + void validate_rejectsNonPositiveTimeouts_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://localhost:389"); + props.setBase("dc=example,dc=org"); + props.setConnectTimeoutMillis(0); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect-timeout-millis"); + } + + @Test + void validate_skipsChecks_whenDisabled() { + LdapProperties props = new LdapProperties(); + assertThatCode(props::validate).doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java new file mode 100644 index 000000000..f15892258 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java @@ -0,0 +1,358 @@ + +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import java.lang.reflect.Method; +import java.util.Optional; +import jakarta.persistence.EntityManager; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Behavior-level unit tests for {@link LdapAuthService} identity provisioning. + * + * These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + entityManager = mock(EntityManager.class); + transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + ldapContextSource, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository, + entityManager, + transactionManager); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception { + // Given — the binding points to an account that no longer exists + IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(stale)); + given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — the same subject logs in again + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — the stale binding is removed and a fresh account is provisioned + assertThat(created.getEmail()).isEqualTo(EMAIL); + assertThat(created.getId()).isNotEqualTo("usr_deleted"); + verify(identityBindingRepository).delete(stale); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration"); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception { + // Given — the LDAP subject is bound, but the directory now reports an email that already + // belongs to a different account. The refresh must refuse to adopt it (409), matching the + // first-login email-collision rule. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other)); + + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice")); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + // The user's own email must remain untouched. + assertThat(existing.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void returningUser_sameEmail_isAllowedToRefresh() throws Exception { + // Given — the directory reports the same email the bound account already owns; the + // collision lookup must exclude the user's own account. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith")); + + assertThat(result.getId()).isEqualTo(userId); + assertThat(result.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsCertPathValidatorWithoutSslException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 000000000..d64928bc8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + * Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java new file mode 100644 index 000000000..f509138a0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LdapTrustStoreInstallerTest { + + @TempDir + Path tempDir; + + private String previousTrustStore; + private String previousPassword; + private String previousType; + + @AfterEach + void restoreSystemProperties() { + restore("javax.net.ssl.trustStore", previousTrustStore); + restore("javax.net.ssl.trustStorePassword", previousPassword); + restore("javax.net.ssl.trustStoreType", previousType); + } + + @Test + void install_mergesCustomTrustStoreIntoDefaults() throws Exception { + previousTrustStore = System.getProperty("javax.net.ssl.trustStore"); + previousPassword = System.getProperty("javax.net.ssl.trustStorePassword"); + previousType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Build a custom trust store containing one certificate copied from the JVM defaults. + KeyStore defaults = defaultTrustStore(); + String sourceAlias = firstCertificateAlias(defaults); + Path custom = tempDir.resolve("custom.p12"); + KeyStore customStore = KeyStore.getInstance("PKCS12"); + customStore.load(null, null); + customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias)); + try (OutputStream out = Files.newOutputStream(custom)) { + customStore.store(out, "changeit".toCharArray()); + } + + LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12"); + + String installedPath = System.getProperty("javax.net.ssl.trustStore"); + assertThat(installedPath).isNotBlank(); + KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType")); + try (InputStream in = Files.newInputStream(Path.of(installedPath))) { + installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray()); + } + assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue(); + assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue(); + } + + @Test + void install_withMissingFile_failsFast() { + assertThatThrownBy(() -> LdapTrustStoreInstaller.install( + tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to install LDAPS trust store"); + } + + private static KeyStore defaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, "changeit".toCharArray()); + } + return keyStore; + } + + private static String firstCertificateAlias(KeyStore keyStore) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isCertificateEntry(alias)) { + return alias; + } + } + throw new IllegalStateException("default trust store has no certificate entries"); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f43446..f8ba17171 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -9,9 +9,12 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +25,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,10 +55,25 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LdapProperties ldapProperties; + + @Mock + private LdapAuthService ldapAuthService; + + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + + @Mock + private org.springframework.transaction.PlatformTransactionManager transactionManager; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any())) + .thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class)); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -62,7 +81,10 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + ldapProperties, + ldapAuthServiceProvider, + transactionManager ); } @@ -161,6 +183,7 @@ void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -247,4 +270,116 @@ void register_rejectsBlankEmail() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.notBlank"); } + + @Test + void login_withUnknownUsername_fallsBackToLdap_whenEnabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null); + ldapUser.setStatus(UserStatus.ACTIVE); + PlatformPrincipal ldapPrincipal = new PlatformPrincipal( + "usr_ldap", + "ldapuser", + "ldapuser@example.com", + null, + "ldap", + Set.of("USER") + ); + + given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal); + + // When + var principal = service.login("ldapuser", "LdapPassword123!"); + + // Then + assertThat(principal.userId()).isEqualTo("usr_ldap"); + assertThat(principal.displayName()).isEqualTo("ldapuser"); + assertThat(principal.email()).isEqualTo("ldapuser@example.com"); + verify(ldapAuthService).login("ldapuser", "LdapPassword123!"); + } + + @Test + void login_withUnknownUsername_fails_whenLdapAuthenticationFails() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "WrongPassword")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed")); + + // When & Then + assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService).login("ldapuser", "WrongPassword"); + } + + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void login_withUnknownUsername_fails_whenLdapDisabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); + + // When & Then + assertThatThrownBy(() -> service.login("localuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService, never()).login(any(), any()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..d4ef31a55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + LdapBindRequest, ReviewSkillDetail, ReviewTask, PromotionTask, @@ -444,6 +445,18 @@ export const accountApi = { }, } +export const ldapApi = { + async bindIdentity(request: LdapBindRequest): Promise { + await fetchJson('/api/v1/auth/ldap/bind', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..48bca25c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -81,6 +81,11 @@ export interface MergeInitiateRequest { secondaryIdentifier: string } +export interface LdapBindRequest { + username: string + password: string +} + export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string diff --git a/web/src/features/auth/use-ldap-bind.ts b/web/src/features/auth/use-ldap-bind.ts new file mode 100644 index 000000000..b241e42a3 --- /dev/null +++ b/web/src/features/auth/use-ldap-bind.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { ldapApi } from '@/api/client' +import type { LdapBindRequest } from '@/api/types' + +/** + * Binds the LDAP identity verified by the given directory credentials to the currently + * authenticated account. + */ +export function useLdapBind() { + return useMutation({ + mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..2876fc272 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -757,7 +757,15 @@ "confirming": "Confirming...", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "ldapBindTitle": "Bind LDAP Account", + "ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.", + "ldapUsername": "LDAP Username", + "ldapPassword": "LDAP Password", + "ldapBinding": "Binding...", + "ldapBind": "Bind", + "ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP", + "ldapBindError": "LDAP binding failed" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..0d4b7eb5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -757,7 +757,15 @@ "confirming": "确认中...", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "ldapBindTitle": "绑定 LDAP 账号", + "ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。", + "ldapUsername": "LDAP 用户名", + "ldapPassword": "LDAP 密码", + "ldapBinding": "绑定中...", + "ldapBind": "绑定", + "ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录", + "ldapBindError": "LDAP 绑定失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a9336..6f76c7582 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useLdapBind } from '@/features/auth/use-ldap-bind' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input' */ export function AccountSettingsPage() { const { t } = useTranslation() + const [ldapUsername, setLdapUsername] = useState('') + const [ldapPassword, setLdapPassword] = useState('') + const [ldapStatusMessage, setLdapStatusMessage] = useState('') const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') const [verificationToken, setVerificationToken] = useState('') @@ -21,6 +25,25 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() const confirmMutation = useConfirmAccountMerge() + const ldapBindMutation = useLdapBind() + + /** + * Binds the LDAP identity proven by the given directory credentials to the current account. + */ + async function handleLdapBind(event: React.FormEvent) { + event.preventDefault() + setLdapStatusMessage('') + try { + await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword }) + setLdapUsername('') + setLdapPassword('') + setLdapStatusMessage(t('accounts.ldapBindSuccess')) + } catch (error) { + setLdapStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'), + ) + } + } /** * Starts the merge flow and surfaces the request id plus verification token @@ -77,6 +100,40 @@ export function AccountSettingsPage() { return ( + + + {t('accounts.ldapBindTitle')} + {t('accounts.ldapBindDesc')} + + + + + {t('accounts.ldapUsername')} + setLdapUsername(event.target.value)} + /> + + + {t('accounts.ldapPassword')} + setLdapPassword(event.target.value)} + /> + + + {ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')} + + + {ldapStatusMessage ? {ldapStatusMessage} : null} + + + {t('accounts.initiateTitle')} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 5144fdf67..c30af2eea 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -57,6 +57,30 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) + it('shows the server message for 403 errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员')) + + expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员') + }) + + it('shows the server message for 5xx errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试')) + + expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试') + }) + + it('falls back to generic text for 403/5xx without a server message', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403)) + + expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden')) + }) + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca2..a743c94d9 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -68,7 +68,9 @@ export function handleApiError(error: unknown): void { } if (status === 403) { - toast.error(i18n.t('apiError.forbidden')) + // The backend may attach a specific business message (e.g. a disabled account or an + // LDAP conflict); prefer it over the generic forbidden text. + toast.error(error.serverMessage || i18n.t('apiError.forbidden')) return } @@ -78,7 +80,9 @@ export function handleApiError(error: unknown): void { } if (status >= 500) { - toast.error(i18n.t('apiError.serverError')) + // The backend returns localized operational messages for service-level failures such as + // "directory unavailable" or "TLS misconfiguration"; surface them when present. + toast.error(error.serverMessage || i18n.t('apiError.serverError')) return }
This test intentionally does not use Testcontainers: it needs no directory, and keeping it + * standalone lets it run in any environment, including ones without a Docker daemon. + */ +class LdapDirectoryUnavailableTest { + + @Test + void login_whenDirectoryUnreachable_returnsServiceUnavailable() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://127.0.0.1:" + freePort()); + props.setBase("dc=example,dc=org"); + + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(props.getUrl()); + contextSource.setPooled(false); + contextSource.afterPropertiesSet(); + + LdapAuthService svc = new LdapAuthService( + props, + contextSource, + mock(UserAccountRepository.class), + mock(UserRoleBindingRepository.class), + mock(GlobalNamespaceMembershipService.class), + mock(IdentityBindingRepository.class), + mock(EntityManager.class), + mock(PlatformTransactionManager.class)); + + assertThatThrownBy(() -> svc.login("alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.directoryUnavailable"); + }); + } + + /** + * Reserves an ephemeral port and releases it, leaving a port that is (almost certainly) + * closed for the subsequent connection attempt. + */ + private static int freePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate an ephemeral port", e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java new file mode 100644 index 000000000..90d46f904 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration coverage for "startup with LDAP disabled" required by the PR #437 review: the full + * Spring context must boot without a directory configured, the conditional {@link LdapAuthService} + * bean must be absent, and the local login fallback must degrade to invalid credentials instead of + * failing on a missing LDAP bean. + */ +@SpringBootTest(properties = "skillhub.ldap.enabled=false") +@ActiveProfiles("test") +class LdapDisabledStartupTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private LdapProperties ldapProperties; + + @Autowired + private LocalAuthService localAuthService; + + @Test + void contextStartsWithoutLdapAuthServiceBean() { + assertThat(ldapProperties.isEnabled()).isFalse(); + // The bean is created only when skillhub.ldap.enabled=true; with LDAP disabled the + // context must start without it (LocalAuthService consumes it via ObjectProvider). + assertThat(context.getBeansOfType(LdapAuthService.class)).isEmpty(); + assertThat(localAuthService).isNotNull(); + } + + @Test + void localLogin_withoutLdapBean_fallsBackToInvalidCredentials() { + assertThatThrownBy(() -> localAuthService.login("no-such-user", "wrong-password")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()) + .isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java new file mode 100644 index 000000000..873e0ce06 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java @@ -0,0 +1,420 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.service.LdapBindingAppService; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import jakarta.persistence.EntityManager; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +/** + * End-to-end LDAP coverage against a real OpenLDAP directory (Testcontainers) for the behavior + * required by PR #437 review: first-login provisioning, repeat-login identity stability, + * no-email placeholder handling, email-collision refusal, invalid credentials, attribute + * synchronization, and LDAPS TLS-failure classification. + * + *
The default {@code subject-attribute=entryUUID} is intentionally left untouched: OpenLDAP + * exposes entryUUID only as an operational attribute, which previously made every login fail + * with a 503 unless the operator changed the subject attribute. + */ +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers(disabledWithoutDocker = true) +class LdapIntegrationTest { + + private static final String BASE_DN = "dc=example,dc=org"; + private static final String BIND_DN = "cn=admin," + BASE_DN; + private static final String BIND_PASSWORD = "admin"; + + /** + * The image's baked-in TLS certificates expired in 2026, which makes any LDAPS success path + * impossible. Generate a fresh CA and server certificate with the JDK's keytool before the + * container starts, and let {@code withCopyFileToContainer} install them over the baked-in + * files. The container's entrypoint only generates certificates when the files are absent. + */ + private static final Path TLS_CERTS_DIR = prepareTlsCertificates(); + + /** + * The JDK LDAP provider resolves LDAPS trust from the JVM-wide SSL configuration and offers + * no per-connection trust-store injection point, so the test CA must be installed through the + * {@code javax.net.ssl.trustStore*} system properties before any JNDI connection (and thus + * before the JSSE default SSLContext is cached). The static initializer runs at class load, + * before the Spring context and the LDAP container are created. + */ + static { + try { + Path truststore = Files.createTempFile("ldap-truststore", ".p12"); + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (InputStream in = Files.newInputStream(TLS_CERTS_DIR.resolve("ca.crt"))) { + ks.setCertificateEntry("ldap-ca", cf.generateCertificate(in)); + } + try (OutputStream out = Files.newOutputStream(truststore)) { + ks.store(out, "changeit".toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", truststore.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", "changeit"); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + } catch (Exception e) { + throw new ExceptionInInitializerError(e); + } + } + + @Container + static final GenericContainer> LDAP = new GenericContainer<>("osixia/openldap:1.5.0") + .withEnv("LDAP_ORGANISATION", "Example Inc") + .withEnv("LDAP_DOMAIN", "example.org") + .withEnv("LDAP_ADMIN_PASSWORD", BIND_PASSWORD) + // The image defaults to olcTLSVerifyClient=demand, which requires client certificates + // during the TLS handshake. The tests exercise server-certificate validation only. + .withEnv("LDAP_TLS_VERIFY_CLIENT", "never") + .withExposedPorts(389, 636) + .withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ca.crt")), + "/container/service/slapd/assets/certs/ca.crt") + .withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.crt")), + "/container/service/slapd/assets/certs/ldap.crt") + .withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.key")), + "/container/service/slapd/assets/certs/ldap.key"); + + private static Path prepareTlsCertificates() { + try { + Path dir = Files.createTempDirectory("ldap-tls-certs"); + runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "ca", + "-dname", "CN=SkillHub Test CA", "-validity", "3650", "-keyalg", "RSA", + "-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "ca.p12", + "-storepass", "changeit", "-keypass", "changeit", + "-ext", "BasicConstraints=ca:true")); + runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "server", + "-dname", "CN=ldap.example.org", "-validity", "3650", "-keyalg", "RSA", + "-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "server.p12", + "-storepass", "changeit", "-keypass", "changeit")); + runKeytool(dir, List.of("keytool", "-certreq", "-alias", "server", + "-keystore", "server.p12", "-storepass", "changeit", "-file", "server.csr")); + runKeytool(dir, List.of("keytool", "-gencert", "-alias", "ca", + "-keystore", "ca.p12", "-storepass", "changeit", "-infile", "server.csr", + "-rfc", "-validity", "3650", + "-ext", "BasicConstraints=ca:false", + "-ext", "KeyUsage=digitalSignature,keyEncipherment", + "-ext", "ExtendedKeyUsage=serverAuth", + "-outfile", "server.crt")); + runKeytool(dir, List.of("keytool", "-exportcert", "-alias", "ca", + "-keystore", "ca.p12", "-storepass", "changeit", "-rfc", "-file", "ca.crt")); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(dir.resolve("server.p12"))) { + ks.load(in, "changeit".toCharArray()); + } + PrivateKey key = (PrivateKey) ks.getKey("server", "changeit".toCharArray()); + String pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(key.getEncoded()) + + "\n-----END PRIVATE KEY-----\n"; + Files.writeString(dir.resolve("ldap.key"), pem); + // slapd runs as the openldap user; the key must be world-readable inside the container. + Files.setPosixFilePermissions(dir.resolve("ldap.key"), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--")); + Files.copy(dir.resolve("server.crt"), dir.resolve("ldap.crt")); + return dir; + } catch (Exception e) { + throw new IllegalStateException("Failed to prepare LDAPS test certificates", e); + } + } + + private static void runKeytool(Path dir, List command) throws Exception { + Process process = new ProcessBuilder(command) + .directory(dir.toFile()) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int exit = process.waitFor(); + if (exit != 0) { + throw new IllegalStateException(command.get(0) + " failed (" + exit + "): " + output); + } + } + + @DynamicPropertySource + static void ldapProperties(DynamicPropertyRegistry registry) { + registry.add("skillhub.ldap.enabled", () -> "true"); + registry.add("skillhub.ldap.url", () -> "ldap://" + LDAP.getHost() + ":" + LDAP.getMappedPort(389)); + registry.add("skillhub.ldap.base", () -> BASE_DN); + registry.add("skillhub.ldap.username", () -> BIND_DN); + registry.add("skillhub.ldap.password", () -> BIND_PASSWORD); + registry.add("skillhub.ldap.user-search-attribute", () -> "uid"); + // subject-attribute intentionally stays at its default (entryUUID). + } + + @Autowired + private LocalAuthService localAuthService; + + @Autowired + private UserAccountRepository userAccountRepository; + + @Autowired + private IdentityBindingRepository identityBindingRepository; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private LdapBindingAppService ldapBindingAppService; + + @BeforeEach + void ensureGlobalNamespace() { + namespaceRepository.findBySlug("global") + .orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap"))); + } + + @BeforeAll + static void seedDirectory() throws Exception { + // The OpenLDAP container's certificate is issued for cn=ldap.example.org, while the test + // connects through the container's mapped address. The JNDI LDAP provider performs + // endpoint identification (hostname verification) by default, which would reject the + // address even with a trusted CA. Disable endpoint identification for this test JVM so + // the custom-truststore success path exercises certificate-chain validation only. + System.setProperty("com.sun.jndi.ldap.object.disableEndpointIdentification", "true"); + LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif"); + LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/modify-dave.ldif"), "/tmp/modify-dave.ldif"); + awaitLdapReady(); + ExecResult add = LDAP.execInContainer("ldapadd", "-x", "-H", "ldap://localhost", + "-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/seed-users.ldif"); + assertThat(add.getExitCode()) + .as("ldapadd failed: %s", add.getStdout() + add.getStderr()) + .isZero(); + } + + @AfterAll + static void restoreEndpointIdentification() { + System.clearProperty("com.sun.jndi.ldap.object.disableEndpointIdentification"); + } + + private static void awaitLdapReady() throws Exception { + long deadline = System.currentTimeMillis() + 30_000; + Exception last = null; + while (System.currentTimeMillis() < deadline) { + try { + ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost", + "-b", BASE_DN, "-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "dn"); + if (r.getExitCode() == 0) { + return; + } + last = new IllegalStateException("ldapsearch exit " + r.getExitCode() + + ": " + r.getStdout() + r.getStderr()); + } catch (Exception e) { + last = e; + } + Thread.sleep(500); + } + throw new IllegalStateException("OpenLDAP did not become ready", last); + } + + @Test + void firstLogin_withDefaultEntryUuidSubject_provisionsAccountAndBinding() throws Exception { + PlatformPrincipal principal = localAuthService.login("alice", "alice123"); + + assertThat(principal.userId()).isNotBlank(); + Optional account = userAccountRepository.findById(principal.userId()); + assertThat(account).isPresent(); + assertThat(account.get().getEmail()).isEqualTo("alice@example.com"); + assertThat(account.get().getDisplayName()).isEqualTo("Alice Smith"); + + String entryUuid = directoryEntryUuid("alice"); + Optional binding = + identityBindingRepository.findByProviderCodeAndSubject("ldap", entryUuid); + assertThat(binding).as("identity binding keyed by OpenLDAP entryUUID").isPresent(); + } + + @Test + void repeatLogin_returnsSameAccount_andDoesNotDuplicate() throws Exception { + PlatformPrincipal first = localAuthService.login("alice", "alice123"); + PlatformPrincipal second = localAuthService.login("alice", "alice123"); + + assertThat(second.userId()).isEqualTo(first.userId()); + Optional account = userAccountRepository.findByEmailIgnoreCase("alice@example.com"); + assertThat(account).isPresent(); + assertThat(account.get().getId()).isEqualTo(first.userId()); + // Exactly one binding exists for this subject; no duplicate provisioning happened. + Optional binding = + identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("alice")); + assertThat(binding).isPresent(); + assertThat(binding.get().getUserId()).isEqualTo(first.userId()); + } + + @Test + void noEmailUser_usesPlaceholderEmail_andCnFallback() { + PlatformPrincipal principal = localAuthService.login("bob", "bob123"); + + assertThat(principal.email()).isEqualTo("ldap:bob@internal"); + // bob has no displayName in the directory; the configured cn fallback must apply. + assertThat(principal.displayName()).isEqualTo("Bob Jones"); + } + + @Test + void emailCollision_returnsConflict_andDoesNotTakeOverExistingAccount() throws Exception { + UserAccount local = new UserAccount("usr_local_carol", "Carol Local", "carol@example.com", null); + userAccountRepository.save(local); + + assertThatThrownBy(() -> localAuthService.login("carol", "carol123")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + }); + + // The existing account is untouched and no LDAP binding was written. + Optional untouched = userAccountRepository.findById(local.getId()); + assertThat(untouched).isPresent(); + assertThat(untouched.get().getDisplayName()).isEqualTo("Carol Local"); + assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("carol"))) + .isEmpty(); + } + + @Test + void wrongPassword_returnsUnauthorized() { + assertThatThrownBy(() -> localAuthService.login("alice", "wrong-password")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void emptyPassword_returnsUnauthorized_notServerError() { + // Direct service-level coverage: a null password must be classified as a credential + // failure (401), never a NullPointerException/500. (The HTTP layer already rejects + // blank passwords via @NotBlank; this guards service-level callers.) + assertThatThrownBy(() -> localAuthService.login("alice", null)) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void attributeChanges_areRefreshedOnNextLogin() throws Exception { + PlatformPrincipal before = localAuthService.login("dave", "dave123"); + assertThat(before.displayName()).isEqualTo("Dave Miller"); + + ExecResult mod = LDAP.execInContainer("ldapmodify", "-x", "-H", "ldap://localhost", + "-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/modify-dave.ldif"); + assertThat(mod.getExitCode()) + .as("ldapmodify failed: %s", mod.getStdout() + mod.getStderr()) + .isZero(); + + PlatformPrincipal after = localAuthService.login("dave", "dave123"); + assertThat(after.userId()).isEqualTo(before.userId()); + assertThat(after.displayName()).isEqualTo("Dave D. Miller"); + } + + @Test + void ldaps_withCustomTrustStore_authenticatesSuccessfully() throws Exception { + // The test CA is installed JVM-wide by the static initializer (the JDK LDAP provider has + // no per-connection trust-store injection point). This test verifies the full LDAPS chain + // (search, bind, attribute read) succeeds with that trust store in place. + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldaps://" + LDAP.getHost() + ":" + LDAP.getMappedPort(636)); + props.setBase(BASE_DN); + props.setUsername(BIND_DN); + props.setPassword(BIND_PASSWORD); + + UserAccountRepository userRepo = mock(UserAccountRepository.class); + when(userRepo.findByEmailIgnoreCase(any())).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + IdentityBindingRepository bindingRepo = mock(IdentityBindingRepository.class); + when(bindingRepo.findByProviderCodeAndSubject(any(), any())).thenReturn(Optional.empty()); + + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(props.getUrl()); + contextSource.setUserDn(BIND_DN); + contextSource.setPassword(BIND_PASSWORD); + contextSource.setPooled(false); + contextSource.afterPropertiesSet(); + + LdapAuthService svc = new LdapAuthService(props, + contextSource, + userRepo, + mock(UserRoleBindingRepository.class), + mock(GlobalNamespaceMembershipService.class), + bindingRepo, + mock(EntityManager.class), + mock(PlatformTransactionManager.class)); + + PlatformPrincipal principal = svc.login("alice", "alice123"); + assertThat(principal.userId()).isNotBlank(); + assertThat(principal.email()).isEqualTo("alice@example.com"); + } + + @Test + void explicitBind_attachesLdapIdentity_thenLdapLoginResolvesToBoundAccount() throws Exception { + // Self-service binding: a local account proves ownership of the LDAP identity with the + // directory password, and subsequent LDAP logins resolve to that account. + UserAccount local = new UserAccount("usr_grace_bind", "Grace Local", "grace@example.com", null); + userAccountRepository.save(local); + + ldapBindingAppService.bindLdapIdentity(local.getId(), "grace", "grace123"); + + assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("grace"))) + .as("binding is persisted for the LDAP subject") + .isPresent() + .get() + .extracting(IdentityBinding::getUserId) + .isEqualTo(local.getId()); + + PlatformPrincipal principal = localAuthService.login("grace", "grace123"); + assertThat(principal.userId()).isEqualTo(local.getId()); + assertThat(principal.displayName()).isEqualTo("Grace Smith"); + } + + private static String directoryEntryUuid(String uid) throws Exception { + ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost", + "-b", "uid=" + uid + "," + BASE_DN, "-s", "base", + "-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "entryUUID"); + assertThat(r.getExitCode()) + .as("ldapsearch failed: %s", r.getStdout() + r.getStderr()) + .isZero(); + return r.getStdout().lines() + .filter(line -> line.startsWith("entryUUID:")) + .map(line -> line.substring("entryUUID:".length()).trim()) + .findFirst() + .orElseThrow(() -> new AssertionError("entryUUID not returned by ldapsearch")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java new file mode 100644 index 000000000..5b646e020 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java @@ -0,0 +1,126 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; +import com.iflytek.skillhub.auth.ldap.LdapAuthService.LdapIdentity; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; + +class LdapBindingAppServiceTest { + + private static final String CURRENT_USER = "usr_current"; + + private LdapAuthService ldapAuthService; + private IdentityBindingRepository identityBindingRepository; + private UserAccountRepository userAccountRepository; + private LdapBindingAppService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ldapAuthService = mock(LdapAuthService.class); + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(ldapAuthService); + identityBindingRepository = mock(IdentityBindingRepository.class); + userAccountRepository = mock(UserAccountRepository.class); + service = new LdapBindingAppService(provider, identityBindingRepository, userAccountRepository); + } + + @Test + void bind_createsBindingForCurrentAccount() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice")); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.empty()); + when(userAccountRepository.findByEmailIgnoreCase("alice@example.com")) + .thenReturn(Optional.empty()); + + service.bindLdapIdentity(CURRENT_USER, "alice", "secret"); + + verify(identityBindingRepository).save(any(IdentityBinding.class)); + } + + @Test + void bind_whenSubjectBelongsToAnotherAccount_throwsConflict() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", null, "Alice")); + IdentityBinding other = new IdentityBinding("usr_other", "ldap", "entry-uuid-1", "alice"); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.of(other)); + + assertThatThrownBy(() -> service.bindLdapIdentity(CURRENT_USER, "alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.bindingTaken"); + }); + verify(identityBindingRepository, never()).save(any()); + } + + @Test + void bind_whenSubjectAlreadyBoundToCurrentAccount_isIdempotent() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice")); + IdentityBinding own = new IdentityBinding(CURRENT_USER, "ldap", "entry-uuid-1", "alice"); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.of(own)); + when(userAccountRepository.findByEmailIgnoreCase("alice@example.com")) + .thenReturn(Optional.of(new UserAccount(CURRENT_USER, "Alice", "alice@example.com", null))); + + service.bindLdapIdentity(CURRENT_USER, "alice", "secret"); + + verify(identityBindingRepository, never()).save(any()); + } + + @Test + void bind_whenEmailBelongsToAnotherAccount_throwsConflict() { + when(ldapAuthService.resolveIdentity("alice", "secret")) + .thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice")); + when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1")) + .thenReturn(Optional.empty()); + when(userAccountRepository.findByEmailIgnoreCase("alice@example.com")) + .thenReturn(Optional.of(new UserAccount("usr_other", "Other", "alice@example.com", null))); + + assertThatThrownBy(() -> service.bindLdapIdentity(CURRENT_USER, "alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + }); + verify(identityBindingRepository, never()).save(any()); + } + + @Test + void bind_whenLdapDisabled_throwsServiceUnavailable() { + ObjectProvider emptyProvider = mock(ObjectProvider.class); + when(emptyProvider.getIfAvailable()).thenReturn(null); + LdapBindingAppService disabledService = + new LdapBindingAppService(emptyProvider, identityBindingRepository, userAccountRepository); + + assertThatThrownBy(() -> disabledService.bindLdapIdentity(CURRENT_USER, "alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.disabled"); + }); + } +} diff --git a/server/skillhub-app/src/test/resources/ldap/modify-dave.ldif b/server/skillhub-app/src/test/resources/ldap/modify-dave.ldif new file mode 100644 index 000000000..935d42ca7 --- /dev/null +++ b/server/skillhub-app/src/test/resources/ldap/modify-dave.ldif @@ -0,0 +1,4 @@ +dn: uid=dave,dc=example,dc=org +changetype: modify +replace: displayName +displayName: Dave D. Miller diff --git a/server/skillhub-app/src/test/resources/ldap/seed-users.ldif b/server/skillhub-app/src/test/resources/ldap/seed-users.ldif new file mode 100644 index 000000000..50f668360 --- /dev/null +++ b/server/skillhub-app/src/test/resources/ldap/seed-users.ldif @@ -0,0 +1,81 @@ +dn: uid=alice,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: alice +cn: Alice Smith +sn: Smith +displayName: Alice Smith +mail: alice@example.com +userPassword: alice123 + +dn: uid=bob,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: bob +cn: Bob Jones +sn: Jones +userPassword: bob123 + +dn: uid=carol,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: carol +cn: Carol King +sn: King +displayName: Carol King +mail: carol@example.com +userPassword: carol123 + +dn: uid=dave,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: dave +cn: Dave Miller +sn: Miller +displayName: Dave Miller +mail: dave@example.com +userPassword: dave123 + +dn: uid=eve,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: eve +cn: Eve Adams +sn: Adams +displayName: Eve Adams +mail: shared@example.com +userPassword: eve123 + +dn: uid=frank,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: frank +cn: Frank Brown +sn: Brown +displayName: Frank Brown +mail: shared@example.com +userPassword: frank123 + +dn: uid=grace,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: grace +cn: Grace Smith +sn: Smith +displayName: Grace Smith +mail: grace@example.com +userPassword: grace123 diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 3bee6a9e9..4de33034d 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -44,6 +44,10 @@ spring-boot-configuration-processor true + + org.springframework.ldap + spring-ldap-core + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java new file mode 100644 index 000000000..000a1d475 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.auth.config; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.ldap.core.support.LdapContextSource; + +/** + * LDAP connection configuration, created only when {@code skillhub.ldap.enabled=true}. + * + * The context source is the single place that maps {@link LdapProperties} onto JNDI + * connection settings (URL, base, bind credentials, connect/read timeouts). A custom + * trust store for LDAPS is installed JVM-wide before the context starts by + * {@link LdapTrustStoreEnvironmentPostProcessor} (the JDK LDAP provider has no per-context + * trust-store injection point). + */ +@Configuration +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAutoConfiguration { + + @Bean + public LdapContextSource ldapContextSource(LdapProperties ldapProperties) { + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(ldapProperties.getUrl()); + // The search base is applied explicitly by LdapAuthService (user-search-base + base), so + // the context source must stay root-relative; otherwise the base would be applied twice + // and every search would fail. + if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) { + contextSource.setUserDn(ldapProperties.getUsername()); + contextSource.setPassword(ldapProperties.getPassword()); + } + contextSource.setPooled(false); + + Map baseEnvironment = new HashMap<>(); + baseEnvironment.put("com.sun.jndi.ldap.connect.timeout", + String.valueOf(ldapProperties.getConnectTimeoutMillis())); + baseEnvironment.put("com.sun.jndi.ldap.read.timeout", + String.valueOf(ldapProperties.getReadTimeoutMillis())); + contextSource.setBaseEnvironmentProperties(baseEnvironment); + + contextSource.afterPropertiesSet(); + return contextSource; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java new file mode 100644 index 000000000..a9ff8d33d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java @@ -0,0 +1,246 @@ +package com.iflytek.skillhub.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +/** + * Configuration properties for LDAP authentication. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.ldap") +public class LdapProperties { + + /** + * Whether LDAP authentication is enabled. + */ + private boolean enabled = false; + + /** + * LDAP server URL (e.g., ldap://localhost:389). + */ + private String url; + + /** + * Base DN for LDAP searches (e.g., dc=example,dc=com). + */ + private String base; + + /** + * DN of the user to bind for LDAP searches. + */ + private String username; + + /** + * Password for the LDAP bind user. + */ + private String password; + + /** + * LDAP attribute to use for username lookup (e.g., uid, sAMAccountName). + */ + private String userSearchAttribute = "uid"; + + /** + * Search base for user lookup (relative to base). + */ + private String userSearchBase = ""; + + /** + * Stable directory identifier attribute used as the LDAP identity subject. + * OpenLDAP uses "entryUUID", Active Directory uses "objectGUID". + */ + private String subjectAttribute = "entryUUID"; + + /** + * LDAP attribute mapped to the local display name. + */ + private String displayNameAttribute = "displayName"; + + /** + * Fallback LDAP attribute for the display name when the primary + * {@link #displayNameAttribute} is absent or empty. Defaults to {@code cn} + * (common name), the conventional fallback for directories that do not + * populate a dedicated display name. + */ + private String displayNameFallbackAttribute = "cn"; + + /** + * LDAP attribute mapped to the local email. + */ + private String emailAttribute = "mail"; + + /** + * LDAP connection timeout in milliseconds. + */ + private int connectTimeoutMillis = 5000; + + /** + * LDAP read timeout in milliseconds. + */ + private int readTimeoutMillis = 10000; + + /** + * Path to a custom trust store used for LDAPS certificate validation. When empty, + * the JVM default trust store is used. Configure this for directories signed by + * internal/self-signed CAs. + */ + private String tlsTrustStorePath = ""; + + /** + * Password for the custom trust store. Only used when {@link #tlsTrustStorePath} is set. + */ + private String tlsTrustStorePassword = ""; + + /** + * Trust store type (JKS, PKCS12). Defaults to JKS for compatibility. + */ + private String tlsTrustStoreType = "JKS"; + + @PostConstruct + void validate() { + if (!enabled) { + return; + } + if (url == null || url.isBlank()) { + throw new IllegalStateException("skillhub.ldap.url must be configured when LDAP is enabled"); + } + if (base == null || base.isBlank()) { + throw new IllegalStateException("skillhub.ldap.base must be configured when LDAP is enabled"); + } + if (connectTimeoutMillis <= 0) { + throw new IllegalStateException("skillhub.ldap.connect-timeout-millis must be a positive number"); + } + if (readTimeoutMillis <= 0) { + throw new IllegalStateException("skillhub.ldap.read-timeout-millis must be a positive number"); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getBase() { + return base; + } + + public void setBase(String base) { + this.base = base; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getUserSearchAttribute() { + return userSearchAttribute; + } + + public void setUserSearchAttribute(String userSearchAttribute) { + this.userSearchAttribute = userSearchAttribute; + } + + public String getUserSearchBase() { + return userSearchBase; + } + + public void setUserSearchBase(String userSearchBase) { + this.userSearchBase = userSearchBase; + } + + public String getSubjectAttribute() { + return subjectAttribute; + } + + public void setSubjectAttribute(String subjectAttribute) { + this.subjectAttribute = subjectAttribute; + } + + public String getDisplayNameAttribute() { + return displayNameAttribute; + } + + public void setDisplayNameAttribute(String displayNameAttribute) { + this.displayNameAttribute = displayNameAttribute; + } + + public String getDisplayNameFallbackAttribute() { + return displayNameFallbackAttribute; + } + + public void setDisplayNameFallbackAttribute(String displayNameFallbackAttribute) { + this.displayNameFallbackAttribute = displayNameFallbackAttribute; + } + + public String getEmailAttribute() { + return emailAttribute; + } + + public void setEmailAttribute(String emailAttribute) { + this.emailAttribute = emailAttribute; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public int getReadTimeoutMillis() { + return readTimeoutMillis; + } + + public void setReadTimeoutMillis(int readTimeoutMillis) { + this.readTimeoutMillis = readTimeoutMillis; + } + + public String getTlsTrustStorePath() { + return tlsTrustStorePath; + } + + public void setTlsTrustStorePath(String tlsTrustStorePath) { + this.tlsTrustStorePath = tlsTrustStorePath; + } + + public String getTlsTrustStorePassword() { + return tlsTrustStorePassword; + } + + public void setTlsTrustStorePassword(String tlsTrustStorePassword) { + this.tlsTrustStorePassword = tlsTrustStorePassword; + } + + public String getTlsTrustStoreType() { + return tlsTrustStoreType; + } + + public void setTlsTrustStoreType(String tlsTrustStoreType) { + this.tlsTrustStoreType = tlsTrustStoreType; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java new file mode 100644 index 000000000..212df2248 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.auth.config; + +import com.iflytek.skillhub.auth.ldap.LdapTrustStoreInstaller; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Installs the custom LDAPS trust store before the Spring context (and therefore any TLS + * connection) is created. Runs only when {@code skillhub.ldap.enabled=true} and + * {@code skillhub.ldap.tls-trust-store} is set. + */ +public class LdapTrustStoreEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (!Boolean.parseBoolean(environment.getProperty("skillhub.ldap.enabled", "false"))) { + return; + } + String path = environment.getProperty("skillhub.ldap.tls-trust-store", ""); + if (path == null || path.isBlank()) { + return; + } + String password = environment.getProperty("skillhub.ldap.tls-trust-store-password", ""); + String type = environment.getProperty("skillhub.ldap.tls-trust-store-type", "JKS"); + LdapTrustStoreInstaller.install(path, password, type); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java new file mode 100644 index 000000000..33536c4d0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java @@ -0,0 +1,780 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import jakarta.persistence.EntityManager; +import javax.net.ssl.SSLException; +import javax.naming.AuthenticationException; +import javax.naming.CommunicationException; +import javax.naming.NamingException; +import java.util.regex.Pattern; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.LdapName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.TransactionDefinition; + +/** + * Handles LDAP authentication for enterprise directory integration. + * + * Identity is anchored on a stable directory identifier (entryUUID/objectGUID) via + * {@link IdentityBinding} (provider="ldap"), not on the user's email. This prevents + * silent account merging when an LDAP user's email collides with an existing + * local/OAuth account, and avoids duplicate accounts for email-less users. + */ +@Service +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAuthService { + + /** + * An LDAP identity resolved and verified against the directory without provisioning a local + * account. Used by the explicit bind flow to attach an LDAP identity to an existing account. + */ + public record LdapIdentity(String username, String subject, String email, String displayName) { + } + + private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class); + private static final String LDAP_PROVIDER = "ldap"; + // Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters. + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{3,64}$"); + // LDAP attribute names only allow ASCII letters, digits, and hyphens. + private static final Pattern ATTRIBUTE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$"); + + /** + * Signals that a concurrent first login wrote the same LDAP subject binding first. The + * provisioning (sub-)transaction has already been rolled back cleanly; callers must + * re-resolve the identity by subject in a fresh transaction. + */ + private static final class LdapBindingRaceException extends RuntimeException { + LdapBindingRaceException(Throwable cause) { + super(cause); + } + } + + private final LdapProperties ldapProperties; + private final LdapContextSource ldapContextSource; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final IdentityBindingRepository identityBindingRepository; + private final EntityManager entityManager; + /** + * Programmatic REQUIRES_NEW template for account/binding provisioning. A separate physical + * transaction is required so a failed concurrent insert (which marks its transaction + * rollback-only) can be rolled back cleanly and the identity re-resolved in a fresh + * transaction. Spring's annotation-driven propagation cannot be used here because the + * provisioning methods are invoked internally (self-invocation bypasses the proxy). + */ + private final TransactionTemplate ldapProvisioningTx; + + /** + * Striped monitors that serialize first-login email-collision checks across concurrent + * requests in this JVM. {@code user_account.email} intentionally has no UNIQUE constraint + * (other identity flows may share an email), so the application-level check-and-insert for + * the same email must be serialized to stop two distinct LDAP subjects from provisioning two + * accounts with the same email at the same time. A fixed stripe count keeps memory constant. + * Multi-instance deployments need an equivalent cross-node lock (database advisory lock or a + * unique index with the other flows migrated) on top of this. + */ + private final Object[] emailLockStripes = new Object[64]; + + public LdapAuthService(LdapProperties ldapProperties, + LdapContextSource ldapContextSource, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, + IdentityBindingRepository identityBindingRepository, + EntityManager entityManager, + PlatformTransactionManager transactionManager) { + this.ldapProperties = ldapProperties; + this.ldapContextSource = ldapContextSource; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.identityBindingRepository = identityBindingRepository; + this.entityManager = entityManager; + this.ldapProvisioningTx = new TransactionTemplate(transactionManager); + this.ldapProvisioningTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + for (int i = 0; i < emailLockStripes.length; i++) { + emailLockStripes[i] = new Object(); + } + } + + /** + * Authenticates a user against the LDAP server. + * If the user doesn't exist in the local database, creates a new user based on LDAP attributes. + * + * @param username the username + * @param password the password + * @return PlatformPrincipal if authentication succeeds + * @throws AuthFlowException if authentication fails + */ + public PlatformPrincipal login(String username, String password) { + log.debug("Starting LDAP authentication for username: {}", username); + + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + + // Validate all LDAP attribute names that flow into JNDI calls to prevent filter/attribute + // injection via operator misconfiguration. These names are operator-controlled, not user input. + validateAttributeNames(); + + log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}", + safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase(), + ldapProperties.getUserSearchBase(), + ldapProperties.getUserSearchAttribute()); + + // First, try to find the user in LDAP and authenticate + Attributes userAttributes = authenticateAndFetch(username, password); + + // Find or create local user account anchored on the stable LDAP subject. Account and + // binding creation run in their own (sub-)transaction; a concurrent first login for the + // same subject is recovered by re-resolving the identity in a fresh transaction. When the + // directory entry carries an email, the check-and-insert of that email is additionally + // serialized per email (striped monitor) so two different subjects cannot both pass the + // collision check and provision duplicate accounts simultaneously. + log.debug("Finding or creating local user account for username: {}", username); + String email = getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()); + UserAccount user = (email == null || email.isEmpty()) + ? provisionUser(username, userAttributes) + : provisionUserSerializedByEmail(username, userAttributes, email); + + // Check if user can login (status check) + log.debug("Checking user status for user: {}, status: {}", username, user.getStatus()); + ensureUserCanLogin(user); + + log.debug("LDAP authentication successful for username: {}", username); + return buildPrincipal(user); + } + + /** + * Resolves and verifies an LDAP identity (search, bind, attribute read) without provisioning + * a local account or identity binding. Serves the explicit account-binding flow: the caller + * has already authenticated (or is being authenticated) and uses the LDAP credentials to + * prove ownership of the directory identity. + */ + public LdapIdentity resolveIdentity(String username, String password) { + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + validateAttributeNames(); + Attributes userAttributes = authenticateAndFetch(username, password); + String subject = getAttributeValue(userAttributes, ldapProperties.getSubjectAttribute()); + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + return new LdapIdentity( + username, + subject, + getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()), + resolveDisplayName(userAttributes, username) + ); + } + + /** + * Finds the user entry, verifies the password via a directory bind, and fetches the entry + * attributes. All errors are classified with the same semantics for login and binding. + */ + private Attributes authenticateAndFetch(String username, String password) { + String userDn = findUserDn(username); + log.debug("LDAP findUserDn result for {}: {}", username, userDn != null); + + if (userDn == null) { + log.warn("User {} not found in LDAP directory", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.userNotFound"); + } + + log.debug("Attempting LDAP bind for user DN: {}", userDn); + boolean authenticated = authenticateLdap(userDn, password); + log.debug("LDAP bind result for {}: {}", username, authenticated); + + if (!authenticated) { + log.warn("LDAP authentication failed for username: {}", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } + + log.debug("Fetching user attributes from LDAP for DN: {}", userDn); + Attributes userAttributes = getUserAttributes(userDn); + if (userAttributes == null) { + log.error("Failed to fetch user attributes from LDAP for DN: {}", userDn); + // Bind already succeeded, so the credentials are valid. This is a transient directory + // failure; surface a 503 with the directoryUnavailable message instead of masking it + // as a 401 "invalid credentials" (which would mislead the user about the password). + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } + return userAttributes; + } + + /** + * Provisions the local account and identity binding in a REQUIRES_NEW sub-transaction, + * recovering from a concurrent same-subject first login by re-resolving the existing account. + */ + private UserAccount provisionUser(String username, Attributes userAttributes) { + try { + return ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes)); + } catch (LdapBindingRaceException e) { + log.warn("Concurrent first login detected for LDAP subject of username {}; resolving existing account", username); + return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username)); + } + } + + /** + * Serializes the check-and-insert of a non-empty LDAP email so concurrent first logins from + * different subjects sharing one email cannot both provision an account. The monitor is held + * until the provisioning sub-transaction commits, so the second caller observes the first + * account and receives the regular 409 email-conflict result. + */ + private UserAccount provisionUserSerializedByEmail(String username, Attributes userAttributes, String email) { + Object stripe = emailLockStripes[Math.floorMod(email.toLowerCase(Locale.ROOT).hashCode(), emailLockStripes.length)]; + synchronized (stripe) { + return provisionUser(username, userAttributes); + } + } + + /** + * Ensures the user account status allows login. + */ + private void ensureUserCanLogin(UserAccount user) { + if (user.getStatus() == UserStatus.DISABLED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); + } + if (user.getStatus() == UserStatus.PENDING) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); + } + if (user.getStatus() == UserStatus.MERGED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + } + } + + /** + * Finds the DN (Distinguished Name) of a user in LDAP. + */ + private String findUserDn(String username) { + // LDAP injection prevention: validate username before search + if (!isValidUsername(username)) { + log.warn("Invalid username format for LDAP search: {}", username); + return null; + } + String searchAttr = ldapProperties.getUserSearchAttribute(); + + DirContext ctx = null; + javax.naming.NamingEnumeration results = null; + try { + ctx = createLdapContext(); + String searchFilter = "(" + searchAttr + "={0})"; + String searchBase = ldapProperties.getUserSearchBase().isEmpty() + ? ldapProperties.getBase() + : ldapProperties.getUserSearchBase() + "," + ldapProperties.getBase(); + + SearchControls searchControls = new SearchControls(); + searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE); + searchControls.setReturningAttributes(new String[0]); + + results = ctx.search(searchBase, searchFilter, new Object[]{username}, searchControls); + + if (results.hasMore()) { + SearchResult result = results.next(); + return result.getNameInNamespace(); + } + return null; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (AuthenticationException e) { + log.warn("LDAP bind authentication failed while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } catch (NamingException e) { + log.warn("LDAP naming error while searching for user {}: {}", username, e.getMessage()); + return null; + } finally { + // Close NamingEnumeration to prevent resource leaks + if (results != null) { + try { + results.close(); + } catch (Exception e) { + log.warn("Failed to close LDAP search results", e); + } + } + closeContext(ctx); + } + } + + /** + * Creates an LDAP context for searching. + */ + private DirContext createLdapContext() throws NamingException { + // Bind-account context for directory searches/reads; delegates to the shared factory. + return createLdapContext(null, null); + } + + /** + * Creates an LDAP context authenticated with the given principal/credentials. When both are + {@code null}, falls back to the configured bind account (or anonymous if none is set). This is + the single place that obtains contexts, so connection/timeout/TLS settings configured on the + shared {@link LdapContextSource} stay consistent across search, bind, and attribute-read + operations. + */ + private DirContext createLdapContext(String principal, String credentials) throws NamingException { + try { + // Explicit principal/credentials take precedence; otherwise use the configured bind account. + String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername(); + String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword(); + if (bindPrincipal != null && !bindPrincipal.isEmpty()) { + return ldapContextSource.getContext(bindPrincipal, bindCredentials); + } + // No bind account configured: anonymous read context for directory searches/reads. + return ldapContextSource.getReadOnlyContext(); + } catch (org.springframework.ldap.CommunicationException e) { + // Spring LDAP wraps JNDI failures into unchecked org.springframework.ldap.* exceptions; + // translate them back so the callers' javax.naming.* classification stays unchanged. + throw (javax.naming.CommunicationException) new javax.naming.CommunicationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.AuthenticationException e) { + throw (javax.naming.AuthenticationException) new javax.naming.AuthenticationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.NameNotFoundException e) { + throw (javax.naming.NameNotFoundException) new javax.naming.NameNotFoundException(e.getMessage()) + .initCause(e); + } + } + + /** + * Closes an LDAP context. + */ + private void closeContext(DirContext ctx) { + if (ctx != null) { + try { + ctx.close(); + } catch (NamingException e) { + // Ignore + } + } + } + + /** + * Safely extracts host:port from LDAP URL for logging, avoiding credential exposure. + * Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host + */ + public static String safeLogHost(String url) { + if (url == null || url.isEmpty()) { + return ""; + } + try { + String withoutProtocol = url.replaceFirst("^ldaps?://", ""); + int atIndex = withoutProtocol.indexOf('@'); + if (atIndex > 0) { + withoutProtocol = withoutProtocol.substring(atIndex + 1); + } + // IPv6 literal: ldap://[::1]:389 — keep the bracketed address plus port + int bracketEnd = withoutProtocol.indexOf(']'); + if (bracketEnd > 0) { + return withoutProtocol.substring(0, Math.min(bracketEnd + 1, withoutProtocol.length())); + } + int slashIndex = withoutProtocol.indexOf('/'); + String hostPort = slashIndex > 0 ? withoutProtocol.substring(0, slashIndex) : withoutProtocol; + return hostPort; + } catch (Exception e) { + return "[url-parse-error]"; + } + } + + /** + * Returns whether the throwable chain indicates a TLS/trust failure (LDAPS handshake or + * certificate validation). JNDI wraps TLS failures in a {@link CommunicationException}, so + * without this check a certificate problem is indistinguishable from an unreachable directory. + */ + static boolean isTlsFailure(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof SSLException || c instanceof CertificateException + || c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) { + return true; + } + } + return false; + } + + /** + * Authenticates a user against the LDAP server using their DN and password. + */ + private boolean authenticateLdap(String userDn, String password) { + // Reject null/empty passwords explicitly: a null value would otherwise reach + // Hashtable.put (NPE -> 500), and an empty password could be accepted by directories + // that allow anonymous/weak binds. Treat both as credential failures (401). + if (password == null || password.isEmpty()) { + log.debug("LDAP bind rejected: empty password for DN {}", userDn); + return false; + } + DirContext ctx = null; + try { + // Bind as the authenticating user to verify credentials (shared factory handles env). + ctx = createLdapContext(userDn, password); + return true; + } catch (AuthenticationException e) { + // Invalid credentials — expected, return false to signal auth failure + return false; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (NamingException e) { + log.warn("LDAP naming error while authenticating DN {}: {}", userDn, e.getMessage()); + return false; + } finally { + closeContext(ctx); + } + } + + /** + * Retrieves user attributes from LDAP. + */ + private Attributes getUserAttributes(String userDn) { + DirContext ctx = null; + try { + // Read attributes via the bind-account context (shared factory handles env/timeout). + ctx = createLdapContext(); + Attributes attrs; + try { + // Request user attributes ("*") and operational attributes ("+") so stable + // directory identifiers (OpenLDAP entryUUID, AD objectGUID) are included in the + // response. Without the explicit request, operational attributes are omitted and + // the subject key would be null on every login. + attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"}); + } catch (NamingException e) { + // Some directories reject the "*"/"+" attribute-request syntax; fall back to the + // default attribute set for protocol/request-level failures only. Connection and + // authentication failures must not trigger a retry — the outer catch blocks + // classify them as TLS error vs directory-unavailable vs bind failure. + if (e instanceof CommunicationException || e instanceof AuthenticationException) { + throw e; + } + attrs = ctx.getAttributes(new LdapName(userDn)); + } + return attrs; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (Exception e) { + log.warn("Failed to fetch user attributes from LDAP for DN {}: {}", userDn, e.getMessage()); + return null; + } finally { + closeContext(ctx); + } + } + + /** + * Finds an existing LDAP user or creates a new one based on LDAP attributes. + * + * Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID) + * via {@link IdentityBinding}, not on the user's email. This prevents: + * + * Silent account merging when an LDAP email collides with a local/OAuth account + * Duplicate accounts for email-less LDAP users on repeated logins + * + */ + UserAccount findOrCreateLdapUser(String username, Attributes attributes) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + // Bind already succeeded. The directory entry lacks the configured subject attribute, + // which is a configuration/schema issue the user cannot fix. Surface a 503 with the + // invalidConfiguration message instead of a 401 that would look like a wrong password. + // Operators can locate the cause via the log line above. + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + + // Anchor on the stable LDAP subject: an existing binding means this identity is already known. + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + + if (binding != null) { + // Returning user — refresh attributes from the directory on each login. + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // The bound account no longer exists (e.g. deleted by an administrator). The stale + // binding would otherwise block this subject forever with a 500. Remove it and fall + // through to the first-login provisioning path, which creates a fresh account for + // the directory identity. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + } + + // First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth + // account that happens to share the same email — that would be a privilege escalation. + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(); + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null) { + // The email already belongs to another account. Refuse to silently create a second + // account (two distinct LDAP subjects sharing one email would both map to it, and + // user_account.email has no UNIQUE constraint, so this would otherwise happen + // silently). This covers both cross-provider collisions and the same-issuer case + // (a different LDAP subject under the same email). If an entry's stable subject + // legitimately changes (e.g. after an AD objectGUID migration), an administrator + // must remove the stale binding before the new subject can log in. + log.warn("LDAP user {} email {} collides with an existing account {} (subject differs); refusing to create a duplicate account", + username, normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + + // Create a new user account. The placeholder email is only used to satisfy the NOT NULL + // constraint and never serves as an identity key. + String normalizedEmail = email != null && !email.isEmpty() + ? email.toLowerCase() + : LDAP_PROVIDER + ":" + username + "@internal"; + + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + displayName, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + user = userAccountRepository.save(user); + globalNamespaceMembershipService.ensureMember(user.getId()); + + try { + IdentityBinding newBinding = new IdentityBinding(user.getId(), LDAP_PROVIDER, subject, username); + // saveAndFlush surfaces the unique (provider_code, subject) constraint immediately, so + // a concurrent first login for the same subject is detected here and the whole + // sub-transaction (account + membership + binding) is rolled back. + identityBindingRepository.saveAndFlush(newBinding); + } catch (DataIntegrityViolationException e) { + // A concurrent first login for the same subject committed its binding first. Clear the + // failed persist state (otherwise Hibernate throws AssertionFailure while rolling back + // the session), then signal the race so the identity is re-resolved in a fresh + // transaction. The insert failure already marked this sub-transaction rollback-only, + // so it can never be committed with the re-resolved state. + entityManager.clear(); + throw new LdapBindingRaceException(e); + } + + return user; + } + + /** + * Re-resolves a returning LDAP user in a fresh transaction after a concurrent first-login + * race. Called only when the racing transaction has committed its binding, so the lookup is + * guaranteed to hit the existing account. + */ + UserAccount resolveReturningUser(Attributes attributes, String username) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + if (binding == null) { + // The racing transaction rolled back after all (rare), or the binding was cleaned up + // concurrently. Fall back to the regular provisioning path. + throw new LdapBindingRaceException(new IllegalStateException("No binding found after race for " + subject)); + } + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // Stale binding for a deleted account: remove it and retry provisioning from scratch. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + throw new LdapBindingRaceException(new IllegalStateException("Stale binding removed for " + subject)); + } + + private String resolveDisplayName(Attributes attributes, String username) { + String displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameAttribute()); + if (displayName == null || displayName.isEmpty()) { + displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameFallbackAttribute()); + } + if (displayName == null || displayName.isEmpty()) { + displayName = username; + } + return displayName; + } + + private void updateFromAttributes(UserAccount user, String displayName, String email) { + if (displayName != null && !displayName.isEmpty()) { + user.setDisplayName(displayName); + } + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(Locale.ROOT); + // A bound user may update their own email, but must never silently adopt an email + // that already belongs to a different account (same rule as first login). + if (user.getEmail() == null || !user.getEmail().equalsIgnoreCase(normalizedEmail)) { + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null && !existingByEmail.getId().equals(user.getId())) { + log.warn("LDAP user {} email {} collides with another account {} on refresh; refusing to update", + user.getDisplayName(), normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + user.setEmail(normalizedEmail); + } + } + + /** + * Gets a string attribute value from LDAP attributes. + */ + private String getAttributeValue(Attributes attributes, String attrName) { + try { + Attribute attr = attributes.get(attrName); + if (attr != null && attr.get() != null) { + Object value = attr.get(); + // Active Directory stores stable identifiers such as objectGUID / objectSid as + // binary (OctetString). JNDI returns these as byte[], whose toString() yields an + // unstable "[B@" — making every login look like a new identity. + // Convert binary values to a stable hexadecimal representation (mixed-endian GUID + // layout for 16-byte values) so the subject key remains stable across logins. + if (value instanceof byte[] bytes) { + return toStableGuidString(bytes); + } + return value.toString(); + } + } catch (Exception e) { + // Ignore and return null + } + return null; + } + + /** + * Converts a binary attribute value into a stable string suitable for use as an identity + * subject. Active Directory objectGUID is a 16-byte mixed-endian GUID; rearranging it into + * the canonical 8-4-4-4-12 hex layout yields the same string .NET/AD display, which is stable + * across JVM restarts and connections. Non-16-byte binaries fall back to plain hex so the + * value is still deterministic. + */ + private static String toStableGuidString(byte[] bytes) { + if (bytes.length == 16) { + // AD objectGUID layout: little-endian uint32, little-endian uint16 x2, big-endian rest. + return String.format("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[3] & 0xff, bytes[2] & 0xff, bytes[1] & 0xff, bytes[0] & 0xff, + bytes[5] & 0xff, bytes[4] & 0xff, + bytes[7] & 0xff, bytes[6] & 0xff, + bytes[8] & 0xff, bytes[9] & 0xff, + bytes[10] & 0xff, bytes[11] & 0xff, bytes[12] & 0xff, + bytes[13] & 0xff, bytes[14] & 0xff, bytes[15] & 0xff); + } + // Non-GUID binary attribute: deterministic plain hex so the value stays stable. + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * Builds a PlatformPrincipal from a UserAccount. + */ + private PlatformPrincipal buildPrincipal(UserAccount user) { + Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + "ldap", + roles + ); + } + + /** + * Validates username to prevent LDAP injection attacks. + * Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), + * 3-64 characters. + */ + private boolean isValidUsername(String username) { + if (username == null || username.isEmpty()) { + return false; + } + return USERNAME_PATTERN.matcher(username).matches(); + } + + /** + * Validates all operator-configured LDAP attribute names that flow into JNDI calls. + * Rejects names that are null or fail the LDAP attribute-name pattern, preventing + * filter/attribute injection via misconfiguration before any directory call is made. + */ + private void validateAttributeNames() { + String[] attrNames = { + ldapProperties.getUserSearchAttribute(), + ldapProperties.getSubjectAttribute(), + ldapProperties.getDisplayNameAttribute(), + ldapProperties.getDisplayNameFallbackAttribute(), + ldapProperties.getEmailAttribute() + }; + for (String name : attrNames) { + if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) { + log.error("Invalid LDAP attribute name configured: {}", name); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.invalidConfiguration"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java new file mode 100644 index 000000000..9f7e5ec4d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.ldap; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import java.util.UUID; + +/** + * Installs a custom trust store for LDAPS by merging it with the JVM's default trust store and + * pointing the {@code javax.net.ssl.trustStore*} system properties at the merged store. + * + * This is the only reliable injection point for LDAPS trust in a JNDI-based client: the JDK + * LDAP provider builds its sockets from the JVM-wide SSL configuration and ignores both + * {@code javax.net.ssl.trustStore*} context-environment entries and the + * {@code java.naming.ldap.factory.socket} property (verified against the JDK 21 implementation), + * and Spring LDAP 3.x no longer offers a per-context socket factory. Because the JSSE default + * SSLContext is cached on first use, the installer must run before any TLS connection is made; + * it is invoked from an {@code EnvironmentPostProcessor} during application startup. + * + * Merging (rather than replacing) keeps public-CA connectivity intact: the resulting store + * contains the JVM defaults plus the configured internal CA. + */ +public final class LdapTrustStoreInstaller { + + private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit"; + + private LdapTrustStoreInstaller() { + } + + /** + * Merges the configured custom trust store into the JVM default trust store and installs the + * result through the {@code javax.net.ssl.trustStore*} system properties. + * + * @param customPath the custom trust store path + * @param customPassword the custom trust store password (may be empty) + * @param customType the custom trust store type (JKS, PKCS12, ...) + */ + public static void install(String customPath, String customPassword, String customType) { + try { + KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType()); + merged.load(null, null); + copyCertificateEntries(loadDefaultTrustStore(), merged); + copyCertificateEntries(loadCustomTrustStore(customPath, customPassword, customType), merged); + + String password = "skillhub-" + UUID.randomUUID(); + Path file = Files.createTempFile("skillhub-truststore", ".p12"); + try (OutputStream out = Files.newOutputStream(file)) { + merged.store(out, password.toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", file.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", password); + System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType()); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to install LDAPS trust store from " + customPath, e); + } + } + + private static KeyStore loadDefaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + String systemPassword = System.getProperty("javax.net.ssl.trustStorePassword", + DEFAULT_TRUSTSTORE_PASSWORD); + String systemType = System.getProperty("javax.net.ssl.trustStoreType", + KeyStore.getDefaultType()); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(systemType); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, systemPassword.toCharArray()); + } + return keyStore; + } + + private static KeyStore loadCustomTrustStore(String path, String password, String type) throws Exception { + KeyStore keyStore = KeyStore.getInstance(type); + try (InputStream in = Files.newInputStream(Path.of(path))) { + keyStore.load(in, password.toCharArray()); + } + return keyStore; + } + + private static void copyCertificateEntries(KeyStore source, KeyStore target) throws Exception { + Enumeration aliases = source.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (source.isCertificateEntry(alias)) { + target.setCertificateEntry(alias, source.getCertificate(alias)); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867b..512515617 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.local; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -16,9 +18,14 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; /** @@ -28,6 +35,8 @@ @Service public class LocalAuthService { + private static final Logger log = LoggerFactory.getLogger(LocalAuthService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; @@ -44,6 +53,15 @@ public class LocalAuthService { private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final LdapProperties ldapProperties; + private final ObjectProvider ldapAuthServiceProvider; + /** + * Transaction boundary for the local-credential login path. The {@code login} method itself + * is intentionally NOT transactional: the LDAP fallback performs directory network calls + * (up to connect+read timeout) and must not hold a database connection/transaction open + * while blocked on the directory. Only the local credential database work is wrapped. + */ + private final TransactionTemplate transactionTemplate; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -51,7 +69,10 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LdapProperties ldapProperties, + ObjectProvider ldapAuthServiceProvider, + PlatformTransactionManager transactionManager) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +80,9 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.ldapProperties = ldapProperties; + this.ldapAuthServiceProvider = ldapAuthServiceProvider; + this.transactionTemplate = new TransactionTemplate(transactionManager); } /** @@ -107,33 +131,72 @@ public PlatformPrincipal register(String username, String password, String email /** * Authenticates a local account and returns the principal snapshot used to * establish a web session. + * If the user is not found locally, falls back to LDAP authentication if enabled. */ - @Transactional public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); - LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) - .orElse(null); + LocalCredential credential = transactionTemplate.execute(status -> + credentialRepository.findByUsernameIgnoreCase(normalizedUsername).orElse(null)); if (credential == null) { + // Blur timing to prevent username enumeration passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + + // Fallback to LDAP authentication if enabled + if (ldapProperties.isEnabled()) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username); + throw invalidCredentials(); + } + log.debug("Local user not found, attempting LDAP authentication for username: {}", username); + log.debug("LDAP enabled: {}, host: {}, base: {}", + ldapProperties.isEnabled(), + LdapAuthService.safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase()); + try { + PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password); + log.debug("LDAP authentication successful for username: {}", username); + return ldapPrincipal; + } catch (AuthFlowException e) { + log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage()); + // Propagate account-state, availability, and email-conflict errors instead of + // masking them as invalid credentials, so the frontend can show the right message. + // A 409 emailConflict is only reached after a successful LDAP bind, so the + // credentials are valid; masking it as 401 would mislead the user into thinking + // their password is wrong. Only genuine credential failures (userNotFound / + // invalidCredentials) fall back to the generic response to avoid enumeration. + HttpStatus status = e.getStatus(); + if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE + || status == HttpStatus.CONFLICT) { + throw e; + } + throw invalidCredentials(); + } + } else { + log.debug("LDAP authentication is disabled, rejecting login for username: {}", username); + } + throw invalidCredentials(); } - UserAccount user = userAccountRepository.findById(credential.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + return transactionTemplate.execute(status -> { + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); - ensureUserCanLogin(user); - ensureNotLocked(credential); + ensureUserCanLogin(user); + ensureNotLocked(credential); - if (!passwordEncoder.matches(password, credential.getPasswordHash())) { - handleFailedLogin(credential); - throw invalidCredentials(); - } + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } - credential.setFailedAttempts(0); - credential.setLockedUntil(null); - credentialRepository.save(credential); - return buildPrincipal(user); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + }); } /** diff --git a/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 000000000..e5ec5e04e --- /dev/null +++ b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java new file mode 100644 index 000000000..9c8915aec --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Startup configuration validation for the LDAP properties block. + */ +class LdapPropertiesTest { + + @Test + void validate_requiresUrlAndBase_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("skillhub.ldap.url"); + } + + @Test + void validate_rejectsNonPositiveTimeouts_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://localhost:389"); + props.setBase("dc=example,dc=org"); + props.setConnectTimeoutMillis(0); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect-timeout-millis"); + } + + @Test + void validate_skipsChecks_whenDisabled() { + LdapProperties props = new LdapProperties(); + assertThatCode(props::validate).doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java new file mode 100644 index 000000000..f15892258 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java @@ -0,0 +1,358 @@ + +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import java.lang.reflect.Method; +import java.util.Optional; +import jakarta.persistence.EntityManager; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Behavior-level unit tests for {@link LdapAuthService} identity provisioning. + * + * These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + entityManager = mock(EntityManager.class); + transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + ldapContextSource, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository, + entityManager, + transactionManager); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception { + // Given — the binding points to an account that no longer exists + IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(stale)); + given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — the same subject logs in again + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — the stale binding is removed and a fresh account is provisioned + assertThat(created.getEmail()).isEqualTo(EMAIL); + assertThat(created.getId()).isNotEqualTo("usr_deleted"); + verify(identityBindingRepository).delete(stale); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration"); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception { + // Given — the LDAP subject is bound, but the directory now reports an email that already + // belongs to a different account. The refresh must refuse to adopt it (409), matching the + // first-login email-collision rule. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other)); + + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice")); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + // The user's own email must remain untouched. + assertThat(existing.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void returningUser_sameEmail_isAllowedToRefresh() throws Exception { + // Given — the directory reports the same email the bound account already owns; the + // collision lookup must exclude the user's own account. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith")); + + assertThat(result.getId()).isEqualTo(userId); + assertThat(result.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsCertPathValidatorWithoutSslException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 000000000..d64928bc8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + * Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java new file mode 100644 index 000000000..f509138a0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LdapTrustStoreInstallerTest { + + @TempDir + Path tempDir; + + private String previousTrustStore; + private String previousPassword; + private String previousType; + + @AfterEach + void restoreSystemProperties() { + restore("javax.net.ssl.trustStore", previousTrustStore); + restore("javax.net.ssl.trustStorePassword", previousPassword); + restore("javax.net.ssl.trustStoreType", previousType); + } + + @Test + void install_mergesCustomTrustStoreIntoDefaults() throws Exception { + previousTrustStore = System.getProperty("javax.net.ssl.trustStore"); + previousPassword = System.getProperty("javax.net.ssl.trustStorePassword"); + previousType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Build a custom trust store containing one certificate copied from the JVM defaults. + KeyStore defaults = defaultTrustStore(); + String sourceAlias = firstCertificateAlias(defaults); + Path custom = tempDir.resolve("custom.p12"); + KeyStore customStore = KeyStore.getInstance("PKCS12"); + customStore.load(null, null); + customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias)); + try (OutputStream out = Files.newOutputStream(custom)) { + customStore.store(out, "changeit".toCharArray()); + } + + LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12"); + + String installedPath = System.getProperty("javax.net.ssl.trustStore"); + assertThat(installedPath).isNotBlank(); + KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType")); + try (InputStream in = Files.newInputStream(Path.of(installedPath))) { + installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray()); + } + assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue(); + assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue(); + } + + @Test + void install_withMissingFile_failsFast() { + assertThatThrownBy(() -> LdapTrustStoreInstaller.install( + tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to install LDAPS trust store"); + } + + private static KeyStore defaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, "changeit".toCharArray()); + } + return keyStore; + } + + private static String firstCertificateAlias(KeyStore keyStore) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isCertificateEntry(alias)) { + return alias; + } + } + throw new IllegalStateException("default trust store has no certificate entries"); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f43446..f8ba17171 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -9,9 +9,12 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +25,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,10 +55,25 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LdapProperties ldapProperties; + + @Mock + private LdapAuthService ldapAuthService; + + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + + @Mock + private org.springframework.transaction.PlatformTransactionManager transactionManager; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any())) + .thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class)); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -62,7 +81,10 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + ldapProperties, + ldapAuthServiceProvider, + transactionManager ); } @@ -161,6 +183,7 @@ void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -247,4 +270,116 @@ void register_rejectsBlankEmail() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.notBlank"); } + + @Test + void login_withUnknownUsername_fallsBackToLdap_whenEnabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null); + ldapUser.setStatus(UserStatus.ACTIVE); + PlatformPrincipal ldapPrincipal = new PlatformPrincipal( + "usr_ldap", + "ldapuser", + "ldapuser@example.com", + null, + "ldap", + Set.of("USER") + ); + + given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal); + + // When + var principal = service.login("ldapuser", "LdapPassword123!"); + + // Then + assertThat(principal.userId()).isEqualTo("usr_ldap"); + assertThat(principal.displayName()).isEqualTo("ldapuser"); + assertThat(principal.email()).isEqualTo("ldapuser@example.com"); + verify(ldapAuthService).login("ldapuser", "LdapPassword123!"); + } + + @Test + void login_withUnknownUsername_fails_whenLdapAuthenticationFails() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "WrongPassword")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed")); + + // When & Then + assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService).login("ldapuser", "WrongPassword"); + } + + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void login_withUnknownUsername_fails_whenLdapDisabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); + + // When & Then + assertThatThrownBy(() -> service.login("localuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService, never()).login(any(), any()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..d4ef31a55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + LdapBindRequest, ReviewSkillDetail, ReviewTask, PromotionTask, @@ -444,6 +445,18 @@ export const accountApi = { }, } +export const ldapApi = { + async bindIdentity(request: LdapBindRequest): Promise { + await fetchJson('/api/v1/auth/ldap/bind', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..48bca25c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -81,6 +81,11 @@ export interface MergeInitiateRequest { secondaryIdentifier: string } +export interface LdapBindRequest { + username: string + password: string +} + export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string diff --git a/web/src/features/auth/use-ldap-bind.ts b/web/src/features/auth/use-ldap-bind.ts new file mode 100644 index 000000000..b241e42a3 --- /dev/null +++ b/web/src/features/auth/use-ldap-bind.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { ldapApi } from '@/api/client' +import type { LdapBindRequest } from '@/api/types' + +/** + * Binds the LDAP identity verified by the given directory credentials to the currently + * authenticated account. + */ +export function useLdapBind() { + return useMutation({ + mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..2876fc272 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -757,7 +757,15 @@ "confirming": "Confirming...", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "ldapBindTitle": "Bind LDAP Account", + "ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.", + "ldapUsername": "LDAP Username", + "ldapPassword": "LDAP Password", + "ldapBinding": "Binding...", + "ldapBind": "Bind", + "ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP", + "ldapBindError": "LDAP binding failed" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..0d4b7eb5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -757,7 +757,15 @@ "confirming": "确认中...", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "ldapBindTitle": "绑定 LDAP 账号", + "ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。", + "ldapUsername": "LDAP 用户名", + "ldapPassword": "LDAP 密码", + "ldapBinding": "绑定中...", + "ldapBind": "绑定", + "ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录", + "ldapBindError": "LDAP 绑定失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a9336..6f76c7582 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useLdapBind } from '@/features/auth/use-ldap-bind' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input' */ export function AccountSettingsPage() { const { t } = useTranslation() + const [ldapUsername, setLdapUsername] = useState('') + const [ldapPassword, setLdapPassword] = useState('') + const [ldapStatusMessage, setLdapStatusMessage] = useState('') const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') const [verificationToken, setVerificationToken] = useState('') @@ -21,6 +25,25 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() const confirmMutation = useConfirmAccountMerge() + const ldapBindMutation = useLdapBind() + + /** + * Binds the LDAP identity proven by the given directory credentials to the current account. + */ + async function handleLdapBind(event: React.FormEvent) { + event.preventDefault() + setLdapStatusMessage('') + try { + await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword }) + setLdapUsername('') + setLdapPassword('') + setLdapStatusMessage(t('accounts.ldapBindSuccess')) + } catch (error) { + setLdapStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'), + ) + } + } /** * Starts the merge flow and surfaces the request id plus verification token @@ -77,6 +100,40 @@ export function AccountSettingsPage() { return ( + + + {t('accounts.ldapBindTitle')} + {t('accounts.ldapBindDesc')} + + + + + {t('accounts.ldapUsername')} + setLdapUsername(event.target.value)} + /> + + + {t('accounts.ldapPassword')} + setLdapPassword(event.target.value)} + /> + + + {ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')} + + + {ldapStatusMessage ? {ldapStatusMessage} : null} + + + {t('accounts.initiateTitle')} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 5144fdf67..c30af2eea 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -57,6 +57,30 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) + it('shows the server message for 403 errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员')) + + expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员') + }) + + it('shows the server message for 5xx errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试')) + + expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试') + }) + + it('falls back to generic text for 403/5xx without a server message', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403)) + + expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden')) + }) + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca2..a743c94d9 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -68,7 +68,9 @@ export function handleApiError(error: unknown): void { } if (status === 403) { - toast.error(i18n.t('apiError.forbidden')) + // The backend may attach a specific business message (e.g. a disabled account or an + // LDAP conflict); prefer it over the generic forbidden text. + toast.error(error.serverMessage || i18n.t('apiError.forbidden')) return } @@ -78,7 +80,9 @@ export function handleApiError(error: unknown): void { } if (status >= 500) { - toast.error(i18n.t('apiError.serverError')) + // The backend returns localized operational messages for service-level failures such as + // "directory unavailable" or "TLS misconfiguration"; surface them when present. + toast.error(error.serverMessage || i18n.t('apiError.serverError')) return }
+ * The context source is the single place that maps {@link LdapProperties} onto JNDI + * connection settings (URL, base, bind credentials, connect/read timeouts). A custom + * trust store for LDAPS is installed JVM-wide before the context starts by + * {@link LdapTrustStoreEnvironmentPostProcessor} (the JDK LDAP provider has no per-context + * trust-store injection point). + */ +@Configuration +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAutoConfiguration { + + @Bean + public LdapContextSource ldapContextSource(LdapProperties ldapProperties) { + LdapContextSource contextSource = new LdapContextSource(); + contextSource.setUrl(ldapProperties.getUrl()); + // The search base is applied explicitly by LdapAuthService (user-search-base + base), so + // the context source must stay root-relative; otherwise the base would be applied twice + // and every search would fail. + if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) { + contextSource.setUserDn(ldapProperties.getUsername()); + contextSource.setPassword(ldapProperties.getPassword()); + } + contextSource.setPooled(false); + + Map baseEnvironment = new HashMap<>(); + baseEnvironment.put("com.sun.jndi.ldap.connect.timeout", + String.valueOf(ldapProperties.getConnectTimeoutMillis())); + baseEnvironment.put("com.sun.jndi.ldap.read.timeout", + String.valueOf(ldapProperties.getReadTimeoutMillis())); + contextSource.setBaseEnvironmentProperties(baseEnvironment); + + contextSource.afterPropertiesSet(); + return contextSource; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java new file mode 100644 index 000000000..a9ff8d33d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java @@ -0,0 +1,246 @@ +package com.iflytek.skillhub.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +/** + * Configuration properties for LDAP authentication. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.ldap") +public class LdapProperties { + + /** + * Whether LDAP authentication is enabled. + */ + private boolean enabled = false; + + /** + * LDAP server URL (e.g., ldap://localhost:389). + */ + private String url; + + /** + * Base DN for LDAP searches (e.g., dc=example,dc=com). + */ + private String base; + + /** + * DN of the user to bind for LDAP searches. + */ + private String username; + + /** + * Password for the LDAP bind user. + */ + private String password; + + /** + * LDAP attribute to use for username lookup (e.g., uid, sAMAccountName). + */ + private String userSearchAttribute = "uid"; + + /** + * Search base for user lookup (relative to base). + */ + private String userSearchBase = ""; + + /** + * Stable directory identifier attribute used as the LDAP identity subject. + * OpenLDAP uses "entryUUID", Active Directory uses "objectGUID". + */ + private String subjectAttribute = "entryUUID"; + + /** + * LDAP attribute mapped to the local display name. + */ + private String displayNameAttribute = "displayName"; + + /** + * Fallback LDAP attribute for the display name when the primary + * {@link #displayNameAttribute} is absent or empty. Defaults to {@code cn} + * (common name), the conventional fallback for directories that do not + * populate a dedicated display name. + */ + private String displayNameFallbackAttribute = "cn"; + + /** + * LDAP attribute mapped to the local email. + */ + private String emailAttribute = "mail"; + + /** + * LDAP connection timeout in milliseconds. + */ + private int connectTimeoutMillis = 5000; + + /** + * LDAP read timeout in milliseconds. + */ + private int readTimeoutMillis = 10000; + + /** + * Path to a custom trust store used for LDAPS certificate validation. When empty, + * the JVM default trust store is used. Configure this for directories signed by + * internal/self-signed CAs. + */ + private String tlsTrustStorePath = ""; + + /** + * Password for the custom trust store. Only used when {@link #tlsTrustStorePath} is set. + */ + private String tlsTrustStorePassword = ""; + + /** + * Trust store type (JKS, PKCS12). Defaults to JKS for compatibility. + */ + private String tlsTrustStoreType = "JKS"; + + @PostConstruct + void validate() { + if (!enabled) { + return; + } + if (url == null || url.isBlank()) { + throw new IllegalStateException("skillhub.ldap.url must be configured when LDAP is enabled"); + } + if (base == null || base.isBlank()) { + throw new IllegalStateException("skillhub.ldap.base must be configured when LDAP is enabled"); + } + if (connectTimeoutMillis <= 0) { + throw new IllegalStateException("skillhub.ldap.connect-timeout-millis must be a positive number"); + } + if (readTimeoutMillis <= 0) { + throw new IllegalStateException("skillhub.ldap.read-timeout-millis must be a positive number"); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getBase() { + return base; + } + + public void setBase(String base) { + this.base = base; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getUserSearchAttribute() { + return userSearchAttribute; + } + + public void setUserSearchAttribute(String userSearchAttribute) { + this.userSearchAttribute = userSearchAttribute; + } + + public String getUserSearchBase() { + return userSearchBase; + } + + public void setUserSearchBase(String userSearchBase) { + this.userSearchBase = userSearchBase; + } + + public String getSubjectAttribute() { + return subjectAttribute; + } + + public void setSubjectAttribute(String subjectAttribute) { + this.subjectAttribute = subjectAttribute; + } + + public String getDisplayNameAttribute() { + return displayNameAttribute; + } + + public void setDisplayNameAttribute(String displayNameAttribute) { + this.displayNameAttribute = displayNameAttribute; + } + + public String getDisplayNameFallbackAttribute() { + return displayNameFallbackAttribute; + } + + public void setDisplayNameFallbackAttribute(String displayNameFallbackAttribute) { + this.displayNameFallbackAttribute = displayNameFallbackAttribute; + } + + public String getEmailAttribute() { + return emailAttribute; + } + + public void setEmailAttribute(String emailAttribute) { + this.emailAttribute = emailAttribute; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public int getReadTimeoutMillis() { + return readTimeoutMillis; + } + + public void setReadTimeoutMillis(int readTimeoutMillis) { + this.readTimeoutMillis = readTimeoutMillis; + } + + public String getTlsTrustStorePath() { + return tlsTrustStorePath; + } + + public void setTlsTrustStorePath(String tlsTrustStorePath) { + this.tlsTrustStorePath = tlsTrustStorePath; + } + + public String getTlsTrustStorePassword() { + return tlsTrustStorePassword; + } + + public void setTlsTrustStorePassword(String tlsTrustStorePassword) { + this.tlsTrustStorePassword = tlsTrustStorePassword; + } + + public String getTlsTrustStoreType() { + return tlsTrustStoreType; + } + + public void setTlsTrustStoreType(String tlsTrustStoreType) { + this.tlsTrustStoreType = tlsTrustStoreType; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java new file mode 100644 index 000000000..212df2248 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.auth.config; + +import com.iflytek.skillhub.auth.ldap.LdapTrustStoreInstaller; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Installs the custom LDAPS trust store before the Spring context (and therefore any TLS + * connection) is created. Runs only when {@code skillhub.ldap.enabled=true} and + * {@code skillhub.ldap.tls-trust-store} is set. + */ +public class LdapTrustStoreEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (!Boolean.parseBoolean(environment.getProperty("skillhub.ldap.enabled", "false"))) { + return; + } + String path = environment.getProperty("skillhub.ldap.tls-trust-store", ""); + if (path == null || path.isBlank()) { + return; + } + String password = environment.getProperty("skillhub.ldap.tls-trust-store-password", ""); + String type = environment.getProperty("skillhub.ldap.tls-trust-store-type", "JKS"); + LdapTrustStoreInstaller.install(path, password, type); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java new file mode 100644 index 000000000..33536c4d0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java @@ -0,0 +1,780 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import jakarta.persistence.EntityManager; +import javax.net.ssl.SSLException; +import javax.naming.AuthenticationException; +import javax.naming.CommunicationException; +import javax.naming.NamingException; +import java.util.regex.Pattern; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.LdapName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.TransactionDefinition; + +/** + * Handles LDAP authentication for enterprise directory integration. + * + * Identity is anchored on a stable directory identifier (entryUUID/objectGUID) via + * {@link IdentityBinding} (provider="ldap"), not on the user's email. This prevents + * silent account merging when an LDAP user's email collides with an existing + * local/OAuth account, and avoids duplicate accounts for email-less users. + */ +@Service +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAuthService { + + /** + * An LDAP identity resolved and verified against the directory without provisioning a local + * account. Used by the explicit bind flow to attach an LDAP identity to an existing account. + */ + public record LdapIdentity(String username, String subject, String email, String displayName) { + } + + private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class); + private static final String LDAP_PROVIDER = "ldap"; + // Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters. + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{3,64}$"); + // LDAP attribute names only allow ASCII letters, digits, and hyphens. + private static final Pattern ATTRIBUTE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$"); + + /** + * Signals that a concurrent first login wrote the same LDAP subject binding first. The + * provisioning (sub-)transaction has already been rolled back cleanly; callers must + * re-resolve the identity by subject in a fresh transaction. + */ + private static final class LdapBindingRaceException extends RuntimeException { + LdapBindingRaceException(Throwable cause) { + super(cause); + } + } + + private final LdapProperties ldapProperties; + private final LdapContextSource ldapContextSource; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final IdentityBindingRepository identityBindingRepository; + private final EntityManager entityManager; + /** + * Programmatic REQUIRES_NEW template for account/binding provisioning. A separate physical + * transaction is required so a failed concurrent insert (which marks its transaction + * rollback-only) can be rolled back cleanly and the identity re-resolved in a fresh + * transaction. Spring's annotation-driven propagation cannot be used here because the + * provisioning methods are invoked internally (self-invocation bypasses the proxy). + */ + private final TransactionTemplate ldapProvisioningTx; + + /** + * Striped monitors that serialize first-login email-collision checks across concurrent + * requests in this JVM. {@code user_account.email} intentionally has no UNIQUE constraint + * (other identity flows may share an email), so the application-level check-and-insert for + * the same email must be serialized to stop two distinct LDAP subjects from provisioning two + * accounts with the same email at the same time. A fixed stripe count keeps memory constant. + * Multi-instance deployments need an equivalent cross-node lock (database advisory lock or a + * unique index with the other flows migrated) on top of this. + */ + private final Object[] emailLockStripes = new Object[64]; + + public LdapAuthService(LdapProperties ldapProperties, + LdapContextSource ldapContextSource, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, + IdentityBindingRepository identityBindingRepository, + EntityManager entityManager, + PlatformTransactionManager transactionManager) { + this.ldapProperties = ldapProperties; + this.ldapContextSource = ldapContextSource; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.identityBindingRepository = identityBindingRepository; + this.entityManager = entityManager; + this.ldapProvisioningTx = new TransactionTemplate(transactionManager); + this.ldapProvisioningTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + for (int i = 0; i < emailLockStripes.length; i++) { + emailLockStripes[i] = new Object(); + } + } + + /** + * Authenticates a user against the LDAP server. + * If the user doesn't exist in the local database, creates a new user based on LDAP attributes. + * + * @param username the username + * @param password the password + * @return PlatformPrincipal if authentication succeeds + * @throws AuthFlowException if authentication fails + */ + public PlatformPrincipal login(String username, String password) { + log.debug("Starting LDAP authentication for username: {}", username); + + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + + // Validate all LDAP attribute names that flow into JNDI calls to prevent filter/attribute + // injection via operator misconfiguration. These names are operator-controlled, not user input. + validateAttributeNames(); + + log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}", + safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase(), + ldapProperties.getUserSearchBase(), + ldapProperties.getUserSearchAttribute()); + + // First, try to find the user in LDAP and authenticate + Attributes userAttributes = authenticateAndFetch(username, password); + + // Find or create local user account anchored on the stable LDAP subject. Account and + // binding creation run in their own (sub-)transaction; a concurrent first login for the + // same subject is recovered by re-resolving the identity in a fresh transaction. When the + // directory entry carries an email, the check-and-insert of that email is additionally + // serialized per email (striped monitor) so two different subjects cannot both pass the + // collision check and provision duplicate accounts simultaneously. + log.debug("Finding or creating local user account for username: {}", username); + String email = getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()); + UserAccount user = (email == null || email.isEmpty()) + ? provisionUser(username, userAttributes) + : provisionUserSerializedByEmail(username, userAttributes, email); + + // Check if user can login (status check) + log.debug("Checking user status for user: {}, status: {}", username, user.getStatus()); + ensureUserCanLogin(user); + + log.debug("LDAP authentication successful for username: {}", username); + return buildPrincipal(user); + } + + /** + * Resolves and verifies an LDAP identity (search, bind, attribute read) without provisioning + * a local account or identity binding. Serves the explicit account-binding flow: the caller + * has already authenticated (or is being authenticated) and uses the LDAP credentials to + * prove ownership of the directory identity. + */ + public LdapIdentity resolveIdentity(String username, String password) { + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + validateAttributeNames(); + Attributes userAttributes = authenticateAndFetch(username, password); + String subject = getAttributeValue(userAttributes, ldapProperties.getSubjectAttribute()); + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + return new LdapIdentity( + username, + subject, + getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()), + resolveDisplayName(userAttributes, username) + ); + } + + /** + * Finds the user entry, verifies the password via a directory bind, and fetches the entry + * attributes. All errors are classified with the same semantics for login and binding. + */ + private Attributes authenticateAndFetch(String username, String password) { + String userDn = findUserDn(username); + log.debug("LDAP findUserDn result for {}: {}", username, userDn != null); + + if (userDn == null) { + log.warn("User {} not found in LDAP directory", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.userNotFound"); + } + + log.debug("Attempting LDAP bind for user DN: {}", userDn); + boolean authenticated = authenticateLdap(userDn, password); + log.debug("LDAP bind result for {}: {}", username, authenticated); + + if (!authenticated) { + log.warn("LDAP authentication failed for username: {}", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } + + log.debug("Fetching user attributes from LDAP for DN: {}", userDn); + Attributes userAttributes = getUserAttributes(userDn); + if (userAttributes == null) { + log.error("Failed to fetch user attributes from LDAP for DN: {}", userDn); + // Bind already succeeded, so the credentials are valid. This is a transient directory + // failure; surface a 503 with the directoryUnavailable message instead of masking it + // as a 401 "invalid credentials" (which would mislead the user about the password). + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } + return userAttributes; + } + + /** + * Provisions the local account and identity binding in a REQUIRES_NEW sub-transaction, + * recovering from a concurrent same-subject first login by re-resolving the existing account. + */ + private UserAccount provisionUser(String username, Attributes userAttributes) { + try { + return ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes)); + } catch (LdapBindingRaceException e) { + log.warn("Concurrent first login detected for LDAP subject of username {}; resolving existing account", username); + return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username)); + } + } + + /** + * Serializes the check-and-insert of a non-empty LDAP email so concurrent first logins from + * different subjects sharing one email cannot both provision an account. The monitor is held + * until the provisioning sub-transaction commits, so the second caller observes the first + * account and receives the regular 409 email-conflict result. + */ + private UserAccount provisionUserSerializedByEmail(String username, Attributes userAttributes, String email) { + Object stripe = emailLockStripes[Math.floorMod(email.toLowerCase(Locale.ROOT).hashCode(), emailLockStripes.length)]; + synchronized (stripe) { + return provisionUser(username, userAttributes); + } + } + + /** + * Ensures the user account status allows login. + */ + private void ensureUserCanLogin(UserAccount user) { + if (user.getStatus() == UserStatus.DISABLED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); + } + if (user.getStatus() == UserStatus.PENDING) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); + } + if (user.getStatus() == UserStatus.MERGED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + } + } + + /** + * Finds the DN (Distinguished Name) of a user in LDAP. + */ + private String findUserDn(String username) { + // LDAP injection prevention: validate username before search + if (!isValidUsername(username)) { + log.warn("Invalid username format for LDAP search: {}", username); + return null; + } + String searchAttr = ldapProperties.getUserSearchAttribute(); + + DirContext ctx = null; + javax.naming.NamingEnumeration results = null; + try { + ctx = createLdapContext(); + String searchFilter = "(" + searchAttr + "={0})"; + String searchBase = ldapProperties.getUserSearchBase().isEmpty() + ? ldapProperties.getBase() + : ldapProperties.getUserSearchBase() + "," + ldapProperties.getBase(); + + SearchControls searchControls = new SearchControls(); + searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE); + searchControls.setReturningAttributes(new String[0]); + + results = ctx.search(searchBase, searchFilter, new Object[]{username}, searchControls); + + if (results.hasMore()) { + SearchResult result = results.next(); + return result.getNameInNamespace(); + } + return null; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (AuthenticationException e) { + log.warn("LDAP bind authentication failed while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } catch (NamingException e) { + log.warn("LDAP naming error while searching for user {}: {}", username, e.getMessage()); + return null; + } finally { + // Close NamingEnumeration to prevent resource leaks + if (results != null) { + try { + results.close(); + } catch (Exception e) { + log.warn("Failed to close LDAP search results", e); + } + } + closeContext(ctx); + } + } + + /** + * Creates an LDAP context for searching. + */ + private DirContext createLdapContext() throws NamingException { + // Bind-account context for directory searches/reads; delegates to the shared factory. + return createLdapContext(null, null); + } + + /** + * Creates an LDAP context authenticated with the given principal/credentials. When both are + {@code null}, falls back to the configured bind account (or anonymous if none is set). This is + the single place that obtains contexts, so connection/timeout/TLS settings configured on the + shared {@link LdapContextSource} stay consistent across search, bind, and attribute-read + operations. + */ + private DirContext createLdapContext(String principal, String credentials) throws NamingException { + try { + // Explicit principal/credentials take precedence; otherwise use the configured bind account. + String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername(); + String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword(); + if (bindPrincipal != null && !bindPrincipal.isEmpty()) { + return ldapContextSource.getContext(bindPrincipal, bindCredentials); + } + // No bind account configured: anonymous read context for directory searches/reads. + return ldapContextSource.getReadOnlyContext(); + } catch (org.springframework.ldap.CommunicationException e) { + // Spring LDAP wraps JNDI failures into unchecked org.springframework.ldap.* exceptions; + // translate them back so the callers' javax.naming.* classification stays unchanged. + throw (javax.naming.CommunicationException) new javax.naming.CommunicationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.AuthenticationException e) { + throw (javax.naming.AuthenticationException) new javax.naming.AuthenticationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.NameNotFoundException e) { + throw (javax.naming.NameNotFoundException) new javax.naming.NameNotFoundException(e.getMessage()) + .initCause(e); + } + } + + /** + * Closes an LDAP context. + */ + private void closeContext(DirContext ctx) { + if (ctx != null) { + try { + ctx.close(); + } catch (NamingException e) { + // Ignore + } + } + } + + /** + * Safely extracts host:port from LDAP URL for logging, avoiding credential exposure. + * Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host + */ + public static String safeLogHost(String url) { + if (url == null || url.isEmpty()) { + return ""; + } + try { + String withoutProtocol = url.replaceFirst("^ldaps?://", ""); + int atIndex = withoutProtocol.indexOf('@'); + if (atIndex > 0) { + withoutProtocol = withoutProtocol.substring(atIndex + 1); + } + // IPv6 literal: ldap://[::1]:389 — keep the bracketed address plus port + int bracketEnd = withoutProtocol.indexOf(']'); + if (bracketEnd > 0) { + return withoutProtocol.substring(0, Math.min(bracketEnd + 1, withoutProtocol.length())); + } + int slashIndex = withoutProtocol.indexOf('/'); + String hostPort = slashIndex > 0 ? withoutProtocol.substring(0, slashIndex) : withoutProtocol; + return hostPort; + } catch (Exception e) { + return "[url-parse-error]"; + } + } + + /** + * Returns whether the throwable chain indicates a TLS/trust failure (LDAPS handshake or + * certificate validation). JNDI wraps TLS failures in a {@link CommunicationException}, so + * without this check a certificate problem is indistinguishable from an unreachable directory. + */ + static boolean isTlsFailure(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof SSLException || c instanceof CertificateException + || c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) { + return true; + } + } + return false; + } + + /** + * Authenticates a user against the LDAP server using their DN and password. + */ + private boolean authenticateLdap(String userDn, String password) { + // Reject null/empty passwords explicitly: a null value would otherwise reach + // Hashtable.put (NPE -> 500), and an empty password could be accepted by directories + // that allow anonymous/weak binds. Treat both as credential failures (401). + if (password == null || password.isEmpty()) { + log.debug("LDAP bind rejected: empty password for DN {}", userDn); + return false; + } + DirContext ctx = null; + try { + // Bind as the authenticating user to verify credentials (shared factory handles env). + ctx = createLdapContext(userDn, password); + return true; + } catch (AuthenticationException e) { + // Invalid credentials — expected, return false to signal auth failure + return false; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (NamingException e) { + log.warn("LDAP naming error while authenticating DN {}: {}", userDn, e.getMessage()); + return false; + } finally { + closeContext(ctx); + } + } + + /** + * Retrieves user attributes from LDAP. + */ + private Attributes getUserAttributes(String userDn) { + DirContext ctx = null; + try { + // Read attributes via the bind-account context (shared factory handles env/timeout). + ctx = createLdapContext(); + Attributes attrs; + try { + // Request user attributes ("*") and operational attributes ("+") so stable + // directory identifiers (OpenLDAP entryUUID, AD objectGUID) are included in the + // response. Without the explicit request, operational attributes are omitted and + // the subject key would be null on every login. + attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"}); + } catch (NamingException e) { + // Some directories reject the "*"/"+" attribute-request syntax; fall back to the + // default attribute set for protocol/request-level failures only. Connection and + // authentication failures must not trigger a retry — the outer catch blocks + // classify them as TLS error vs directory-unavailable vs bind failure. + if (e instanceof CommunicationException || e instanceof AuthenticationException) { + throw e; + } + attrs = ctx.getAttributes(new LdapName(userDn)); + } + return attrs; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (Exception e) { + log.warn("Failed to fetch user attributes from LDAP for DN {}: {}", userDn, e.getMessage()); + return null; + } finally { + closeContext(ctx); + } + } + + /** + * Finds an existing LDAP user or creates a new one based on LDAP attributes. + * + * Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID) + * via {@link IdentityBinding}, not on the user's email. This prevents: + * + * Silent account merging when an LDAP email collides with a local/OAuth account + * Duplicate accounts for email-less LDAP users on repeated logins + * + */ + UserAccount findOrCreateLdapUser(String username, Attributes attributes) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + // Bind already succeeded. The directory entry lacks the configured subject attribute, + // which is a configuration/schema issue the user cannot fix. Surface a 503 with the + // invalidConfiguration message instead of a 401 that would look like a wrong password. + // Operators can locate the cause via the log line above. + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + + // Anchor on the stable LDAP subject: an existing binding means this identity is already known. + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + + if (binding != null) { + // Returning user — refresh attributes from the directory on each login. + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // The bound account no longer exists (e.g. deleted by an administrator). The stale + // binding would otherwise block this subject forever with a 500. Remove it and fall + // through to the first-login provisioning path, which creates a fresh account for + // the directory identity. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + } + + // First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth + // account that happens to share the same email — that would be a privilege escalation. + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(); + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null) { + // The email already belongs to another account. Refuse to silently create a second + // account (two distinct LDAP subjects sharing one email would both map to it, and + // user_account.email has no UNIQUE constraint, so this would otherwise happen + // silently). This covers both cross-provider collisions and the same-issuer case + // (a different LDAP subject under the same email). If an entry's stable subject + // legitimately changes (e.g. after an AD objectGUID migration), an administrator + // must remove the stale binding before the new subject can log in. + log.warn("LDAP user {} email {} collides with an existing account {} (subject differs); refusing to create a duplicate account", + username, normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + + // Create a new user account. The placeholder email is only used to satisfy the NOT NULL + // constraint and never serves as an identity key. + String normalizedEmail = email != null && !email.isEmpty() + ? email.toLowerCase() + : LDAP_PROVIDER + ":" + username + "@internal"; + + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + displayName, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + user = userAccountRepository.save(user); + globalNamespaceMembershipService.ensureMember(user.getId()); + + try { + IdentityBinding newBinding = new IdentityBinding(user.getId(), LDAP_PROVIDER, subject, username); + // saveAndFlush surfaces the unique (provider_code, subject) constraint immediately, so + // a concurrent first login for the same subject is detected here and the whole + // sub-transaction (account + membership + binding) is rolled back. + identityBindingRepository.saveAndFlush(newBinding); + } catch (DataIntegrityViolationException e) { + // A concurrent first login for the same subject committed its binding first. Clear the + // failed persist state (otherwise Hibernate throws AssertionFailure while rolling back + // the session), then signal the race so the identity is re-resolved in a fresh + // transaction. The insert failure already marked this sub-transaction rollback-only, + // so it can never be committed with the re-resolved state. + entityManager.clear(); + throw new LdapBindingRaceException(e); + } + + return user; + } + + /** + * Re-resolves a returning LDAP user in a fresh transaction after a concurrent first-login + * race. Called only when the racing transaction has committed its binding, so the lookup is + * guaranteed to hit the existing account. + */ + UserAccount resolveReturningUser(Attributes attributes, String username) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + if (binding == null) { + // The racing transaction rolled back after all (rare), or the binding was cleaned up + // concurrently. Fall back to the regular provisioning path. + throw new LdapBindingRaceException(new IllegalStateException("No binding found after race for " + subject)); + } + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // Stale binding for a deleted account: remove it and retry provisioning from scratch. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + throw new LdapBindingRaceException(new IllegalStateException("Stale binding removed for " + subject)); + } + + private String resolveDisplayName(Attributes attributes, String username) { + String displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameAttribute()); + if (displayName == null || displayName.isEmpty()) { + displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameFallbackAttribute()); + } + if (displayName == null || displayName.isEmpty()) { + displayName = username; + } + return displayName; + } + + private void updateFromAttributes(UserAccount user, String displayName, String email) { + if (displayName != null && !displayName.isEmpty()) { + user.setDisplayName(displayName); + } + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(Locale.ROOT); + // A bound user may update their own email, but must never silently adopt an email + // that already belongs to a different account (same rule as first login). + if (user.getEmail() == null || !user.getEmail().equalsIgnoreCase(normalizedEmail)) { + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null && !existingByEmail.getId().equals(user.getId())) { + log.warn("LDAP user {} email {} collides with another account {} on refresh; refusing to update", + user.getDisplayName(), normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + user.setEmail(normalizedEmail); + } + } + + /** + * Gets a string attribute value from LDAP attributes. + */ + private String getAttributeValue(Attributes attributes, String attrName) { + try { + Attribute attr = attributes.get(attrName); + if (attr != null && attr.get() != null) { + Object value = attr.get(); + // Active Directory stores stable identifiers such as objectGUID / objectSid as + // binary (OctetString). JNDI returns these as byte[], whose toString() yields an + // unstable "[B@" — making every login look like a new identity. + // Convert binary values to a stable hexadecimal representation (mixed-endian GUID + // layout for 16-byte values) so the subject key remains stable across logins. + if (value instanceof byte[] bytes) { + return toStableGuidString(bytes); + } + return value.toString(); + } + } catch (Exception e) { + // Ignore and return null + } + return null; + } + + /** + * Converts a binary attribute value into a stable string suitable for use as an identity + * subject. Active Directory objectGUID is a 16-byte mixed-endian GUID; rearranging it into + * the canonical 8-4-4-4-12 hex layout yields the same string .NET/AD display, which is stable + * across JVM restarts and connections. Non-16-byte binaries fall back to plain hex so the + * value is still deterministic. + */ + private static String toStableGuidString(byte[] bytes) { + if (bytes.length == 16) { + // AD objectGUID layout: little-endian uint32, little-endian uint16 x2, big-endian rest. + return String.format("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[3] & 0xff, bytes[2] & 0xff, bytes[1] & 0xff, bytes[0] & 0xff, + bytes[5] & 0xff, bytes[4] & 0xff, + bytes[7] & 0xff, bytes[6] & 0xff, + bytes[8] & 0xff, bytes[9] & 0xff, + bytes[10] & 0xff, bytes[11] & 0xff, bytes[12] & 0xff, + bytes[13] & 0xff, bytes[14] & 0xff, bytes[15] & 0xff); + } + // Non-GUID binary attribute: deterministic plain hex so the value stays stable. + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * Builds a PlatformPrincipal from a UserAccount. + */ + private PlatformPrincipal buildPrincipal(UserAccount user) { + Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + "ldap", + roles + ); + } + + /** + * Validates username to prevent LDAP injection attacks. + * Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), + * 3-64 characters. + */ + private boolean isValidUsername(String username) { + if (username == null || username.isEmpty()) { + return false; + } + return USERNAME_PATTERN.matcher(username).matches(); + } + + /** + * Validates all operator-configured LDAP attribute names that flow into JNDI calls. + * Rejects names that are null or fail the LDAP attribute-name pattern, preventing + * filter/attribute injection via misconfiguration before any directory call is made. + */ + private void validateAttributeNames() { + String[] attrNames = { + ldapProperties.getUserSearchAttribute(), + ldapProperties.getSubjectAttribute(), + ldapProperties.getDisplayNameAttribute(), + ldapProperties.getDisplayNameFallbackAttribute(), + ldapProperties.getEmailAttribute() + }; + for (String name : attrNames) { + if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) { + log.error("Invalid LDAP attribute name configured: {}", name); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.invalidConfiguration"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java new file mode 100644 index 000000000..9f7e5ec4d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.ldap; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import java.util.UUID; + +/** + * Installs a custom trust store for LDAPS by merging it with the JVM's default trust store and + * pointing the {@code javax.net.ssl.trustStore*} system properties at the merged store. + * + * This is the only reliable injection point for LDAPS trust in a JNDI-based client: the JDK + * LDAP provider builds its sockets from the JVM-wide SSL configuration and ignores both + * {@code javax.net.ssl.trustStore*} context-environment entries and the + * {@code java.naming.ldap.factory.socket} property (verified against the JDK 21 implementation), + * and Spring LDAP 3.x no longer offers a per-context socket factory. Because the JSSE default + * SSLContext is cached on first use, the installer must run before any TLS connection is made; + * it is invoked from an {@code EnvironmentPostProcessor} during application startup. + * + * Merging (rather than replacing) keeps public-CA connectivity intact: the resulting store + * contains the JVM defaults plus the configured internal CA. + */ +public final class LdapTrustStoreInstaller { + + private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit"; + + private LdapTrustStoreInstaller() { + } + + /** + * Merges the configured custom trust store into the JVM default trust store and installs the + * result through the {@code javax.net.ssl.trustStore*} system properties. + * + * @param customPath the custom trust store path + * @param customPassword the custom trust store password (may be empty) + * @param customType the custom trust store type (JKS, PKCS12, ...) + */ + public static void install(String customPath, String customPassword, String customType) { + try { + KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType()); + merged.load(null, null); + copyCertificateEntries(loadDefaultTrustStore(), merged); + copyCertificateEntries(loadCustomTrustStore(customPath, customPassword, customType), merged); + + String password = "skillhub-" + UUID.randomUUID(); + Path file = Files.createTempFile("skillhub-truststore", ".p12"); + try (OutputStream out = Files.newOutputStream(file)) { + merged.store(out, password.toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", file.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", password); + System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType()); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to install LDAPS trust store from " + customPath, e); + } + } + + private static KeyStore loadDefaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + String systemPassword = System.getProperty("javax.net.ssl.trustStorePassword", + DEFAULT_TRUSTSTORE_PASSWORD); + String systemType = System.getProperty("javax.net.ssl.trustStoreType", + KeyStore.getDefaultType()); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(systemType); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, systemPassword.toCharArray()); + } + return keyStore; + } + + private static KeyStore loadCustomTrustStore(String path, String password, String type) throws Exception { + KeyStore keyStore = KeyStore.getInstance(type); + try (InputStream in = Files.newInputStream(Path.of(path))) { + keyStore.load(in, password.toCharArray()); + } + return keyStore; + } + + private static void copyCertificateEntries(KeyStore source, KeyStore target) throws Exception { + Enumeration aliases = source.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (source.isCertificateEntry(alias)) { + target.setCertificateEntry(alias, source.getCertificate(alias)); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867b..512515617 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.local; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -16,9 +18,14 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; /** @@ -28,6 +35,8 @@ @Service public class LocalAuthService { + private static final Logger log = LoggerFactory.getLogger(LocalAuthService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; @@ -44,6 +53,15 @@ public class LocalAuthService { private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final LdapProperties ldapProperties; + private final ObjectProvider ldapAuthServiceProvider; + /** + * Transaction boundary for the local-credential login path. The {@code login} method itself + * is intentionally NOT transactional: the LDAP fallback performs directory network calls + * (up to connect+read timeout) and must not hold a database connection/transaction open + * while blocked on the directory. Only the local credential database work is wrapped. + */ + private final TransactionTemplate transactionTemplate; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -51,7 +69,10 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LdapProperties ldapProperties, + ObjectProvider ldapAuthServiceProvider, + PlatformTransactionManager transactionManager) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +80,9 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.ldapProperties = ldapProperties; + this.ldapAuthServiceProvider = ldapAuthServiceProvider; + this.transactionTemplate = new TransactionTemplate(transactionManager); } /** @@ -107,33 +131,72 @@ public PlatformPrincipal register(String username, String password, String email /** * Authenticates a local account and returns the principal snapshot used to * establish a web session. + * If the user is not found locally, falls back to LDAP authentication if enabled. */ - @Transactional public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); - LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) - .orElse(null); + LocalCredential credential = transactionTemplate.execute(status -> + credentialRepository.findByUsernameIgnoreCase(normalizedUsername).orElse(null)); if (credential == null) { + // Blur timing to prevent username enumeration passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + + // Fallback to LDAP authentication if enabled + if (ldapProperties.isEnabled()) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username); + throw invalidCredentials(); + } + log.debug("Local user not found, attempting LDAP authentication for username: {}", username); + log.debug("LDAP enabled: {}, host: {}, base: {}", + ldapProperties.isEnabled(), + LdapAuthService.safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase()); + try { + PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password); + log.debug("LDAP authentication successful for username: {}", username); + return ldapPrincipal; + } catch (AuthFlowException e) { + log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage()); + // Propagate account-state, availability, and email-conflict errors instead of + // masking them as invalid credentials, so the frontend can show the right message. + // A 409 emailConflict is only reached after a successful LDAP bind, so the + // credentials are valid; masking it as 401 would mislead the user into thinking + // their password is wrong. Only genuine credential failures (userNotFound / + // invalidCredentials) fall back to the generic response to avoid enumeration. + HttpStatus status = e.getStatus(); + if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE + || status == HttpStatus.CONFLICT) { + throw e; + } + throw invalidCredentials(); + } + } else { + log.debug("LDAP authentication is disabled, rejecting login for username: {}", username); + } + throw invalidCredentials(); } - UserAccount user = userAccountRepository.findById(credential.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + return transactionTemplate.execute(status -> { + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); - ensureUserCanLogin(user); - ensureNotLocked(credential); + ensureUserCanLogin(user); + ensureNotLocked(credential); - if (!passwordEncoder.matches(password, credential.getPasswordHash())) { - handleFailedLogin(credential); - throw invalidCredentials(); - } + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } - credential.setFailedAttempts(0); - credential.setLockedUntil(null); - credentialRepository.save(credential); - return buildPrincipal(user); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + }); } /** diff --git a/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 000000000..e5ec5e04e --- /dev/null +++ b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java new file mode 100644 index 000000000..9c8915aec --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Startup configuration validation for the LDAP properties block. + */ +class LdapPropertiesTest { + + @Test + void validate_requiresUrlAndBase_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("skillhub.ldap.url"); + } + + @Test + void validate_rejectsNonPositiveTimeouts_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://localhost:389"); + props.setBase("dc=example,dc=org"); + props.setConnectTimeoutMillis(0); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect-timeout-millis"); + } + + @Test + void validate_skipsChecks_whenDisabled() { + LdapProperties props = new LdapProperties(); + assertThatCode(props::validate).doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java new file mode 100644 index 000000000..f15892258 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java @@ -0,0 +1,358 @@ + +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import java.lang.reflect.Method; +import java.util.Optional; +import jakarta.persistence.EntityManager; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Behavior-level unit tests for {@link LdapAuthService} identity provisioning. + * + * These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + entityManager = mock(EntityManager.class); + transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + ldapContextSource, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository, + entityManager, + transactionManager); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception { + // Given — the binding points to an account that no longer exists + IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(stale)); + given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — the same subject logs in again + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — the stale binding is removed and a fresh account is provisioned + assertThat(created.getEmail()).isEqualTo(EMAIL); + assertThat(created.getId()).isNotEqualTo("usr_deleted"); + verify(identityBindingRepository).delete(stale); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration"); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception { + // Given — the LDAP subject is bound, but the directory now reports an email that already + // belongs to a different account. The refresh must refuse to adopt it (409), matching the + // first-login email-collision rule. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other)); + + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice")); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + // The user's own email must remain untouched. + assertThat(existing.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void returningUser_sameEmail_isAllowedToRefresh() throws Exception { + // Given — the directory reports the same email the bound account already owns; the + // collision lookup must exclude the user's own account. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith")); + + assertThat(result.getId()).isEqualTo(userId); + assertThat(result.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsCertPathValidatorWithoutSslException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 000000000..d64928bc8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + * Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java new file mode 100644 index 000000000..f509138a0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LdapTrustStoreInstallerTest { + + @TempDir + Path tempDir; + + private String previousTrustStore; + private String previousPassword; + private String previousType; + + @AfterEach + void restoreSystemProperties() { + restore("javax.net.ssl.trustStore", previousTrustStore); + restore("javax.net.ssl.trustStorePassword", previousPassword); + restore("javax.net.ssl.trustStoreType", previousType); + } + + @Test + void install_mergesCustomTrustStoreIntoDefaults() throws Exception { + previousTrustStore = System.getProperty("javax.net.ssl.trustStore"); + previousPassword = System.getProperty("javax.net.ssl.trustStorePassword"); + previousType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Build a custom trust store containing one certificate copied from the JVM defaults. + KeyStore defaults = defaultTrustStore(); + String sourceAlias = firstCertificateAlias(defaults); + Path custom = tempDir.resolve("custom.p12"); + KeyStore customStore = KeyStore.getInstance("PKCS12"); + customStore.load(null, null); + customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias)); + try (OutputStream out = Files.newOutputStream(custom)) { + customStore.store(out, "changeit".toCharArray()); + } + + LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12"); + + String installedPath = System.getProperty("javax.net.ssl.trustStore"); + assertThat(installedPath).isNotBlank(); + KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType")); + try (InputStream in = Files.newInputStream(Path.of(installedPath))) { + installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray()); + } + assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue(); + assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue(); + } + + @Test + void install_withMissingFile_failsFast() { + assertThatThrownBy(() -> LdapTrustStoreInstaller.install( + tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to install LDAPS trust store"); + } + + private static KeyStore defaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, "changeit".toCharArray()); + } + return keyStore; + } + + private static String firstCertificateAlias(KeyStore keyStore) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isCertificateEntry(alias)) { + return alias; + } + } + throw new IllegalStateException("default trust store has no certificate entries"); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f43446..f8ba17171 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -9,9 +9,12 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +25,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,10 +55,25 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LdapProperties ldapProperties; + + @Mock + private LdapAuthService ldapAuthService; + + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + + @Mock + private org.springframework.transaction.PlatformTransactionManager transactionManager; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any())) + .thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class)); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -62,7 +81,10 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + ldapProperties, + ldapAuthServiceProvider, + transactionManager ); } @@ -161,6 +183,7 @@ void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -247,4 +270,116 @@ void register_rejectsBlankEmail() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.notBlank"); } + + @Test + void login_withUnknownUsername_fallsBackToLdap_whenEnabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null); + ldapUser.setStatus(UserStatus.ACTIVE); + PlatformPrincipal ldapPrincipal = new PlatformPrincipal( + "usr_ldap", + "ldapuser", + "ldapuser@example.com", + null, + "ldap", + Set.of("USER") + ); + + given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal); + + // When + var principal = service.login("ldapuser", "LdapPassword123!"); + + // Then + assertThat(principal.userId()).isEqualTo("usr_ldap"); + assertThat(principal.displayName()).isEqualTo("ldapuser"); + assertThat(principal.email()).isEqualTo("ldapuser@example.com"); + verify(ldapAuthService).login("ldapuser", "LdapPassword123!"); + } + + @Test + void login_withUnknownUsername_fails_whenLdapAuthenticationFails() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "WrongPassword")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed")); + + // When & Then + assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService).login("ldapuser", "WrongPassword"); + } + + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void login_withUnknownUsername_fails_whenLdapDisabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); + + // When & Then + assertThatThrownBy(() -> service.login("localuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService, never()).login(any(), any()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..d4ef31a55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + LdapBindRequest, ReviewSkillDetail, ReviewTask, PromotionTask, @@ -444,6 +445,18 @@ export const accountApi = { }, } +export const ldapApi = { + async bindIdentity(request: LdapBindRequest): Promise { + await fetchJson('/api/v1/auth/ldap/bind', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..48bca25c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -81,6 +81,11 @@ export interface MergeInitiateRequest { secondaryIdentifier: string } +export interface LdapBindRequest { + username: string + password: string +} + export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string diff --git a/web/src/features/auth/use-ldap-bind.ts b/web/src/features/auth/use-ldap-bind.ts new file mode 100644 index 000000000..b241e42a3 --- /dev/null +++ b/web/src/features/auth/use-ldap-bind.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { ldapApi } from '@/api/client' +import type { LdapBindRequest } from '@/api/types' + +/** + * Binds the LDAP identity verified by the given directory credentials to the currently + * authenticated account. + */ +export function useLdapBind() { + return useMutation({ + mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..2876fc272 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -757,7 +757,15 @@ "confirming": "Confirming...", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "ldapBindTitle": "Bind LDAP Account", + "ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.", + "ldapUsername": "LDAP Username", + "ldapPassword": "LDAP Password", + "ldapBinding": "Binding...", + "ldapBind": "Bind", + "ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP", + "ldapBindError": "LDAP binding failed" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..0d4b7eb5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -757,7 +757,15 @@ "confirming": "确认中...", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "ldapBindTitle": "绑定 LDAP 账号", + "ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。", + "ldapUsername": "LDAP 用户名", + "ldapPassword": "LDAP 密码", + "ldapBinding": "绑定中...", + "ldapBind": "绑定", + "ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录", + "ldapBindError": "LDAP 绑定失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a9336..6f76c7582 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useLdapBind } from '@/features/auth/use-ldap-bind' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input' */ export function AccountSettingsPage() { const { t } = useTranslation() + const [ldapUsername, setLdapUsername] = useState('') + const [ldapPassword, setLdapPassword] = useState('') + const [ldapStatusMessage, setLdapStatusMessage] = useState('') const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') const [verificationToken, setVerificationToken] = useState('') @@ -21,6 +25,25 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() const confirmMutation = useConfirmAccountMerge() + const ldapBindMutation = useLdapBind() + + /** + * Binds the LDAP identity proven by the given directory credentials to the current account. + */ + async function handleLdapBind(event: React.FormEvent) { + event.preventDefault() + setLdapStatusMessage('') + try { + await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword }) + setLdapUsername('') + setLdapPassword('') + setLdapStatusMessage(t('accounts.ldapBindSuccess')) + } catch (error) { + setLdapStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'), + ) + } + } /** * Starts the merge flow and surfaces the request id plus verification token @@ -77,6 +100,40 @@ export function AccountSettingsPage() { return ( + + + {t('accounts.ldapBindTitle')} + {t('accounts.ldapBindDesc')} + + + + + {t('accounts.ldapUsername')} + setLdapUsername(event.target.value)} + /> + + + {t('accounts.ldapPassword')} + setLdapPassword(event.target.value)} + /> + + + {ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')} + + + {ldapStatusMessage ? {ldapStatusMessage} : null} + + + {t('accounts.initiateTitle')} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 5144fdf67..c30af2eea 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -57,6 +57,30 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) + it('shows the server message for 403 errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员')) + + expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员') + }) + + it('shows the server message for 5xx errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试')) + + expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试') + }) + + it('falls back to generic text for 403/5xx without a server message', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403)) + + expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden')) + }) + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca2..a743c94d9 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -68,7 +68,9 @@ export function handleApiError(error: unknown): void { } if (status === 403) { - toast.error(i18n.t('apiError.forbidden')) + // The backend may attach a specific business message (e.g. a disabled account or an + // LDAP conflict); prefer it over the generic forbidden text. + toast.error(error.serverMessage || i18n.t('apiError.forbidden')) return } @@ -78,7 +80,9 @@ export function handleApiError(error: unknown): void { } if (status >= 500) { - toast.error(i18n.t('apiError.serverError')) + // The backend returns localized operational messages for service-level failures such as + // "directory unavailable" or "TLS misconfiguration"; surface them when present. + toast.error(error.serverMessage || i18n.t('apiError.serverError')) return }
+ * Identity is anchored on a stable directory identifier (entryUUID/objectGUID) via + * {@link IdentityBinding} (provider="ldap"), not on the user's email. This prevents + * silent account merging when an LDAP user's email collides with an existing + * local/OAuth account, and avoids duplicate accounts for email-less users. + */ +@Service +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") +public class LdapAuthService { + + /** + * An LDAP identity resolved and verified against the directory without provisioning a local + * account. Used by the explicit bind flow to attach an LDAP identity to an existing account. + */ + public record LdapIdentity(String username, String subject, String email, String displayName) { + } + + private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class); + private static final String LDAP_PROVIDER = "ldap"; + // Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters. + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{3,64}$"); + // LDAP attribute names only allow ASCII letters, digits, and hyphens. + private static final Pattern ATTRIBUTE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$"); + + /** + * Signals that a concurrent first login wrote the same LDAP subject binding first. The + * provisioning (sub-)transaction has already been rolled back cleanly; callers must + * re-resolve the identity by subject in a fresh transaction. + */ + private static final class LdapBindingRaceException extends RuntimeException { + LdapBindingRaceException(Throwable cause) { + super(cause); + } + } + + private final LdapProperties ldapProperties; + private final LdapContextSource ldapContextSource; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final IdentityBindingRepository identityBindingRepository; + private final EntityManager entityManager; + /** + * Programmatic REQUIRES_NEW template for account/binding provisioning. A separate physical + * transaction is required so a failed concurrent insert (which marks its transaction + * rollback-only) can be rolled back cleanly and the identity re-resolved in a fresh + * transaction. Spring's annotation-driven propagation cannot be used here because the + * provisioning methods are invoked internally (self-invocation bypasses the proxy). + */ + private final TransactionTemplate ldapProvisioningTx; + + /** + * Striped monitors that serialize first-login email-collision checks across concurrent + * requests in this JVM. {@code user_account.email} intentionally has no UNIQUE constraint + * (other identity flows may share an email), so the application-level check-and-insert for + * the same email must be serialized to stop two distinct LDAP subjects from provisioning two + * accounts with the same email at the same time. A fixed stripe count keeps memory constant. + * Multi-instance deployments need an equivalent cross-node lock (database advisory lock or a + * unique index with the other flows migrated) on top of this. + */ + private final Object[] emailLockStripes = new Object[64]; + + public LdapAuthService(LdapProperties ldapProperties, + LdapContextSource ldapContextSource, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, + IdentityBindingRepository identityBindingRepository, + EntityManager entityManager, + PlatformTransactionManager transactionManager) { + this.ldapProperties = ldapProperties; + this.ldapContextSource = ldapContextSource; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.identityBindingRepository = identityBindingRepository; + this.entityManager = entityManager; + this.ldapProvisioningTx = new TransactionTemplate(transactionManager); + this.ldapProvisioningTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + for (int i = 0; i < emailLockStripes.length; i++) { + emailLockStripes[i] = new Object(); + } + } + + /** + * Authenticates a user against the LDAP server. + * If the user doesn't exist in the local database, creates a new user based on LDAP attributes. + * + * @param username the username + * @param password the password + * @return PlatformPrincipal if authentication succeeds + * @throws AuthFlowException if authentication fails + */ + public PlatformPrincipal login(String username, String password) { + log.debug("Starting LDAP authentication for username: {}", username); + + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + + // Validate all LDAP attribute names that flow into JNDI calls to prevent filter/attribute + // injection via operator misconfiguration. These names are operator-controlled, not user input. + validateAttributeNames(); + + log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}", + safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase(), + ldapProperties.getUserSearchBase(), + ldapProperties.getUserSearchAttribute()); + + // First, try to find the user in LDAP and authenticate + Attributes userAttributes = authenticateAndFetch(username, password); + + // Find or create local user account anchored on the stable LDAP subject. Account and + // binding creation run in their own (sub-)transaction; a concurrent first login for the + // same subject is recovered by re-resolving the identity in a fresh transaction. When the + // directory entry carries an email, the check-and-insert of that email is additionally + // serialized per email (striped monitor) so two different subjects cannot both pass the + // collision check and provision duplicate accounts simultaneously. + log.debug("Finding or creating local user account for username: {}", username); + String email = getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()); + UserAccount user = (email == null || email.isEmpty()) + ? provisionUser(username, userAttributes) + : provisionUserSerializedByEmail(username, userAttributes, email); + + // Check if user can login (status check) + log.debug("Checking user status for user: {}, status: {}", username, user.getStatus()); + ensureUserCanLogin(user); + + log.debug("LDAP authentication successful for username: {}", username); + return buildPrincipal(user); + } + + /** + * Resolves and verifies an LDAP identity (search, bind, attribute read) without provisioning + * a local account or identity binding. Serves the explicit account-binding flow: the caller + * has already authenticated (or is being authenticated) and uses the LDAP credentials to + * prove ownership of the directory identity. + */ + public LdapIdentity resolveIdentity(String username, String password) { + if (!ldapProperties.isEnabled()) { + log.warn("LDAP authentication is not enabled"); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); + } + validateAttributeNames(); + Attributes userAttributes = authenticateAndFetch(username, password); + String subject = getAttributeValue(userAttributes, ldapProperties.getSubjectAttribute()); + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + return new LdapIdentity( + username, + subject, + getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()), + resolveDisplayName(userAttributes, username) + ); + } + + /** + * Finds the user entry, verifies the password via a directory bind, and fetches the entry + * attributes. All errors are classified with the same semantics for login and binding. + */ + private Attributes authenticateAndFetch(String username, String password) { + String userDn = findUserDn(username); + log.debug("LDAP findUserDn result for {}: {}", username, userDn != null); + + if (userDn == null) { + log.warn("User {} not found in LDAP directory", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.userNotFound"); + } + + log.debug("Attempting LDAP bind for user DN: {}", userDn); + boolean authenticated = authenticateLdap(userDn, password); + log.debug("LDAP bind result for {}: {}", username, authenticated); + + if (!authenticated) { + log.warn("LDAP authentication failed for username: {}", username); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } + + log.debug("Fetching user attributes from LDAP for DN: {}", userDn); + Attributes userAttributes = getUserAttributes(userDn); + if (userAttributes == null) { + log.error("Failed to fetch user attributes from LDAP for DN: {}", userDn); + // Bind already succeeded, so the credentials are valid. This is a transient directory + // failure; surface a 503 with the directoryUnavailable message instead of masking it + // as a 401 "invalid credentials" (which would mislead the user about the password). + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } + return userAttributes; + } + + /** + * Provisions the local account and identity binding in a REQUIRES_NEW sub-transaction, + * recovering from a concurrent same-subject first login by re-resolving the existing account. + */ + private UserAccount provisionUser(String username, Attributes userAttributes) { + try { + return ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes)); + } catch (LdapBindingRaceException e) { + log.warn("Concurrent first login detected for LDAP subject of username {}; resolving existing account", username); + return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username)); + } + } + + /** + * Serializes the check-and-insert of a non-empty LDAP email so concurrent first logins from + * different subjects sharing one email cannot both provision an account. The monitor is held + * until the provisioning sub-transaction commits, so the second caller observes the first + * account and receives the regular 409 email-conflict result. + */ + private UserAccount provisionUserSerializedByEmail(String username, Attributes userAttributes, String email) { + Object stripe = emailLockStripes[Math.floorMod(email.toLowerCase(Locale.ROOT).hashCode(), emailLockStripes.length)]; + synchronized (stripe) { + return provisionUser(username, userAttributes); + } + } + + /** + * Ensures the user account status allows login. + */ + private void ensureUserCanLogin(UserAccount user) { + if (user.getStatus() == UserStatus.DISABLED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); + } + if (user.getStatus() == UserStatus.PENDING) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); + } + if (user.getStatus() == UserStatus.MERGED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + } + } + + /** + * Finds the DN (Distinguished Name) of a user in LDAP. + */ + private String findUserDn(String username) { + // LDAP injection prevention: validate username before search + if (!isValidUsername(username)) { + log.warn("Invalid username format for LDAP search: {}", username); + return null; + } + String searchAttr = ldapProperties.getUserSearchAttribute(); + + DirContext ctx = null; + javax.naming.NamingEnumeration results = null; + try { + ctx = createLdapContext(); + String searchFilter = "(" + searchAttr + "={0})"; + String searchBase = ldapProperties.getUserSearchBase().isEmpty() + ? ldapProperties.getBase() + : ldapProperties.getUserSearchBase() + "," + ldapProperties.getBase(); + + SearchControls searchControls = new SearchControls(); + searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE); + searchControls.setReturningAttributes(new String[0]); + + results = ctx.search(searchBase, searchFilter, new Object[]{username}, searchControls); + + if (results.hasMore()) { + SearchResult result = results.next(); + return result.getNameInNamespace(); + } + return null; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (AuthenticationException e) { + log.warn("LDAP bind authentication failed while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } catch (NamingException e) { + log.warn("LDAP naming error while searching for user {}: {}", username, e.getMessage()); + return null; + } finally { + // Close NamingEnumeration to prevent resource leaks + if (results != null) { + try { + results.close(); + } catch (Exception e) { + log.warn("Failed to close LDAP search results", e); + } + } + closeContext(ctx); + } + } + + /** + * Creates an LDAP context for searching. + */ + private DirContext createLdapContext() throws NamingException { + // Bind-account context for directory searches/reads; delegates to the shared factory. + return createLdapContext(null, null); + } + + /** + * Creates an LDAP context authenticated with the given principal/credentials. When both are + {@code null}, falls back to the configured bind account (or anonymous if none is set). This is + the single place that obtains contexts, so connection/timeout/TLS settings configured on the + shared {@link LdapContextSource} stay consistent across search, bind, and attribute-read + operations. + */ + private DirContext createLdapContext(String principal, String credentials) throws NamingException { + try { + // Explicit principal/credentials take precedence; otherwise use the configured bind account. + String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername(); + String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword(); + if (bindPrincipal != null && !bindPrincipal.isEmpty()) { + return ldapContextSource.getContext(bindPrincipal, bindCredentials); + } + // No bind account configured: anonymous read context for directory searches/reads. + return ldapContextSource.getReadOnlyContext(); + } catch (org.springframework.ldap.CommunicationException e) { + // Spring LDAP wraps JNDI failures into unchecked org.springframework.ldap.* exceptions; + // translate them back so the callers' javax.naming.* classification stays unchanged. + throw (javax.naming.CommunicationException) new javax.naming.CommunicationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.AuthenticationException e) { + throw (javax.naming.AuthenticationException) new javax.naming.AuthenticationException(e.getMessage()) + .initCause(e); + } catch (org.springframework.ldap.NameNotFoundException e) { + throw (javax.naming.NameNotFoundException) new javax.naming.NameNotFoundException(e.getMessage()) + .initCause(e); + } + } + + /** + * Closes an LDAP context. + */ + private void closeContext(DirContext ctx) { + if (ctx != null) { + try { + ctx.close(); + } catch (NamingException e) { + // Ignore + } + } + } + + /** + * Safely extracts host:port from LDAP URL for logging, avoiding credential exposure. + * Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host + */ + public static String safeLogHost(String url) { + if (url == null || url.isEmpty()) { + return ""; + } + try { + String withoutProtocol = url.replaceFirst("^ldaps?://", ""); + int atIndex = withoutProtocol.indexOf('@'); + if (atIndex > 0) { + withoutProtocol = withoutProtocol.substring(atIndex + 1); + } + // IPv6 literal: ldap://[::1]:389 — keep the bracketed address plus port + int bracketEnd = withoutProtocol.indexOf(']'); + if (bracketEnd > 0) { + return withoutProtocol.substring(0, Math.min(bracketEnd + 1, withoutProtocol.length())); + } + int slashIndex = withoutProtocol.indexOf('/'); + String hostPort = slashIndex > 0 ? withoutProtocol.substring(0, slashIndex) : withoutProtocol; + return hostPort; + } catch (Exception e) { + return "[url-parse-error]"; + } + } + + /** + * Returns whether the throwable chain indicates a TLS/trust failure (LDAPS handshake or + * certificate validation). JNDI wraps TLS failures in a {@link CommunicationException}, so + * without this check a certificate problem is indistinguishable from an unreachable directory. + */ + static boolean isTlsFailure(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof SSLException || c instanceof CertificateException + || c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) { + return true; + } + } + return false; + } + + /** + * Authenticates a user against the LDAP server using their DN and password. + */ + private boolean authenticateLdap(String userDn, String password) { + // Reject null/empty passwords explicitly: a null value would otherwise reach + // Hashtable.put (NPE -> 500), and an empty password could be accepted by directories + // that allow anonymous/weak binds. Treat both as credential failures (401). + if (password == null || password.isEmpty()) { + log.debug("LDAP bind rejected: empty password for DN {}", userDn); + return false; + } + DirContext ctx = null; + try { + // Bind as the authenticating user to verify credentials (shared factory handles env). + ctx = createLdapContext(userDn, password); + return true; + } catch (AuthenticationException e) { + // Invalid credentials — expected, return false to signal auth failure + return false; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (NamingException e) { + log.warn("LDAP naming error while authenticating DN {}: {}", userDn, e.getMessage()); + return false; + } finally { + closeContext(ctx); + } + } + + /** + * Retrieves user attributes from LDAP. + */ + private Attributes getUserAttributes(String userDn) { + DirContext ctx = null; + try { + // Read attributes via the bind-account context (shared factory handles env/timeout). + ctx = createLdapContext(); + Attributes attrs; + try { + // Request user attributes ("*") and operational attributes ("+") so stable + // directory identifiers (OpenLDAP entryUUID, AD objectGUID) are included in the + // response. Without the explicit request, operational attributes are omitted and + // the subject key would be null on every login. + attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"}); + } catch (NamingException e) { + // Some directories reject the "*"/"+" attribute-request syntax; fall back to the + // default attribute set for protocol/request-level failures only. Connection and + // authentication failures must not trigger a retry — the outer catch blocks + // classify them as TLS error vs directory-unavailable vs bind failure. + if (e instanceof CommunicationException || e instanceof AuthenticationException) { + throw e; + } + attrs = ctx.getAttributes(new LdapName(userDn)); + } + return attrs; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (Exception e) { + log.warn("Failed to fetch user attributes from LDAP for DN {}: {}", userDn, e.getMessage()); + return null; + } finally { + closeContext(ctx); + } + } + + /** + * Finds an existing LDAP user or creates a new one based on LDAP attributes. + * + * Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID) + * via {@link IdentityBinding}, not on the user's email. This prevents: + * + * Silent account merging when an LDAP email collides with a local/OAuth account + * Duplicate accounts for email-less LDAP users on repeated logins + * + */ + UserAccount findOrCreateLdapUser(String username, Attributes attributes) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + // Bind already succeeded. The directory entry lacks the configured subject attribute, + // which is a configuration/schema issue the user cannot fix. Surface a 503 with the + // invalidConfiguration message instead of a 401 that would look like a wrong password. + // Operators can locate the cause via the log line above. + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration"); + } + + // Anchor on the stable LDAP subject: an existing binding means this identity is already known. + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + + if (binding != null) { + // Returning user — refresh attributes from the directory on each login. + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // The bound account no longer exists (e.g. deleted by an administrator). The stale + // binding would otherwise block this subject forever with a 500. Remove it and fall + // through to the first-login provisioning path, which creates a fresh account for + // the directory identity. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + } + + // First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth + // account that happens to share the same email — that would be a privilege escalation. + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(); + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null) { + // The email already belongs to another account. Refuse to silently create a second + // account (two distinct LDAP subjects sharing one email would both map to it, and + // user_account.email has no UNIQUE constraint, so this would otherwise happen + // silently). This covers both cross-provider collisions and the same-issuer case + // (a different LDAP subject under the same email). If an entry's stable subject + // legitimately changes (e.g. after an AD objectGUID migration), an administrator + // must remove the stale binding before the new subject can log in. + log.warn("LDAP user {} email {} collides with an existing account {} (subject differs); refusing to create a duplicate account", + username, normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + + // Create a new user account. The placeholder email is only used to satisfy the NOT NULL + // constraint and never serves as an identity key. + String normalizedEmail = email != null && !email.isEmpty() + ? email.toLowerCase() + : LDAP_PROVIDER + ":" + username + "@internal"; + + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + displayName, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + user = userAccountRepository.save(user); + globalNamespaceMembershipService.ensureMember(user.getId()); + + try { + IdentityBinding newBinding = new IdentityBinding(user.getId(), LDAP_PROVIDER, subject, username); + // saveAndFlush surfaces the unique (provider_code, subject) constraint immediately, so + // a concurrent first login for the same subject is detected here and the whole + // sub-transaction (account + membership + binding) is rolled back. + identityBindingRepository.saveAndFlush(newBinding); + } catch (DataIntegrityViolationException e) { + // A concurrent first login for the same subject committed its binding first. Clear the + // failed persist state (otherwise Hibernate throws AssertionFailure while rolling back + // the session), then signal the race so the identity is re-resolved in a fresh + // transaction. The insert failure already marked this sub-transaction rollback-only, + // so it can never be committed with the re-resolved state. + entityManager.clear(); + throw new LdapBindingRaceException(e); + } + + return user; + } + + /** + * Re-resolves a returning LDAP user in a fresh transaction after a concurrent first-login + * race. Called only when the racing transaction has committed its binding, so the lookup is + * guaranteed to hit the existing account. + */ + UserAccount resolveReturningUser(Attributes attributes, String username) { + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = resolveDisplayName(attributes, username); + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + if (binding == null) { + // The racing transaction rolled back after all (rare), or the binding was cleaned up + // concurrently. Fall back to the regular provisioning path. + throw new LdapBindingRaceException(new IllegalStateException("No binding found after race for " + subject)); + } + var existing = userAccountRepository.findById(binding.getUserId()); + if (existing.isPresent()) { + UserAccount user = existing.get(); + updateFromAttributes(user, displayName, email); + if (!username.equals(binding.getLoginName())) { + binding.setLoginName(username); + identityBindingRepository.save(binding); + } + return userAccountRepository.save(user); + } + // Stale binding for a deleted account: remove it and retry provisioning from scratch. + log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding", + subject, binding.getUserId()); + identityBindingRepository.delete(binding); + throw new LdapBindingRaceException(new IllegalStateException("Stale binding removed for " + subject)); + } + + private String resolveDisplayName(Attributes attributes, String username) { + String displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameAttribute()); + if (displayName == null || displayName.isEmpty()) { + displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameFallbackAttribute()); + } + if (displayName == null || displayName.isEmpty()) { + displayName = username; + } + return displayName; + } + + private void updateFromAttributes(UserAccount user, String displayName, String email) { + if (displayName != null && !displayName.isEmpty()) { + user.setDisplayName(displayName); + } + if (email != null && !email.isEmpty()) { + String normalizedEmail = email.toLowerCase(Locale.ROOT); + // A bound user may update their own email, but must never silently adopt an email + // that already belongs to a different account (same rule as first login). + if (user.getEmail() == null || !user.getEmail().equalsIgnoreCase(normalizedEmail)) { + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null && !existingByEmail.getId().equals(user.getId())) { + log.warn("LDAP user {} email {} collides with another account {} on refresh; refusing to update", + user.getDisplayName(), normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } + } + user.setEmail(normalizedEmail); + } + } + + /** + * Gets a string attribute value from LDAP attributes. + */ + private String getAttributeValue(Attributes attributes, String attrName) { + try { + Attribute attr = attributes.get(attrName); + if (attr != null && attr.get() != null) { + Object value = attr.get(); + // Active Directory stores stable identifiers such as objectGUID / objectSid as + // binary (OctetString). JNDI returns these as byte[], whose toString() yields an + // unstable "[B@" — making every login look like a new identity. + // Convert binary values to a stable hexadecimal representation (mixed-endian GUID + // layout for 16-byte values) so the subject key remains stable across logins. + if (value instanceof byte[] bytes) { + return toStableGuidString(bytes); + } + return value.toString(); + } + } catch (Exception e) { + // Ignore and return null + } + return null; + } + + /** + * Converts a binary attribute value into a stable string suitable for use as an identity + * subject. Active Directory objectGUID is a 16-byte mixed-endian GUID; rearranging it into + * the canonical 8-4-4-4-12 hex layout yields the same string .NET/AD display, which is stable + * across JVM restarts and connections. Non-16-byte binaries fall back to plain hex so the + * value is still deterministic. + */ + private static String toStableGuidString(byte[] bytes) { + if (bytes.length == 16) { + // AD objectGUID layout: little-endian uint32, little-endian uint16 x2, big-endian rest. + return String.format("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[3] & 0xff, bytes[2] & 0xff, bytes[1] & 0xff, bytes[0] & 0xff, + bytes[5] & 0xff, bytes[4] & 0xff, + bytes[7] & 0xff, bytes[6] & 0xff, + bytes[8] & 0xff, bytes[9] & 0xff, + bytes[10] & 0xff, bytes[11] & 0xff, bytes[12] & 0xff, + bytes[13] & 0xff, bytes[14] & 0xff, bytes[15] & 0xff); + } + // Non-GUID binary attribute: deterministic plain hex so the value stays stable. + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * Builds a PlatformPrincipal from a UserAccount. + */ + private PlatformPrincipal buildPrincipal(UserAccount user) { + Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + "ldap", + roles + ); + } + + /** + * Validates username to prevent LDAP injection attacks. + * Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), + * 3-64 characters. + */ + private boolean isValidUsername(String username) { + if (username == null || username.isEmpty()) { + return false; + } + return USERNAME_PATTERN.matcher(username).matches(); + } + + /** + * Validates all operator-configured LDAP attribute names that flow into JNDI calls. + * Rejects names that are null or fail the LDAP attribute-name pattern, preventing + * filter/attribute injection via misconfiguration before any directory call is made. + */ + private void validateAttributeNames() { + String[] attrNames = { + ldapProperties.getUserSearchAttribute(), + ldapProperties.getSubjectAttribute(), + ldapProperties.getDisplayNameAttribute(), + ldapProperties.getDisplayNameFallbackAttribute(), + ldapProperties.getEmailAttribute() + }; + for (String name : attrNames) { + if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) { + log.error("Invalid LDAP attribute name configured: {}", name); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.invalidConfiguration"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java new file mode 100644 index 000000000..9f7e5ec4d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.ldap; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import java.util.UUID; + +/** + * Installs a custom trust store for LDAPS by merging it with the JVM's default trust store and + * pointing the {@code javax.net.ssl.trustStore*} system properties at the merged store. + * + * This is the only reliable injection point for LDAPS trust in a JNDI-based client: the JDK + * LDAP provider builds its sockets from the JVM-wide SSL configuration and ignores both + * {@code javax.net.ssl.trustStore*} context-environment entries and the + * {@code java.naming.ldap.factory.socket} property (verified against the JDK 21 implementation), + * and Spring LDAP 3.x no longer offers a per-context socket factory. Because the JSSE default + * SSLContext is cached on first use, the installer must run before any TLS connection is made; + * it is invoked from an {@code EnvironmentPostProcessor} during application startup. + * + * Merging (rather than replacing) keeps public-CA connectivity intact: the resulting store + * contains the JVM defaults plus the configured internal CA. + */ +public final class LdapTrustStoreInstaller { + + private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit"; + + private LdapTrustStoreInstaller() { + } + + /** + * Merges the configured custom trust store into the JVM default trust store and installs the + * result through the {@code javax.net.ssl.trustStore*} system properties. + * + * @param customPath the custom trust store path + * @param customPassword the custom trust store password (may be empty) + * @param customType the custom trust store type (JKS, PKCS12, ...) + */ + public static void install(String customPath, String customPassword, String customType) { + try { + KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType()); + merged.load(null, null); + copyCertificateEntries(loadDefaultTrustStore(), merged); + copyCertificateEntries(loadCustomTrustStore(customPath, customPassword, customType), merged); + + String password = "skillhub-" + UUID.randomUUID(); + Path file = Files.createTempFile("skillhub-truststore", ".p12"); + try (OutputStream out = Files.newOutputStream(file)) { + merged.store(out, password.toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", file.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", password); + System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType()); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to install LDAPS trust store from " + customPath, e); + } + } + + private static KeyStore loadDefaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + String systemPassword = System.getProperty("javax.net.ssl.trustStorePassword", + DEFAULT_TRUSTSTORE_PASSWORD); + String systemType = System.getProperty("javax.net.ssl.trustStoreType", + KeyStore.getDefaultType()); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(systemType); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, systemPassword.toCharArray()); + } + return keyStore; + } + + private static KeyStore loadCustomTrustStore(String path, String password, String type) throws Exception { + KeyStore keyStore = KeyStore.getInstance(type); + try (InputStream in = Files.newInputStream(Path.of(path))) { + keyStore.load(in, password.toCharArray()); + } + return keyStore; + } + + private static void copyCertificateEntries(KeyStore source, KeyStore target) throws Exception { + Enumeration aliases = source.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (source.isCertificateEntry(alias)) { + target.setCertificateEntry(alias, source.getCertificate(alias)); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867b..512515617 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.local; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -16,9 +18,14 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; /** @@ -28,6 +35,8 @@ @Service public class LocalAuthService { + private static final Logger log = LoggerFactory.getLogger(LocalAuthService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; @@ -44,6 +53,15 @@ public class LocalAuthService { private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final LdapProperties ldapProperties; + private final ObjectProvider ldapAuthServiceProvider; + /** + * Transaction boundary for the local-credential login path. The {@code login} method itself + * is intentionally NOT transactional: the LDAP fallback performs directory network calls + * (up to connect+read timeout) and must not hold a database connection/transaction open + * while blocked on the directory. Only the local credential database work is wrapped. + */ + private final TransactionTemplate transactionTemplate; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -51,7 +69,10 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LdapProperties ldapProperties, + ObjectProvider ldapAuthServiceProvider, + PlatformTransactionManager transactionManager) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +80,9 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.ldapProperties = ldapProperties; + this.ldapAuthServiceProvider = ldapAuthServiceProvider; + this.transactionTemplate = new TransactionTemplate(transactionManager); } /** @@ -107,33 +131,72 @@ public PlatformPrincipal register(String username, String password, String email /** * Authenticates a local account and returns the principal snapshot used to * establish a web session. + * If the user is not found locally, falls back to LDAP authentication if enabled. */ - @Transactional public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); - LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) - .orElse(null); + LocalCredential credential = transactionTemplate.execute(status -> + credentialRepository.findByUsernameIgnoreCase(normalizedUsername).orElse(null)); if (credential == null) { + // Blur timing to prevent username enumeration passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + + // Fallback to LDAP authentication if enabled + if (ldapProperties.isEnabled()) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username); + throw invalidCredentials(); + } + log.debug("Local user not found, attempting LDAP authentication for username: {}", username); + log.debug("LDAP enabled: {}, host: {}, base: {}", + ldapProperties.isEnabled(), + LdapAuthService.safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase()); + try { + PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password); + log.debug("LDAP authentication successful for username: {}", username); + return ldapPrincipal; + } catch (AuthFlowException e) { + log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage()); + // Propagate account-state, availability, and email-conflict errors instead of + // masking them as invalid credentials, so the frontend can show the right message. + // A 409 emailConflict is only reached after a successful LDAP bind, so the + // credentials are valid; masking it as 401 would mislead the user into thinking + // their password is wrong. Only genuine credential failures (userNotFound / + // invalidCredentials) fall back to the generic response to avoid enumeration. + HttpStatus status = e.getStatus(); + if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE + || status == HttpStatus.CONFLICT) { + throw e; + } + throw invalidCredentials(); + } + } else { + log.debug("LDAP authentication is disabled, rejecting login for username: {}", username); + } + throw invalidCredentials(); } - UserAccount user = userAccountRepository.findById(credential.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + return transactionTemplate.execute(status -> { + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); - ensureUserCanLogin(user); - ensureNotLocked(credential); + ensureUserCanLogin(user); + ensureNotLocked(credential); - if (!passwordEncoder.matches(password, credential.getPasswordHash())) { - handleFailedLogin(credential); - throw invalidCredentials(); - } + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } - credential.setFailedAttempts(0); - credential.setLockedUntil(null); - credentialRepository.save(credential); - return buildPrincipal(user); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + }); } /** diff --git a/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 000000000..e5ec5e04e --- /dev/null +++ b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java new file mode 100644 index 000000000..9c8915aec --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Startup configuration validation for the LDAP properties block. + */ +class LdapPropertiesTest { + + @Test + void validate_requiresUrlAndBase_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("skillhub.ldap.url"); + } + + @Test + void validate_rejectsNonPositiveTimeouts_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://localhost:389"); + props.setBase("dc=example,dc=org"); + props.setConnectTimeoutMillis(0); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect-timeout-millis"); + } + + @Test + void validate_skipsChecks_whenDisabled() { + LdapProperties props = new LdapProperties(); + assertThatCode(props::validate).doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java new file mode 100644 index 000000000..f15892258 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java @@ -0,0 +1,358 @@ + +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import java.lang.reflect.Method; +import java.util.Optional; +import jakarta.persistence.EntityManager; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Behavior-level unit tests for {@link LdapAuthService} identity provisioning. + * + * These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + entityManager = mock(EntityManager.class); + transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + ldapContextSource, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository, + entityManager, + transactionManager); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception { + // Given — the binding points to an account that no longer exists + IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(stale)); + given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — the same subject logs in again + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — the stale binding is removed and a fresh account is provisioned + assertThat(created.getEmail()).isEqualTo(EMAIL); + assertThat(created.getId()).isNotEqualTo("usr_deleted"); + verify(identityBindingRepository).delete(stale); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration"); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception { + // Given — the LDAP subject is bound, but the directory now reports an email that already + // belongs to a different account. The refresh must refuse to adopt it (409), matching the + // first-login email-collision rule. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other)); + + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice")); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + // The user's own email must remain untouched. + assertThat(existing.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void returningUser_sameEmail_isAllowedToRefresh() throws Exception { + // Given — the directory reports the same email the bound account already owns; the + // collision lookup must exclude the user's own account. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith")); + + assertThat(result.getId()).isEqualTo(userId); + assertThat(result.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsCertPathValidatorWithoutSslException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 000000000..d64928bc8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + * Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java new file mode 100644 index 000000000..f509138a0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LdapTrustStoreInstallerTest { + + @TempDir + Path tempDir; + + private String previousTrustStore; + private String previousPassword; + private String previousType; + + @AfterEach + void restoreSystemProperties() { + restore("javax.net.ssl.trustStore", previousTrustStore); + restore("javax.net.ssl.trustStorePassword", previousPassword); + restore("javax.net.ssl.trustStoreType", previousType); + } + + @Test + void install_mergesCustomTrustStoreIntoDefaults() throws Exception { + previousTrustStore = System.getProperty("javax.net.ssl.trustStore"); + previousPassword = System.getProperty("javax.net.ssl.trustStorePassword"); + previousType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Build a custom trust store containing one certificate copied from the JVM defaults. + KeyStore defaults = defaultTrustStore(); + String sourceAlias = firstCertificateAlias(defaults); + Path custom = tempDir.resolve("custom.p12"); + KeyStore customStore = KeyStore.getInstance("PKCS12"); + customStore.load(null, null); + customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias)); + try (OutputStream out = Files.newOutputStream(custom)) { + customStore.store(out, "changeit".toCharArray()); + } + + LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12"); + + String installedPath = System.getProperty("javax.net.ssl.trustStore"); + assertThat(installedPath).isNotBlank(); + KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType")); + try (InputStream in = Files.newInputStream(Path.of(installedPath))) { + installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray()); + } + assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue(); + assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue(); + } + + @Test + void install_withMissingFile_failsFast() { + assertThatThrownBy(() -> LdapTrustStoreInstaller.install( + tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to install LDAPS trust store"); + } + + private static KeyStore defaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, "changeit".toCharArray()); + } + return keyStore; + } + + private static String firstCertificateAlias(KeyStore keyStore) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isCertificateEntry(alias)) { + return alias; + } + } + throw new IllegalStateException("default trust store has no certificate entries"); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f43446..f8ba17171 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -9,9 +9,12 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +25,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,10 +55,25 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LdapProperties ldapProperties; + + @Mock + private LdapAuthService ldapAuthService; + + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + + @Mock + private org.springframework.transaction.PlatformTransactionManager transactionManager; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any())) + .thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class)); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -62,7 +81,10 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + ldapProperties, + ldapAuthServiceProvider, + transactionManager ); } @@ -161,6 +183,7 @@ void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -247,4 +270,116 @@ void register_rejectsBlankEmail() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.notBlank"); } + + @Test + void login_withUnknownUsername_fallsBackToLdap_whenEnabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null); + ldapUser.setStatus(UserStatus.ACTIVE); + PlatformPrincipal ldapPrincipal = new PlatformPrincipal( + "usr_ldap", + "ldapuser", + "ldapuser@example.com", + null, + "ldap", + Set.of("USER") + ); + + given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal); + + // When + var principal = service.login("ldapuser", "LdapPassword123!"); + + // Then + assertThat(principal.userId()).isEqualTo("usr_ldap"); + assertThat(principal.displayName()).isEqualTo("ldapuser"); + assertThat(principal.email()).isEqualTo("ldapuser@example.com"); + verify(ldapAuthService).login("ldapuser", "LdapPassword123!"); + } + + @Test + void login_withUnknownUsername_fails_whenLdapAuthenticationFails() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "WrongPassword")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed")); + + // When & Then + assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService).login("ldapuser", "WrongPassword"); + } + + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void login_withUnknownUsername_fails_whenLdapDisabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); + + // When & Then + assertThatThrownBy(() -> service.login("localuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService, never()).login(any(), any()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..d4ef31a55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + LdapBindRequest, ReviewSkillDetail, ReviewTask, PromotionTask, @@ -444,6 +445,18 @@ export const accountApi = { }, } +export const ldapApi = { + async bindIdentity(request: LdapBindRequest): Promise { + await fetchJson('/api/v1/auth/ldap/bind', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..48bca25c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -81,6 +81,11 @@ export interface MergeInitiateRequest { secondaryIdentifier: string } +export interface LdapBindRequest { + username: string + password: string +} + export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string diff --git a/web/src/features/auth/use-ldap-bind.ts b/web/src/features/auth/use-ldap-bind.ts new file mode 100644 index 000000000..b241e42a3 --- /dev/null +++ b/web/src/features/auth/use-ldap-bind.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { ldapApi } from '@/api/client' +import type { LdapBindRequest } from '@/api/types' + +/** + * Binds the LDAP identity verified by the given directory credentials to the currently + * authenticated account. + */ +export function useLdapBind() { + return useMutation({ + mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..2876fc272 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -757,7 +757,15 @@ "confirming": "Confirming...", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "ldapBindTitle": "Bind LDAP Account", + "ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.", + "ldapUsername": "LDAP Username", + "ldapPassword": "LDAP Password", + "ldapBinding": "Binding...", + "ldapBind": "Bind", + "ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP", + "ldapBindError": "LDAP binding failed" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..0d4b7eb5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -757,7 +757,15 @@ "confirming": "确认中...", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "ldapBindTitle": "绑定 LDAP 账号", + "ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。", + "ldapUsername": "LDAP 用户名", + "ldapPassword": "LDAP 密码", + "ldapBinding": "绑定中...", + "ldapBind": "绑定", + "ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录", + "ldapBindError": "LDAP 绑定失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a9336..6f76c7582 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useLdapBind } from '@/features/auth/use-ldap-bind' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input' */ export function AccountSettingsPage() { const { t } = useTranslation() + const [ldapUsername, setLdapUsername] = useState('') + const [ldapPassword, setLdapPassword] = useState('') + const [ldapStatusMessage, setLdapStatusMessage] = useState('') const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') const [verificationToken, setVerificationToken] = useState('') @@ -21,6 +25,25 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() const confirmMutation = useConfirmAccountMerge() + const ldapBindMutation = useLdapBind() + + /** + * Binds the LDAP identity proven by the given directory credentials to the current account. + */ + async function handleLdapBind(event: React.FormEvent) { + event.preventDefault() + setLdapStatusMessage('') + try { + await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword }) + setLdapUsername('') + setLdapPassword('') + setLdapStatusMessage(t('accounts.ldapBindSuccess')) + } catch (error) { + setLdapStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'), + ) + } + } /** * Starts the merge flow and surfaces the request id plus verification token @@ -77,6 +100,40 @@ export function AccountSettingsPage() { return ( + + + {t('accounts.ldapBindTitle')} + {t('accounts.ldapBindDesc')} + + + + + {t('accounts.ldapUsername')} + setLdapUsername(event.target.value)} + /> + + + {t('accounts.ldapPassword')} + setLdapPassword(event.target.value)} + /> + + + {ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')} + + + {ldapStatusMessage ? {ldapStatusMessage} : null} + + + {t('accounts.initiateTitle')} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 5144fdf67..c30af2eea 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -57,6 +57,30 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) + it('shows the server message for 403 errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员')) + + expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员') + }) + + it('shows the server message for 5xx errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试')) + + expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试') + }) + + it('falls back to generic text for 403/5xx without a server message', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403)) + + expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden')) + }) + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca2..a743c94d9 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -68,7 +68,9 @@ export function handleApiError(error: unknown): void { } if (status === 403) { - toast.error(i18n.t('apiError.forbidden')) + // The backend may attach a specific business message (e.g. a disabled account or an + // LDAP conflict); prefer it over the generic forbidden text. + toast.error(error.serverMessage || i18n.t('apiError.forbidden')) return } @@ -78,7 +80,9 @@ export function handleApiError(error: unknown): void { } if (status >= 500) { - toast.error(i18n.t('apiError.serverError')) + // The backend returns localized operational messages for service-level failures such as + // "directory unavailable" or "TLS misconfiguration"; surface them when present. + toast.error(error.serverMessage || i18n.t('apiError.serverError')) return }
+ * Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID) + * via {@link IdentityBinding}, not on the user's email. This prevents: + *
+ * This is the only reliable injection point for LDAPS trust in a JNDI-based client: the JDK + * LDAP provider builds its sockets from the JVM-wide SSL configuration and ignores both + * {@code javax.net.ssl.trustStore*} context-environment entries and the + * {@code java.naming.ldap.factory.socket} property (verified against the JDK 21 implementation), + * and Spring LDAP 3.x no longer offers a per-context socket factory. Because the JSSE default + * SSLContext is cached on first use, the installer must run before any TLS connection is made; + * it is invoked from an {@code EnvironmentPostProcessor} during application startup. + *
+ * Merging (rather than replacing) keeps public-CA connectivity intact: the resulting store + * contains the JVM defaults plus the configured internal CA. + */ +public final class LdapTrustStoreInstaller { + + private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit"; + + private LdapTrustStoreInstaller() { + } + + /** + * Merges the configured custom trust store into the JVM default trust store and installs the + * result through the {@code javax.net.ssl.trustStore*} system properties. + * + * @param customPath the custom trust store path + * @param customPassword the custom trust store password (may be empty) + * @param customType the custom trust store type (JKS, PKCS12, ...) + */ + public static void install(String customPath, String customPassword, String customType) { + try { + KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType()); + merged.load(null, null); + copyCertificateEntries(loadDefaultTrustStore(), merged); + copyCertificateEntries(loadCustomTrustStore(customPath, customPassword, customType), merged); + + String password = "skillhub-" + UUID.randomUUID(); + Path file = Files.createTempFile("skillhub-truststore", ".p12"); + try (OutputStream out = Files.newOutputStream(file)) { + merged.store(out, password.toCharArray()); + } + System.setProperty("javax.net.ssl.trustStore", file.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", password); + System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType()); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to install LDAPS trust store from " + customPath, e); + } + } + + private static KeyStore loadDefaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + String systemPassword = System.getProperty("javax.net.ssl.trustStorePassword", + DEFAULT_TRUSTSTORE_PASSWORD); + String systemType = System.getProperty("javax.net.ssl.trustStoreType", + KeyStore.getDefaultType()); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(systemType); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, systemPassword.toCharArray()); + } + return keyStore; + } + + private static KeyStore loadCustomTrustStore(String path, String password, String type) throws Exception { + KeyStore keyStore = KeyStore.getInstance(type); + try (InputStream in = Files.newInputStream(Path.of(path))) { + keyStore.load(in, password.toCharArray()); + } + return keyStore; + } + + private static void copyCertificateEntries(KeyStore source, KeyStore target) throws Exception { + Enumeration aliases = source.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (source.isCertificateEntry(alias)) { + target.setCertificateEntry(alias, source.getCertificate(alias)); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867b..512515617 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.local; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -16,9 +18,14 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; /** @@ -28,6 +35,8 @@ @Service public class LocalAuthService { + private static final Logger log = LoggerFactory.getLogger(LocalAuthService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; @@ -44,6 +53,15 @@ public class LocalAuthService { private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final LdapProperties ldapProperties; + private final ObjectProvider ldapAuthServiceProvider; + /** + * Transaction boundary for the local-credential login path. The {@code login} method itself + * is intentionally NOT transactional: the LDAP fallback performs directory network calls + * (up to connect+read timeout) and must not hold a database connection/transaction open + * while blocked on the directory. Only the local credential database work is wrapped. + */ + private final TransactionTemplate transactionTemplate; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -51,7 +69,10 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LdapProperties ldapProperties, + ObjectProvider ldapAuthServiceProvider, + PlatformTransactionManager transactionManager) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +80,9 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.ldapProperties = ldapProperties; + this.ldapAuthServiceProvider = ldapAuthServiceProvider; + this.transactionTemplate = new TransactionTemplate(transactionManager); } /** @@ -107,33 +131,72 @@ public PlatformPrincipal register(String username, String password, String email /** * Authenticates a local account and returns the principal snapshot used to * establish a web session. + * If the user is not found locally, falls back to LDAP authentication if enabled. */ - @Transactional public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); - LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) - .orElse(null); + LocalCredential credential = transactionTemplate.execute(status -> + credentialRepository.findByUsernameIgnoreCase(normalizedUsername).orElse(null)); if (credential == null) { + // Blur timing to prevent username enumeration passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + + // Fallback to LDAP authentication if enabled + if (ldapProperties.isEnabled()) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username); + throw invalidCredentials(); + } + log.debug("Local user not found, attempting LDAP authentication for username: {}", username); + log.debug("LDAP enabled: {}, host: {}, base: {}", + ldapProperties.isEnabled(), + LdapAuthService.safeLogHost(ldapProperties.getUrl()), + ldapProperties.getBase()); + try { + PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password); + log.debug("LDAP authentication successful for username: {}", username); + return ldapPrincipal; + } catch (AuthFlowException e) { + log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage()); + // Propagate account-state, availability, and email-conflict errors instead of + // masking them as invalid credentials, so the frontend can show the right message. + // A 409 emailConflict is only reached after a successful LDAP bind, so the + // credentials are valid; masking it as 401 would mislead the user into thinking + // their password is wrong. Only genuine credential failures (userNotFound / + // invalidCredentials) fall back to the generic response to avoid enumeration. + HttpStatus status = e.getStatus(); + if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE + || status == HttpStatus.CONFLICT) { + throw e; + } + throw invalidCredentials(); + } + } else { + log.debug("LDAP authentication is disabled, rejecting login for username: {}", username); + } + throw invalidCredentials(); } - UserAccount user = userAccountRepository.findById(credential.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + return transactionTemplate.execute(status -> { + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); - ensureUserCanLogin(user); - ensureNotLocked(credential); + ensureUserCanLogin(user); + ensureNotLocked(credential); - if (!passwordEncoder.matches(password, credential.getPasswordHash())) { - handleFailedLogin(credential); - throw invalidCredentials(); - } + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } - credential.setFailedAttempts(0); - credential.setLockedUntil(null); - credentialRepository.save(credential); - return buildPrincipal(user); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + }); } /** diff --git a/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 000000000..e5ec5e04e --- /dev/null +++ b/server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java new file mode 100644 index 000000000..9c8915aec --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/LdapPropertiesTest.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Startup configuration validation for the LDAP properties block. + */ +class LdapPropertiesTest { + + @Test + void validate_requiresUrlAndBase_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("skillhub.ldap.url"); + } + + @Test + void validate_rejectsNonPositiveTimeouts_whenEnabled() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://localhost:389"); + props.setBase("dc=example,dc=org"); + props.setConnectTimeoutMillis(0); + + assertThatThrownBy(props::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect-timeout-millis"); + } + + @Test + void validate_skipsChecks_whenDisabled() { + LdapProperties props = new LdapProperties(); + assertThatCode(props::validate).doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java new file mode 100644 index 000000000..f15892258 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java @@ -0,0 +1,358 @@ + +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import java.lang.reflect.Method; +import java.util.Optional; +import jakarta.persistence.EntityManager; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.LdapContextSource; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Behavior-level unit tests for {@link LdapAuthService} identity provisioning. + * + * These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + entityManager = mock(EntityManager.class); + transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + ldapContextSource, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository, + entityManager, + transactionManager); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception { + // Given — the binding points to an account that no longer exists + IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(stale)); + given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — the same subject logs in again + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — the stale binding is removed and a fresh account is provisioned + assertThat(created.getEmail()).isEqualTo(EMAIL); + assertThat(created.getId()).isNotEqualTo("usr_deleted"); + verify(identityBindingRepository).delete(stale); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration"); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception { + // Given — the LDAP subject is bound, but the directory now reports an email that already + // belongs to a different account. The refresh must refuse to adopt it (409), matching the + // first-login email-collision rule. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other)); + + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice")); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + // The user's own email must remain untouched. + assertThat(existing.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void returningUser_sameEmail_isAllowedToRefresh() throws Exception { + // Given — the directory reports the same email the bound account already owns; the + // collision lookup must exclude the user's own account. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith")); + + assertThat(result.getId()).isEqualTo(userId); + assertThat(result.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsCertPathValidatorWithoutSslException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 000000000..d64928bc8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + * Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java new file mode 100644 index 000000000..f509138a0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LdapTrustStoreInstallerTest { + + @TempDir + Path tempDir; + + private String previousTrustStore; + private String previousPassword; + private String previousType; + + @AfterEach + void restoreSystemProperties() { + restore("javax.net.ssl.trustStore", previousTrustStore); + restore("javax.net.ssl.trustStorePassword", previousPassword); + restore("javax.net.ssl.trustStoreType", previousType); + } + + @Test + void install_mergesCustomTrustStoreIntoDefaults() throws Exception { + previousTrustStore = System.getProperty("javax.net.ssl.trustStore"); + previousPassword = System.getProperty("javax.net.ssl.trustStorePassword"); + previousType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Build a custom trust store containing one certificate copied from the JVM defaults. + KeyStore defaults = defaultTrustStore(); + String sourceAlias = firstCertificateAlias(defaults); + Path custom = tempDir.resolve("custom.p12"); + KeyStore customStore = KeyStore.getInstance("PKCS12"); + customStore.load(null, null); + customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias)); + try (OutputStream out = Files.newOutputStream(custom)) { + customStore.store(out, "changeit".toCharArray()); + } + + LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12"); + + String installedPath = System.getProperty("javax.net.ssl.trustStore"); + assertThat(installedPath).isNotBlank(); + KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType")); + try (InputStream in = Files.newInputStream(Path.of(installedPath))) { + installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray()); + } + assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue(); + assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue(); + } + + @Test + void install_withMissingFile_failsFast() { + assertThatThrownBy(() -> LdapTrustStoreInstaller.install( + tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to install LDAPS trust store"); + } + + private static KeyStore defaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, "changeit".toCharArray()); + } + return keyStore; + } + + private static String firstCertificateAlias(KeyStore keyStore) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isCertificateEntry(alias)) { + return alias; + } + } + throw new IllegalStateException("default trust store has no certificate entries"); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f43446..f8ba17171 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -9,9 +9,12 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +25,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,10 +55,25 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LdapProperties ldapProperties; + + @Mock + private LdapAuthService ldapAuthService; + + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + + @Mock + private org.springframework.transaction.PlatformTransactionManager transactionManager; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any())) + .thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class)); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -62,7 +81,10 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + ldapProperties, + ldapAuthServiceProvider, + transactionManager ); } @@ -161,6 +183,7 @@ void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -247,4 +270,116 @@ void register_rejectsBlankEmail() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.notBlank"); } + + @Test + void login_withUnknownUsername_fallsBackToLdap_whenEnabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null); + ldapUser.setStatus(UserStatus.ACTIVE); + PlatformPrincipal ldapPrincipal = new PlatformPrincipal( + "usr_ldap", + "ldapuser", + "ldapuser@example.com", + null, + "ldap", + Set.of("USER") + ); + + given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal); + + // When + var principal = service.login("ldapuser", "LdapPassword123!"); + + // Then + assertThat(principal.userId()).isEqualTo("usr_ldap"); + assertThat(principal.displayName()).isEqualTo("ldapuser"); + assertThat(principal.email()).isEqualTo("ldapuser@example.com"); + verify(ldapAuthService).login("ldapuser", "LdapPassword123!"); + } + + @Test + void login_withUnknownUsername_fails_whenLdapAuthenticationFails() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "WrongPassword")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed")); + + // When & Then + assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService).login("ldapuser", "WrongPassword"); + } + + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void login_withUnknownUsername_fails_whenLdapDisabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); + + // When & Then + assertThatThrownBy(() -> service.login("localuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService, never()).login(any(), any()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..d4ef31a55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + LdapBindRequest, ReviewSkillDetail, ReviewTask, PromotionTask, @@ -444,6 +445,18 @@ export const accountApi = { }, } +export const ldapApi = { + async bindIdentity(request: LdapBindRequest): Promise { + await fetchJson('/api/v1/auth/ldap/bind', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..48bca25c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -81,6 +81,11 @@ export interface MergeInitiateRequest { secondaryIdentifier: string } +export interface LdapBindRequest { + username: string + password: string +} + export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string diff --git a/web/src/features/auth/use-ldap-bind.ts b/web/src/features/auth/use-ldap-bind.ts new file mode 100644 index 000000000..b241e42a3 --- /dev/null +++ b/web/src/features/auth/use-ldap-bind.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { ldapApi } from '@/api/client' +import type { LdapBindRequest } from '@/api/types' + +/** + * Binds the LDAP identity verified by the given directory credentials to the currently + * authenticated account. + */ +export function useLdapBind() { + return useMutation({ + mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..2876fc272 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -757,7 +757,15 @@ "confirming": "Confirming...", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "ldapBindTitle": "Bind LDAP Account", + "ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.", + "ldapUsername": "LDAP Username", + "ldapPassword": "LDAP Password", + "ldapBinding": "Binding...", + "ldapBind": "Bind", + "ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP", + "ldapBindError": "LDAP binding failed" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..0d4b7eb5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -757,7 +757,15 @@ "confirming": "确认中...", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "ldapBindTitle": "绑定 LDAP 账号", + "ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。", + "ldapUsername": "LDAP 用户名", + "ldapPassword": "LDAP 密码", + "ldapBinding": "绑定中...", + "ldapBind": "绑定", + "ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录", + "ldapBindError": "LDAP 绑定失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a9336..6f76c7582 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useLdapBind } from '@/features/auth/use-ldap-bind' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input' */ export function AccountSettingsPage() { const { t } = useTranslation() + const [ldapUsername, setLdapUsername] = useState('') + const [ldapPassword, setLdapPassword] = useState('') + const [ldapStatusMessage, setLdapStatusMessage] = useState('') const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') const [verificationToken, setVerificationToken] = useState('') @@ -21,6 +25,25 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() const confirmMutation = useConfirmAccountMerge() + const ldapBindMutation = useLdapBind() + + /** + * Binds the LDAP identity proven by the given directory credentials to the current account. + */ + async function handleLdapBind(event: React.FormEvent) { + event.preventDefault() + setLdapStatusMessage('') + try { + await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword }) + setLdapUsername('') + setLdapPassword('') + setLdapStatusMessage(t('accounts.ldapBindSuccess')) + } catch (error) { + setLdapStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'), + ) + } + } /** * Starts the merge flow and surfaces the request id plus verification token @@ -77,6 +100,40 @@ export function AccountSettingsPage() { return ( + + + {t('accounts.ldapBindTitle')} + {t('accounts.ldapBindDesc')} + + + + + {t('accounts.ldapUsername')} + setLdapUsername(event.target.value)} + /> + + + {t('accounts.ldapPassword')} + setLdapPassword(event.target.value)} + /> + + + {ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')} + + + {ldapStatusMessage ? {ldapStatusMessage} : null} + + + {t('accounts.initiateTitle')} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 5144fdf67..c30af2eea 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -57,6 +57,30 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) + it('shows the server message for 403 errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员')) + + expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员') + }) + + it('shows the server message for 5xx errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试')) + + expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试') + }) + + it('falls back to generic text for 403/5xx without a server message', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403)) + + expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden')) + }) + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca2..a743c94d9 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -68,7 +68,9 @@ export function handleApiError(error: unknown): void { } if (status === 403) { - toast.error(i18n.t('apiError.forbidden')) + // The backend may attach a specific business message (e.g. a disabled account or an + // LDAP conflict); prefer it over the generic forbidden text. + toast.error(error.serverMessage || i18n.t('apiError.forbidden')) return } @@ -78,7 +80,9 @@ export function handleApiError(error: unknown): void { } if (status >= 500) { - toast.error(i18n.t('apiError.serverError')) + // The backend returns localized operational messages for service-level failures such as + // "directory unavailable" or "TLS misconfiguration"; surface them when present. + toast.error(error.serverMessage || i18n.t('apiError.serverError')) return }
These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + entityManager = mock(EntityManager.class); + transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + ldapContextSource, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository, + entityManager, + transactionManager); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception { + // Given — the binding points to an account that no longer exists + IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(stale)); + given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — the same subject logs in again + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — the stale binding is removed and a fresh account is provisioned + assertThat(created.getEmail()).isEqualTo(EMAIL); + assertThat(created.getId()).isNotEqualTo("usr_deleted"); + verify(identityBindingRepository).delete(stale); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration"); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception { + // Given — the LDAP subject is bound, but the directory now reports an email that already + // belongs to a different account. The refresh must refuse to adopt it (409), matching the + // first-login email-collision rule. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other)); + + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice")); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + // The user's own email must remain untouched. + assertThat(existing.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void returningUser_sameEmail_isAllowedToRefresh() throws Exception { + // Given — the directory reports the same email the bound account already owns; the + // collision lookup must exclude the user's own account. + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith")); + + assertThat(result.getId()).isEqualTo(userId); + assertThat(result.getEmail()).isEqualTo("alice@example.com"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsCertPathValidatorWithoutSslException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 000000000..d64928bc8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + *
Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java new file mode 100644 index 000000000..f509138a0 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Enumeration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LdapTrustStoreInstallerTest { + + @TempDir + Path tempDir; + + private String previousTrustStore; + private String previousPassword; + private String previousType; + + @AfterEach + void restoreSystemProperties() { + restore("javax.net.ssl.trustStore", previousTrustStore); + restore("javax.net.ssl.trustStorePassword", previousPassword); + restore("javax.net.ssl.trustStoreType", previousType); + } + + @Test + void install_mergesCustomTrustStoreIntoDefaults() throws Exception { + previousTrustStore = System.getProperty("javax.net.ssl.trustStore"); + previousPassword = System.getProperty("javax.net.ssl.trustStorePassword"); + previousType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Build a custom trust store containing one certificate copied from the JVM defaults. + KeyStore defaults = defaultTrustStore(); + String sourceAlias = firstCertificateAlias(defaults); + Path custom = tempDir.resolve("custom.p12"); + KeyStore customStore = KeyStore.getInstance("PKCS12"); + customStore.load(null, null); + customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias)); + try (OutputStream out = Files.newOutputStream(custom)) { + customStore.store(out, "changeit".toCharArray()); + } + + LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12"); + + String installedPath = System.getProperty("javax.net.ssl.trustStore"); + assertThat(installedPath).isNotBlank(); + KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType")); + try (InputStream in = Files.newInputStream(Path.of(installedPath))) { + installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray()); + } + assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue(); + assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue(); + } + + @Test + void install_withMissingFile_failsFast() { + assertThatThrownBy(() -> LdapTrustStoreInstaller.install( + tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to install LDAPS trust store"); + } + + private static KeyStore defaultTrustStore() throws Exception { + String systemPath = System.getProperty("javax.net.ssl.trustStore"); + Path path = systemPath != null && !systemPath.isEmpty() + ? Path.of(systemPath) + : Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(path)) { + keyStore.load(in, "changeit".toCharArray()); + } + return keyStore; + } + + private static String firstCertificateAlias(KeyStore keyStore) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isCertificateEntry(alias)) { + return alias; + } + } + throw new IllegalStateException("default trust store has no certificate entries"); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f43446..f8ba17171 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -9,9 +9,12 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.config.LdapProperties; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.ldap.LdapAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +25,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,10 +55,25 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LdapProperties ldapProperties; + + @Mock + private LdapAuthService ldapAuthService; + + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + + @Mock + private org.springframework.transaction.PlatformTransactionManager transactionManager; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any())) + .thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class)); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -62,7 +81,10 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + ldapProperties, + ldapAuthServiceProvider, + transactionManager ); } @@ -161,6 +183,7 @@ void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -247,4 +270,116 @@ void register_rejectsBlankEmail() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.notBlank"); } + + @Test + void login_withUnknownUsername_fallsBackToLdap_whenEnabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null); + ldapUser.setStatus(UserStatus.ACTIVE); + PlatformPrincipal ldapPrincipal = new PlatformPrincipal( + "usr_ldap", + "ldapuser", + "ldapuser@example.com", + null, + "ldap", + Set.of("USER") + ); + + given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal); + + // When + var principal = service.login("ldapuser", "LdapPassword123!"); + + // Then + assertThat(principal.userId()).isEqualTo("usr_ldap"); + assertThat(principal.displayName()).isEqualTo("ldapuser"); + assertThat(principal.email()).isEqualTo("ldapuser@example.com"); + verify(ldapAuthService).login("ldapuser", "LdapPassword123!"); + } + + @Test + void login_withUnknownUsername_fails_whenLdapAuthenticationFails() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "WrongPassword")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed")); + + // When & Then + assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService).login("ldapuser", "WrongPassword"); + } + + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void login_withUnknownUsername_fails_whenLdapDisabled() { + // Given + given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(false); + + // When & Then + assertThatThrownBy(() -> service.login("localuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(ldapAuthService, never()).login(any(), any()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..d4ef31a55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + LdapBindRequest, ReviewSkillDetail, ReviewTask, PromotionTask, @@ -444,6 +445,18 @@ export const accountApi = { }, } +export const ldapApi = { + async bindIdentity(request: LdapBindRequest): Promise { + await fetchJson('/api/v1/auth/ldap/bind', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..48bca25c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -81,6 +81,11 @@ export interface MergeInitiateRequest { secondaryIdentifier: string } +export interface LdapBindRequest { + username: string + password: string +} + export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string diff --git a/web/src/features/auth/use-ldap-bind.ts b/web/src/features/auth/use-ldap-bind.ts new file mode 100644 index 000000000..b241e42a3 --- /dev/null +++ b/web/src/features/auth/use-ldap-bind.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { ldapApi } from '@/api/client' +import type { LdapBindRequest } from '@/api/types' + +/** + * Binds the LDAP identity verified by the given directory credentials to the currently + * authenticated account. + */ +export function useLdapBind() { + return useMutation({ + mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..2876fc272 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -757,7 +757,15 @@ "confirming": "Confirming...", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "ldapBindTitle": "Bind LDAP Account", + "ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.", + "ldapUsername": "LDAP Username", + "ldapPassword": "LDAP Password", + "ldapBinding": "Binding...", + "ldapBind": "Bind", + "ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP", + "ldapBindError": "LDAP binding failed" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..0d4b7eb5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -757,7 +757,15 @@ "confirming": "确认中...", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "ldapBindTitle": "绑定 LDAP 账号", + "ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。", + "ldapUsername": "LDAP 用户名", + "ldapPassword": "LDAP 密码", + "ldapBinding": "绑定中...", + "ldapBind": "绑定", + "ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录", + "ldapBindError": "LDAP 绑定失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a9336..6f76c7582 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useLdapBind } from '@/features/auth/use-ldap-bind' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input' */ export function AccountSettingsPage() { const { t } = useTranslation() + const [ldapUsername, setLdapUsername] = useState('') + const [ldapPassword, setLdapPassword] = useState('') + const [ldapStatusMessage, setLdapStatusMessage] = useState('') const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') const [verificationToken, setVerificationToken] = useState('') @@ -21,6 +25,25 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() const confirmMutation = useConfirmAccountMerge() + const ldapBindMutation = useLdapBind() + + /** + * Binds the LDAP identity proven by the given directory credentials to the current account. + */ + async function handleLdapBind(event: React.FormEvent) { + event.preventDefault() + setLdapStatusMessage('') + try { + await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword }) + setLdapUsername('') + setLdapPassword('') + setLdapStatusMessage(t('accounts.ldapBindSuccess')) + } catch (error) { + setLdapStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'), + ) + } + } /** * Starts the merge flow and surfaces the request id plus verification token @@ -77,6 +100,40 @@ export function AccountSettingsPage() { return ( + + + {t('accounts.ldapBindTitle')} + {t('accounts.ldapBindDesc')} + + + + + {t('accounts.ldapUsername')} + setLdapUsername(event.target.value)} + /> + + + {t('accounts.ldapPassword')} + setLdapPassword(event.target.value)} + /> + + + {ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')} + + + {ldapStatusMessage ? {ldapStatusMessage} : null} + + + {t('accounts.initiateTitle')} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 5144fdf67..c30af2eea 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -57,6 +57,30 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) + it('shows the server message for 403 errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员')) + + expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员') + }) + + it('shows the server message for 5xx errors when present', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试')) + + expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试') + }) + + it('falls back to generic text for 403/5xx without a server message', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('apiError.forbidden', 403)) + + expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden')) + }) + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca2..a743c94d9 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -68,7 +68,9 @@ export function handleApiError(error: unknown): void { } if (status === 403) { - toast.error(i18n.t('apiError.forbidden')) + // The backend may attach a specific business message (e.g. a disabled account or an + // LDAP conflict); prefer it over the generic forbidden text. + toast.error(error.serverMessage || i18n.t('apiError.forbidden')) return } @@ -78,7 +80,9 @@ export function handleApiError(error: unknown): void { } if (status >= 500) { - toast.error(i18n.t('apiError.serverError')) + // The backend returns localized operational messages for service-level failures such as + // "directory unavailable" or "TLS misconfiguration"; surface them when present. + toast.error(error.serverMessage || i18n.t('apiError.serverError')) return }
{ldapStatusMessage}