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')}
+
+
+
+ {ldapStatusMessage ? {ldapStatusMessage}
: null}
+
+
+
{t('accounts.initiateTitle')}