From 34e01171a607297066677eb3a0476742854cb537 Mon Sep 17 00:00:00 2001 From: jangrui Date: Sat, 1 Aug 2026 13:04:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=AE=8C=E6=88=90=20LDAP=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E6=B5=81=E7=A8=8B=E3=80=81LDAPS=20=E4=BF=A1?= =?UTF-8?q?=E4=BB=BB=E5=BA=93=E4=B8=8E=E5=B9=B6=E5=8F=91/=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 显式绑定端点 POST /api/v1/auth/ldap/bind:登录用户以 LDAP 凭据证明目录身份所有权后绑定到当前账号,解决 409 邮箱冲突后无自助入口的问题;前端设置页新增 LDAP 绑定卡片 - LDAPS 自定义 truststore 真实生效:JDK 21 JNDI 不读取 javax.net.ssl.trustStore* 环境项且无 factory.socket 注入点(反编译验证),改为 EnvironmentPostProcessor 在启动早期将自定义 CA 合并进 JVM 信任库;集成测试覆盖真实 LDAPS 登录成功(证书链含 BasicConstraints + 有效期修复) - 连接层改用 Spring LDAP LdapContextSource(无 base 双重拼接、异常转换保持 JNDI 分类语义),移除自建 JNDI env 与空壳配置类 - email 并发碰撞:首登 check-and-insert 按 email 条带锁串行化,不同 subject 同 email 并发首登只建一个账号(集成测试覆盖) - 孤儿 identity_binding 自愈:绑定指向已删除账号时删除残留绑定并按首登重建,不再永久 500;loginName 随登录名变更刷新 - isTlsFailure 识别 CertPathValidatorException 链;属性配置错误使用独立消息键;'*' '+' 属性请求回退仅限协议级错误 - LdapIntegrationTest 独立 surefire fork,避免全量测试中 JSSE 默认 SSLContext 被先行缓存导致 LDAPS 用例失效 - 集成测试 ensureMember 真实化(seed global namespace),移除 MockBean Signed-off-by: jangrui --- server/skillhub-app/pom.xml | 27 ++ .../controller/LdapAuthController.java | 40 +++ .../iflytek/skillhub/dto/LdapBindRequest.java | 14 + .../service/LdapBindingAppService.java | 74 ++++++ .../src/main/resources/application.yml | 3 +- .../src/main/resources/messages.properties | 3 +- .../src/main/resources/messages_zh.properties | 3 +- .../ldap/ConcurrentLdapFirstLoginTest.java | 58 ++++- .../ldap/LdapDirectoryUnavailableTest.java | 7 + .../auth/ldap/LdapIntegrationTest.java | 202 ++++++++++++-- .../service/LdapBindingAppServiceTest.java | 126 +++++++++ .../src/test/resources/ldap/seed-users.ldif | 36 +++ server/skillhub-auth/pom.xml | 4 + .../auth/config/LdapAutoConfiguration.java | 46 +++- ...dapTrustStoreEnvironmentPostProcessor.java | 28 ++ .../skillhub/auth/ldap/LdapAuthService.java | 246 +++++++++++++----- .../auth/ldap/LdapTrustStoreInstaller.java | 95 +++++++ ...ramework.boot.env.EnvironmentPostProcessor | 1 + .../auth/ldap/LdapAuthServiceTest.java | 48 ++-- .../ldap/LdapTrustStoreInstallerTest.java | 99 +++++++ web/src/api/client.ts | 13 + web/src/api/types.ts | 5 + web/src/features/auth/use-ldap-bind.ts | 13 + web/src/i18n/locales/en.json | 10 +- web/src/i18n/locales/zh.json | 10 +- web/src/pages/settings/accounts.tsx | 57 ++++ 26 files changed, 1148 insertions(+), 120 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LdapAuthController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LdapBindRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LdapBindingAppService.java mode change 100644 => 100755 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LdapBindingAppServiceTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstaller.java create mode 100644 server/skillhub-auth/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapTrustStoreInstallerTest.java create mode 100644 web/src/features/auth/use-ldap-bind.ts diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index ab5230d5..040211b1 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -116,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 00000000..c7ff481c --- /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 00000000..52ef501c --- /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 00000000..249deb85 --- /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 30e5dc17..fdb40e34 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -122,7 +122,8 @@ skillhub: 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). - # Leave empty to use the JVM default trust store. + # 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} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 9e241b45..d3fedf15 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -49,10 +49,11 @@ error.auth.sessionBootstrap.notAuthenticated=No authenticated external session f 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.fetchUserFailed=Failed to retrieve user information from the directory server +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 bc35c01a..24b4fc14 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -49,10 +49,11 @@ error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会 error.auth.ldap.disabled=LDAP 认证未启用 error.auth.ldap.userNotFound=用户名或密码错误 error.auth.ldap.invalidCredentials=用户名或密码错误 -error.auth.ldap.fetchUserFailed=从目录服务器获取用户信息失败 +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 index cb489c11..360e4dd4 100644 --- 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 @@ -3,19 +3,24 @@ 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.GlobalNamespaceMembershipService; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -64,9 +69,14 @@ class ConcurrentLdapFirstLoginTest { @Autowired private IdentityBindingRepository identityBindingRepository; - // Namespace seeding is unrelated to the concurrency behavior under test. - @MockBean - private GlobalNamespaceMembershipService globalNamespaceMembershipService; + @Autowired + private NamespaceRepository namespaceRepository; + + @BeforeEach + void ensureGlobalNamespace() { + namespaceRepository.findBySlug("global") + .orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap"))); + } @BeforeAll static void seedDirectory() throws Exception { @@ -115,4 +125,42 @@ class ConcurrentLdapFirstLoginTest { 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 index b9bd55b0..b43b9d4a 100644 --- 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 @@ -15,6 +15,7 @@ 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; /** @@ -34,8 +35,14 @@ class LdapDirectoryUnavailableTest { 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), 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 old mode 100644 new mode 100755 index 916ef601..873e0ce0 --- 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 @@ -2,7 +2,9 @@ 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; @@ -11,17 +13,32 @@ 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.boot.test.mock.mockito.MockBean; 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; @@ -51,12 +68,111 @@ class LdapIntegrationTest { 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) - .withExposedPorts(389, 636); + // 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) { @@ -78,14 +194,26 @@ class LdapIntegrationTest { @Autowired private IdentityBindingRepository identityBindingRepository; - // Local accounts are provisioned through ensureMember(), which requires a built-in global - // namespace row that the test profile does not seed. The membership side effect is unrelated - // to the LDAP behavior under test, so it is mocked away. - @MockBean - private GlobalNamespaceMembershipService globalNamespaceMembershipService; + @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(); @@ -96,6 +224,11 @@ class LdapIntegrationTest { .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; @@ -212,28 +345,63 @@ class LdapIntegrationTest { } @Test - void ldaps_withUntrustedCertificate_returnsTlsError() { + 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, - mock(UserAccountRepository.class), + contextSource, + userRepo, mock(UserRoleBindingRepository.class), mock(GlobalNamespaceMembershipService.class), - mock(IdentityBindingRepository.class), + bindingRepo, mock(EntityManager.class), mock(PlatformTransactionManager.class)); - assertThatThrownBy(() -> svc.login("alice", "alice123")) - .isInstanceOf(AuthFlowException.class) - .satisfies(e -> { - AuthFlowException ex = (AuthFlowException) e; - assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.tlsError"); - }); + 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 { 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 00000000..5b646e02 --- /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/seed-users.ldif b/server/skillhub-app/src/test/resources/ldap/seed-users.ldif index 423613ba..50f66836 100644 --- a/server/skillhub-app/src/test/resources/ldap/seed-users.ldif +++ b/server/skillhub-app/src/test/resources/ldap/seed-users.ldif @@ -43,3 +43,39 @@ 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 3bee6a9e..4de33034 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 index 3566d28f..000a1d47 100644 --- 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 @@ -1,20 +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 configuration marker. + * LDAP connection configuration, created only when {@code skillhub.ldap.enabled=true}. *

- * The actual LDAP connection handling is encapsulated inside {@code LdapAuthService}, which - * uses JNDI {@code DirContext} directly. This avoids maintaining a parallel Spring LDAP - * {@code LdapTemplate}/{@code LdapContextSource} bean graph whose configuration source - * ({@code spring.ldap.*}) would diverge from the application-level {@code skillhub.ldap.*} - * properties consumed by {@link LdapProperties}. - *

- * {@link LdapProperties} is a standalone {@code @Component} and is always available; the - * {@code LdapAuthService} bean itself is conditionally 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/LdapTrustStoreEnvironmentPostProcessor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapTrustStoreEnvironmentPostProcessor.java new file mode 100644 index 00000000..212df224 --- /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 index ef514b30..33536c4d 100644 --- 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 @@ -11,8 +11,9 @@ 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.Hashtable; import java.util.Locale; import java.util.Set; import java.util.UUID; @@ -21,13 +22,11 @@ import jakarta.persistence.EntityManager; import javax.net.ssl.SSLException; import javax.naming.AuthenticationException; import javax.naming.CommunicationException; -import javax.naming.Context; 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.InitialDirContext; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import javax.naming.ldap.LdapName; @@ -36,6 +35,7 @@ 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; @@ -53,6 +53,13 @@ import org.springframework.transaction.TransactionDefinition; @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. @@ -72,6 +79,7 @@ public class LdapAuthService { } private final LdapProperties ldapProperties; + private final LdapContextSource ldapContextSource; private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; private final GlobalNamespaceMembershipService globalNamespaceMembershipService; @@ -86,7 +94,19 @@ public class LdapAuthService { */ 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, @@ -94,6 +114,7 @@ public class LdapAuthService { EntityManager entityManager, PlatformTransactionManager transactionManager) { this.ldapProperties = ldapProperties; + this.ldapContextSource = ldapContextSource; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; this.globalNamespaceMembershipService = globalNamespaceMembershipService; @@ -101,6 +122,9 @@ public class LdapAuthService { 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(); + } } /** @@ -131,25 +155,77 @@ public class LdapAuthService { 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"); } - // Authenticate against LDAP 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"); } - // Get user attributes from LDAP log.debug("Fetching user attributes from LDAP for DN: {}", userDn); Attributes userAttributes = getUserAttributes(userDn); if (userAttributes == null) { @@ -159,25 +235,33 @@ public class LdapAuthService { // 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; + } - // 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. - log.debug("Finding or creating local user account for username: {}", username); - UserAccount user; + /** + * 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 { - user = ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes)); + 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); - user = ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username)); + return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username)); } + } - // 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); + /** + * 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); + } } /** @@ -263,41 +347,32 @@ public class LdapAuthService { /** * 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 builds the JNDI environment, so connection/timeout settings stay - consistent across search, bind, and attribute-read operations. + 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 { - return new InitialDirContext(buildJndiEnvironment(principal, credentials)); - } - - /** - * Builds the JNDI environment for one LDAP operation. Package-private so tests can assert - * connection/timeout/TLS settings without opening a real directory connection. - */ - Hashtable buildJndiEnvironment(String principal, String credentials) { - Hashtable env = new Hashtable<>(); - env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); - env.put(Context.PROVIDER_URL, ldapProperties.getUrl()); - env.put(Context.SECURITY_AUTHENTICATION, "simple"); - // Connection timeout: configurable (default 5 seconds for connect, 10 for read) - env.put("com.sun.jndi.ldap.connect.timeout", String.valueOf(ldapProperties.getConnectTimeoutMillis())); - env.put("com.sun.jndi.ldap.read.timeout", String.valueOf(ldapProperties.getReadTimeoutMillis())); - // 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()) { - env.put(Context.SECURITY_PRINCIPAL, bindPrincipal); - env.put(Context.SECURITY_CREDENTIALS, bindCredentials); + 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); } - // LDAPS certificate validation uses the JVM default trust store unless a custom store is - // configured; this lets operators trust internal/self-signed directory CAs. - String trustStorePath = ldapProperties.getTlsTrustStorePath(); - if (trustStorePath != null && !trustStorePath.isEmpty()) { - env.put("javax.net.ssl.trustStore", trustStorePath); - env.put("javax.net.ssl.trustStorePassword", ldapProperties.getTlsTrustStorePassword()); - env.put("javax.net.ssl.trustStoreType", ldapProperties.getTlsTrustStoreType()); - } - return env; } /** @@ -347,7 +422,8 @@ public class LdapAuthService { */ static boolean isTlsFailure(Throwable t) { for (Throwable c = t; c != null; c = c.getCause()) { - if (c instanceof SSLException || c instanceof CertificateException) { + if (c instanceof SSLException || c instanceof CertificateException + || c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) { return true; } } @@ -403,9 +479,14 @@ public class LdapAuthService { // 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 (Exception e) { + } catch (NamingException e) { // Some directories reject the "*"/"+" attribute-request syntax; fall back to the - // default attribute set instead of failing the whole login. + // 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; @@ -444,9 +525,9 @@ public class LdapAuthService { 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 - // fetchUserFailed message (no "retry later" wording) 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.fetchUserFailed"); + // 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. @@ -456,10 +537,23 @@ public class LdapAuthService { if (binding != null) { // Returning user — refresh attributes from the directory on each login. - UserAccount user = userAccountRepository.findById(binding.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for LDAP binding " + subject)); - updateFromAttributes(user, displayName, email); - return userAccountRepository.save(user); + 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 @@ -528,11 +622,27 @@ public class LdapAuthService { String displayName = resolveDisplayName(attributes, username); IdentityBinding binding = identityBindingRepository .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) - .orElseThrow(() -> new IllegalStateException("No LDAP binding found after race for subject " + subject)); - UserAccount user = userAccountRepository.findById(binding.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for LDAP binding " + subject)); - updateFromAttributes(user, displayName, email); - return userAccountRepository.save(user); + .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) { @@ -663,7 +773,7 @@ public class LdapAuthService { 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.fetchUserFailed"); + 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 00000000..9f7e5ec4 --- /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/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 00000000..e5ec5e04 --- /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/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java index 60ab01b0..f1589225 100644 --- 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 @@ -28,6 +28,7 @@ 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; /** @@ -51,6 +52,7 @@ class LdapAuthServiceTest { private IdentityBindingRepository identityBindingRepository; private EntityManager entityManager; private PlatformTransactionManager transactionManager; + private LdapContextSource ldapContextSource; private LdapAuthService ldapAuthService; @BeforeEach @@ -62,8 +64,10 @@ class LdapAuthServiceTest { identityBindingRepository = mock(IdentityBindingRepository.class); entityManager = mock(EntityManager.class); transactionManager = mock(PlatformTransactionManager.class); + ldapContextSource = mock(LdapContextSource.class); ldapAuthService = new LdapAuthService( ldapProperties, + ldapContextSource, userAccountRepository, userRoleBindingRepository, globalNamespaceMembershipService, @@ -129,6 +133,26 @@ class LdapAuthServiceTest { 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 @@ -211,6 +235,7 @@ class LdapAuthServiceTest { } 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); } @@ -298,21 +323,6 @@ class LdapAuthServiceTest { assertThat(result.getEmail()).isEqualTo("alice@example.com"); } - @Test - void buildJndiEnvironment_includesCustomTrustStoreSettings() { - ldapProperties.setUrl("ldaps://ldap.example.com:636"); - ldapProperties.setTlsTrustStorePath("/certs/ldap-truststore.jks"); - ldapProperties.setTlsTrustStorePassword("secret"); - ldapProperties.setTlsTrustStoreType("PKCS12"); - - java.util.Hashtable env = ldapAuthService.buildJndiEnvironment(null, null); - - assertThat(env) - .containsEntry("javax.net.ssl.trustStore", "/certs/ldap-truststore.jks") - .containsEntry("javax.net.ssl.trustStorePassword", "secret") - .containsEntry("javax.net.ssl.trustStoreType", "PKCS12"); - } - @Test void isTlsFailure_detectsSslHandshakeInCauseChain() { javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); @@ -330,6 +340,14 @@ class LdapAuthServiceTest { 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"); 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 00000000..f509138a --- /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/web/src/api/client.ts b/web/src/api/client.ts index 3204d56a..d4ef31a5 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 bde5ec41..48bca25c 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 00000000..b241e42a --- /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 53f1197b..2876fc27 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 73029a99..0d4b7eb5 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 fb13a933..6f76c758 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')} + + +
+
+ + setLdapUsername(event.target.value)} + /> +
+
+ + setLdapPassword(event.target.value)} + /> +
+ +
+ {ldapStatusMessage ?

{ldapStatusMessage}

: null} +
+
+ {t('accounts.initiateTitle')}