fix(auth): 完成 LDAP 绑定流程、LDAPS 信任库与并发/孤儿加固

- 显式绑定端点 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 <admin@jangrui.com>
This commit is contained in:
jangrui 2026-08-01 13:04:26 +08:00
parent 70dcc9d5f6
commit 34e01171a6
26 changed files with 1148 additions and 120 deletions

View file

@ -116,6 +116,33 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<executions>
<execution>
<id>default-test</id>
<configuration>
<excludes>
<exclude>**/auth/ldap/LdapIntegrationTest.java</exclude>
</excludes>
</configuration>
</execution>
<execution>
<id>ldap-container-test</id>
<phase>test</phase>
<goals>
<goal>test</goal>
</goals>
<configuration>
<includes>
<include>**/auth/ldap/LdapIntegrationTest.java</include>
</includes>
<reuseForks>false</reuseForks>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

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

View file

@ -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
) {}

View file

@ -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<LdapAuthService> ldapAuthServiceProvider;
private final IdentityBindingRepository identityBindingRepository;
private final UserAccountRepository userAccountRepository;
public LdapBindingAppService(ObjectProvider<LdapAuthService> 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");
}
}
}

View file

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

View file

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

View file

@ -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=请求超时

View file

@ -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<Object> eve = pool.submit(() -> (Object) localAuthService.login("eve", "eve123"));
Future<Object> frank = pool.submit(() -> (Object) localAuthService.login("frank", "frank123"));
List<Object> 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();
}
}
}

View file

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

View file

@ -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<String> 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 {

View file

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

View file

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

View file

@ -44,6 +44,10 @@
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.ldap</groupId>
<artifactId>spring-ldap-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View file

@ -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}.
* <p>
* 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}.
* <p>
* {@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<String, Object> 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;
}
}

View file

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

View file

@ -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<String, String> buildJndiEnvironment(String principal, String credentials) {
Hashtable<String, String> 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");
}
}
}

View file

@ -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.
* <p>
* 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.
* <p>
* 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<String> aliases = source.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (source.isCertificateEntry(alias)) {
target.setCertificateEntry(alias, source.getCertificate(alias));
}
}
}
}

View file

@ -0,0 +1 @@
com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor

View file

@ -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<String, String> 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");

View file

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

View file

@ -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<void> {
await fetchJson<void>('/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<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace

View file

@ -81,6 +81,11 @@ export interface MergeInitiateRequest {
secondaryIdentifier: string
}
export interface LdapBindRequest {
username: string
password: string
}
export interface MergeInitiateResponse {
mergeRequestId: number
secondaryUserId: string

View file

@ -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),
})
}

View file

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

View file

@ -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": "命名空间不存在",

View file

@ -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<HTMLFormElement>) {
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 (
<div className="mx-auto max-w-3xl space-y-6">
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('accounts.ldapBindTitle')}</CardTitle>
<CardDescription>{t('accounts.ldapBindDesc')}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleLdapBind}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ldap-username">{t('accounts.ldapUsername')}</label>
<Input
id="ldap-username"
autoComplete="username"
value={ldapUsername}
onChange={(event) => setLdapUsername(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ldap-password">{t('accounts.ldapPassword')}</label>
<Input
id="ldap-password"
type="password"
autoComplete="current-password"
value={ldapPassword}
onChange={(event) => setLdapPassword(event.target.value)}
/>
</div>
<Button type="submit" disabled={ldapBindMutation.isPending}>
{ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')}
</Button>
</form>
{ldapStatusMessage ? <p className="mt-4 text-sm text-muted-foreground">{ldapStatusMessage}</p> : null}
</CardContent>
</Card>
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('accounts.initiateTitle')}</CardTitle>