diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 3b8f21e6..34777806 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -15,10 +15,6 @@ spring: basename: messages application: name: skillhub - ldap: - # Prevent spring-boot-starter-data-ldap AutoConfiguration from attempting - # to initialize LDAP connections when LDAP is not configured - urls: "" lifecycle: timeout-per-shutdown-phase: 30s jpa: @@ -118,6 +114,13 @@ skillhub: password: ${SKILLHUB_LDAP_PASSWORD:} user-search-attribute: ${SKILLHUB_LDAP_USER_SEARCH_ATTRIBUTE:uid} user-search-base: ${SKILLHUB_LDAP_USER_SEARCH_BASE:} + # Stable directory identifier used as the LDAP identity subject (entryUUID for OpenLDAP, objectGUID for AD) + subject-attribute: ${SKILLHUB_LDAP_SUBJECT_ATTRIBUTE:entryUUID} + display-name-attribute: ${SKILLHUB_LDAP_DISPLAY_NAME_ATTRIBUTE:displayName} + display-name-fallback-attribute: ${SKILLHUB_LDAP_DISPLAY_NAME_FALLBACK_ATTRIBUTE:cn} + email-attribute: ${SKILLHUB_LDAP_EMAIL_ATTRIBUTE:mail} + connect-timeout-millis: ${SKILLHUB_LDAP_CONNECT_TIMEOUT_MILLIS:5000} + read-timeout-millis: ${SKILLHUB_LDAP_READ_TIMEOUT_MILLIS:10000} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe..9e241b45 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -46,6 +46,13 @@ error.auth.direct.providerUnsupported=Unsupported direct authentication provider error.auth.sessionBootstrap.disabled=Session bootstrap is disabled error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found +error.auth.ldap.disabled=LDAP authentication is not enabled +error.auth.ldap.userNotFound=Invalid username or password +error.auth.ldap.invalidCredentials=Invalid username or password +error.auth.ldap.fetchUserFailed=Failed to retrieve user information from the directory server +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.badRequest=Invalid request error.forbidden=Forbidden error.request.timeout=Request timed out diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef09563..bc35c01a 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -46,6 +46,13 @@ error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} error.auth.sessionBootstrap.disabled=会话引导能力未启用 error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 +error.auth.ldap.disabled=LDAP 认证未启用 +error.auth.ldap.userNotFound=用户名或密码错误 +error.auth.ldap.invalidCredentials=用户名或密码错误 +error.auth.ldap.fetchUserFailed=从目录服务器获取用户信息失败 +error.auth.ldap.directoryUnavailable=目录服务器暂时不可用,请稍后重试 +error.auth.ldap.tlsError=无法与目录服务器建立安全连接,请检查 TLS 证书配置 +error.auth.ldap.emailConflict=该邮箱已关联已有账号,请联系管理员处理 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 error.request.timeout=请求超时 diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 131652bf..3bee6a9e 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -44,14 +44,6 @@ spring-boot-configuration-processor true - - org.springframework.boot - spring-boot-starter-data-ldap - - - org.springframework.ldap - spring-ldap-core - org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapAutoConfiguration.java index ecccb619..3566d28f 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,40 +1,20 @@ package com.iflytek.skillhub.auth.config; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.ldap.core.LdapTemplate; -import org.springframework.ldap.core.support.LdapContextSource; /** - * LDAP auto-configuration that creates LdapTemplate only when LDAP is enabled. - * This avoids the startup side effects of spring-boot-starter-data-ldap's - * auto-configuration when LDAP is disabled. + * LDAP configuration marker. + *

+ * 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}. */ @Configuration -@ConditionalOnProperty(name = "skillhub.ldap.enabled", havingValue = "true") public class LdapAutoConfiguration { - - /** - * Creates an LdapContextSource configured from skillhub.ldap properties. - */ - @Bean - public LdapContextSource ldapContextSource(LdapProperties ldapProperties) { - LdapContextSource contextSource = new LdapContextSource(); - contextSource.setUrl(ldapProperties.getUrl()); - contextSource.setBase(ldapProperties.getBase()); - if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) { - contextSource.setUserDn(ldapProperties.getUsername()); - contextSource.setPassword(ldapProperties.getPassword()); - } - return contextSource; - } - - /** - * Creates an LdapTemplate for LDAP operations. - */ - @Bean - public LdapTemplate ldapTemplate(LdapContextSource ldapContextSource) { - return new LdapTemplate(ldapContextSource); - } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java index e68da824..46cfc80a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/LdapProperties.java @@ -44,6 +44,40 @@ public class LdapProperties { * Search base for user lookup (relative to base). */ private String userSearchBase = ""; + + /** + * Stable directory identifier attribute used as the LDAP identity subject. + * OpenLDAP uses "entryUUID", Active Directory uses "objectGUID". + */ + private String subjectAttribute = "entryUUID"; + + /** + * LDAP attribute mapped to the local display name. + */ + private String displayNameAttribute = "displayName"; + + /** + * Fallback LDAP attribute for the display name when the primary + * {@link #displayNameAttribute} is absent or empty. Defaults to {@code cn} + * (common name), the conventional fallback for directories that do not + * populate a dedicated display name. + */ + private String displayNameFallbackAttribute = "cn"; + + /** + * LDAP attribute mapped to the local email. + */ + private String emailAttribute = "mail"; + + /** + * LDAP connection timeout in milliseconds. + */ + private int connectTimeoutMillis = 5000; + + /** + * LDAP read timeout in milliseconds. + */ + private int readTimeoutMillis = 10000; public boolean isEnabled() { return enabled; @@ -100,4 +134,52 @@ public class LdapProperties { public void setUserSearchBase(String userSearchBase) { this.userSearchBase = userSearchBase; } -} \ No newline at end of file + + public String getSubjectAttribute() { + return subjectAttribute; + } + + public void setSubjectAttribute(String subjectAttribute) { + this.subjectAttribute = subjectAttribute; + } + + public String getDisplayNameAttribute() { + return displayNameAttribute; + } + + public void setDisplayNameAttribute(String displayNameAttribute) { + this.displayNameAttribute = displayNameAttribute; + } + + public String getDisplayNameFallbackAttribute() { + return displayNameFallbackAttribute; + } + + public void setDisplayNameFallbackAttribute(String displayNameFallbackAttribute) { + this.displayNameFallbackAttribute = displayNameFallbackAttribute; + } + + public String getEmailAttribute() { + return emailAttribute; + } + + public void setEmailAttribute(String emailAttribute) { + this.emailAttribute = emailAttribute; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public int getReadTimeoutMillis() { + return readTimeoutMillis; + } + + public void setReadTimeoutMillis(int readTimeoutMillis) { + this.readTimeoutMillis = readTimeoutMillis; + } +} 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 bf590475..21ea29bb 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 @@ -1,21 +1,27 @@ package com.iflytek.skillhub.auth.ldap; import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.cert.CertificateException; import java.util.Hashtable; -import java.util.List; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +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; @@ -25,55 +31,70 @@ import javax.naming.directory.SearchResult; import javax.naming.ldap.LdapName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; -import org.springframework.ldap.core.LdapTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * Handles LDAP authentication for enterprise directory integration. + *

+ * Identity is anchored on a stable directory identifier (entryUUID/objectGUID) via + * {@link IdentityBinding} (provider="ldap"), not on the user's email. This prevents + * silent account merging when an LDAP user's email collides with an existing + * local/OAuth account, and avoids duplicate accounts for email-less users. */ @Service +@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true") public class LdapAuthService { private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class); + private static final String LDAP_PROVIDER = "ldap"; + // Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters. + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{3,64}$"); + // LDAP attribute names only allow ASCII letters, digits, and hyphens. + private static final Pattern ATTRIBUTE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$"); private final LdapProperties ldapProperties; - private final LdapTemplate ldapTemplate; private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final IdentityBindingRepository identityBindingRepository; public LdapAuthService(LdapProperties ldapProperties, - LdapTemplate ldapTemplate, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, - GlobalNamespaceMembershipService globalNamespaceMembershipService) { + GlobalNamespaceMembershipService globalNamespaceMembershipService, + IdentityBindingRepository identityBindingRepository) { this.ldapProperties = ldapProperties; - this.ldapTemplate = ldapTemplate; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.identityBindingRepository = identityBindingRepository; } /** * Authenticates a user against the LDAP server. * If the user doesn't exist in the local database, creates a new user based on LDAP attributes. * - * @param username the username - * @param password the password - * @return PlatformPrincipal if authentication succeeds - * @throws AuthFlowException if authentication fails - */ + * @param username the username + * @param password the password + * @return PlatformPrincipal if authentication succeeds + * @throws AuthFlowException if authentication fails + */ @Transactional public PlatformPrincipal login(String username, String password) { log.info("Starting LDAP authentication for username: {}", username); - + if (!ldapProperties.isEnabled()) { log.warn("LDAP authentication is not enabled"); throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled"); } + // Validate all LDAP attribute names that flow into JNDI calls to prevent filter/attribute + // injection via operator misconfiguration. These names are operator-controlled, not user input. + validateAttributeNames(); + log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}", safeLogHost(ldapProperties.getUrl()), ldapProperties.getBase(), @@ -82,7 +103,7 @@ public class LdapAuthService { // First, try to find the user in LDAP and authenticate String userDn = findUserDn(username); - log.debug("LDAP findUserDn result for {}: {}", username, userDn); + log.debug("LDAP findUserDn result for {}: {}", username, userDn != null); if (userDn == null) { log.warn("User {} not found in LDAP directory", username); @@ -104,10 +125,13 @@ public class LdapAuthService { Attributes userAttributes = getUserAttributes(userDn); if (userAttributes == null) { log.error("Failed to fetch user attributes from LDAP for DN: {}", userDn); - throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.fetchUserFailed"); + // Bind already succeeded, so the credentials are valid. This is a transient directory + // failure; surface a 503 with the directoryUnavailable message instead of masking it + // as a 401 "invalid credentials" (which would mislead the user about the password). + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); } - // Find or create local user account + // Find or create local user account anchored on the stable LDAP subject log.debug("Finding or creating local user account for username: {}", username); UserAccount user = findOrCreateLdapUser(username, userAttributes); @@ -143,12 +167,13 @@ public class LdapAuthService { log.warn("Invalid username format for LDAP search: {}", username); return null; } + String searchAttr = ldapProperties.getUserSearchAttribute(); DirContext ctx = null; javax.naming.NamingEnumeration results = null; try { ctx = createLdapContext(); - String searchFilter = "(" + ldapProperties.getUserSearchAttribute() + "={0})"; + String searchFilter = "(" + searchAttr + "={0})"; String searchBase = ldapProperties.getUserSearchBase().isEmpty() ? ldapProperties.getBase() : ldapProperties.getUserSearchBase() + "," + ldapProperties.getBase(); @@ -164,7 +189,18 @@ public class LdapAuthService { return result.getNameInNamespace(); } return null; - } catch (Exception e) { + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); + } catch (AuthenticationException e) { + log.warn("LDAP bind authentication failed while searching for user {}: {}", username, e.getMessage()); + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials"); + } catch (NamingException e) { + log.warn("LDAP naming error while searching for user {}: {}", username, e.getMessage()); return null; } finally { // Close NamingEnumeration to prevent resource leaks @@ -183,20 +219,31 @@ public class LdapAuthService { * Creates an LDAP context for searching. */ private DirContext createLdapContext() throws NamingException { + // Bind-account context for directory searches/reads; delegates to the shared factory. + return createLdapContext(null, null); + } + + /** + * Creates an LDAP context authenticated with the given principal/credentials. When both are + {@code null}, falls back to the configured bind account (or anonymous if none is set). This is + the single place that builds the JNDI environment, so connection/timeout settings stay + consistent across search, bind, and attribute-read operations. + */ + private DirContext createLdapContext(String principal, String credentials) throws NamingException { 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: 5 seconds for connect, 10 seconds for read - env.put("com.sun.jndi.ldap.connect.timeout", "5000"); - env.put("com.sun.jndi.ldap.read.timeout", "10000"); - - if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) { - env.put(Context.SECURITY_PRINCIPAL, ldapProperties.getUsername()); - env.put(Context.SECURITY_CREDENTIALS, ldapProperties.getPassword()); + // 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); } - return new InitialDirContext(env); } @@ -214,46 +261,67 @@ public class LdapAuthService { } /** - * Safely extracts host from LDAP URL for logging, avoiding credential exposure. + * Safely extracts host:port from LDAP URL for logging, avoiding credential exposure. * Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host */ - private String safeLogHost(String url) { + public static String safeLogHost(String url) { if (url == null || url.isEmpty()) { return ""; } try { - // Remove protocol prefix String withoutProtocol = url.replaceFirst("^ldaps?://", ""); - // Extract host:port or just host int atIndex = withoutProtocol.indexOf('@'); if (atIndex > 0) { withoutProtocol = withoutProtocol.substring(atIndex + 1); } - int colonIndex = withoutProtocol.indexOf(':'); - return colonIndex > 0 ? withoutProtocol.substring(0, colonIndex) : withoutProtocol; + // IPv6 literal: ldap://[::1]:389 — keep the bracketed address plus port + int bracketEnd = withoutProtocol.indexOf(']'); + if (bracketEnd > 0) { + return withoutProtocol.substring(0, Math.min(bracketEnd + 1, withoutProtocol.length())); + } + int slashIndex = withoutProtocol.indexOf('/'); + String hostPort = slashIndex > 0 ? withoutProtocol.substring(0, slashIndex) : withoutProtocol; + return hostPort; } catch (Exception e) { return "[url-parse-error]"; } } + /** + * Returns whether the throwable chain indicates a TLS/trust failure (LDAPS handshake or + * certificate validation). JNDI wraps TLS failures in a {@link CommunicationException}, so + * without this check a certificate problem is indistinguishable from an unreachable directory. + */ + static boolean isTlsFailure(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof SSLException || c instanceof CertificateException) { + return true; + } + } + return false; + } + /** * Authenticates a user against the LDAP server using their DN and password. */ private boolean authenticateLdap(String userDn, String password) { DirContext ctx = null; try { - 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"); - env.put(Context.SECURITY_PRINCIPAL, userDn); - env.put(Context.SECURITY_CREDENTIALS, password); - env.put("com.sun.jndi.ldap.connect.timeout", "5000"); - env.put("com.sun.jndi.ldap.read.timeout", "10000"); - - ctx = new InitialDirContext(env); + // Bind as the authenticating user to verify credentials (shared factory handles env). + ctx = createLdapContext(userDn, password); return true; + } catch (AuthenticationException e) { + // Invalid credentials — expected, return false to signal auth failure + return false; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while authenticating DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); } catch (NamingException e) { + log.warn("LDAP naming error while authenticating DN {}: {}", userDn, e.getMessage()); return false; } finally { closeContext(ctx); @@ -266,23 +334,30 @@ public class LdapAuthService { private Attributes getUserAttributes(String userDn) { DirContext ctx = null; try { - 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"); - env.put("com.sun.jndi.ldap.connect.timeout", "5000"); - env.put("com.sun.jndi.ldap.read.timeout", "10000"); - - // Use bind DN if configured, otherwise anonymous bind - if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) { - env.put(Context.SECURITY_PRINCIPAL, ldapProperties.getUsername()); - env.put(Context.SECURITY_CREDENTIALS, ldapProperties.getPassword()); + // Read attributes via the bind-account context (shared factory handles env/timeout). + ctx = createLdapContext(); + Attributes attrs; + try { + // Request user attributes ("*") and operational attributes ("+") so stable + // directory identifiers (OpenLDAP entryUUID, AD objectGUID) are included in the + // response. Without the explicit request, operational attributes are omitted and + // the subject key would be null on every login. + attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"}); + } catch (Exception e) { + // Some directories reject the "*"/"+" attribute-request syntax; fall back to the + // default attribute set instead of failing the whole login. + attrs = ctx.getAttributes(new LdapName(userDn)); } - - ctx = new InitialDirContext(env); - Attributes attrs = ctx.getAttributes(new LdapName(userDn)); return attrs; + } catch (CommunicationException e) { + if (isTlsFailure(e)) { + log.warn("LDAP TLS/certificate failure while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError"); + } + log.warn("LDAP directory unavailable while fetching attributes for DN {}: {}", userDn, e.getMessage()); + throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"); } catch (Exception e) { + log.warn("Failed to fetch user attributes from LDAP for DN {}: {}", userDn, e.getMessage()); return null; } finally { closeContext(ctx); @@ -291,49 +366,99 @@ public class LdapAuthService { /** * Finds an existing LDAP user or creates a new one based on LDAP attributes. - * Note: This method only finds users by email, as the repository doesn't support displayName lookup. + *

+ * Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID) + * via {@link IdentityBinding}, not on the user's email. This prevents: + *

*/ private UserAccount findOrCreateLdapUser(String username, Attributes attributes) { - String email = getAttributeValue(attributes, "mail"); - String displayName = getAttributeValue(attributes, "displayName"); - - if (displayName == null || displayName.isEmpty()) { - displayName = getAttributeValue(attributes, "cn"); - } + String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute()); + String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute()); + String displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameAttribute()); + if (displayName == null || displayName.isEmpty()) { + displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameFallbackAttribute()); + } if (displayName == null || displayName.isEmpty()) { displayName = username; } - UserAccount user = null; + if (subject == null || subject.isEmpty()) { + log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity", + username, ldapProperties.getSubjectAttribute()); + // Bind already succeeded. The directory entry lacks the configured subject attribute, + // which is a configuration/schema issue the user cannot fix. Surface a 503 with the + // 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"); + } - // Try to find by email first + // Anchor on the stable LDAP subject: an existing binding means this identity is already known. + IdentityBinding binding = identityBindingRepository + .findByProviderCodeAndSubject(LDAP_PROVIDER, subject) + .orElse(null); + + if (binding != null) { + // Returning user — refresh attributes from the directory on each login. + UserAccount user = userAccountRepository.findById(binding.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for LDAP binding " + subject)); + updateFromAttributes(user, displayName, email); + return userAccountRepository.save(user); + } + + // First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth + // account that happens to share the same email — that would be a privilege escalation. if (email != null && !email.isEmpty()) { - user = userAccountRepository.findByEmailIgnoreCase(email.toLowerCase()).orElse(null); + String normalizedEmail = email.toLowerCase(); + UserAccount existingByEmail = userAccountRepository + .findByEmailIgnoreCase(normalizedEmail).orElse(null); + if (existingByEmail != null) { + // The email already belongs to another account. Refuse to silently create a second + // account (two distinct LDAP subjects sharing one email would both map to it, and + // user_account.email has no UNIQUE constraint, so this would otherwise happen + // silently). This covers both cross-provider collisions and the same-issuer case + // (a different LDAP subject under the same email). If an entry's stable subject + // legitimately changes (e.g. after an AD objectGUID migration), an administrator + // must remove the stale binding before the new subject can log in. + log.warn("LDAP user {} email {} collides with an existing account {} (subject differs); refusing to create a duplicate account", + username, normalizedEmail, existingByEmail.getId()); + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"); + } } - // If not found, create a new user - if (user == null) { - // For LDAP users without email, use "ldap:{username}@internal" as a unique identifier. - // This format: - // 1. Prevents duplicate accounts when email attribute is missing - // 2. Clearly identifies the account origin (LDAP vs local) - // 3. Follows email format to satisfy the email NOT NULL constraint - String normalizedEmail = email != null ? email.toLowerCase() : "ldap:" + username + "@internal"; + // Create a new user account. The placeholder email is only used to satisfy the NOT NULL + // constraint and never serves as an identity key. + String normalizedEmail = email != null && !email.isEmpty() + ? email.toLowerCase() + : LDAP_PROVIDER + ":" + username + "@internal"; - user = new UserAccount( - "usr_" + UUID.randomUUID(), - displayName, - normalizedEmail, - null - ); - user.setStatus(UserStatus.ACTIVE); - userAccountRepository.save(user); - globalNamespaceMembershipService.ensureMember(user.getId()); - } + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + displayName, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + user = userAccountRepository.save(user); + globalNamespaceMembershipService.ensureMember(user.getId()); + + IdentityBinding newBinding = new IdentityBinding(user.getId(), LDAP_PROVIDER, subject, username); + identityBindingRepository.save(newBinding); return user; } + private void updateFromAttributes(UserAccount user, String displayName, String email) { + if (displayName != null && !displayName.isEmpty()) { + user.setDisplayName(displayName); + } + if (email != null && !email.isEmpty()) { + user.setEmail(email.toLowerCase()); + } + } + /** * Gets a string attribute value from LDAP attributes. */ @@ -341,7 +466,16 @@ public class LdapAuthService { try { Attribute attr = attributes.get(attrName); if (attr != null && attr.get() != null) { - return attr.get().toString(); + Object value = attr.get(); + // Active Directory stores stable identifiers such as objectGUID / objectSid as + // binary (OctetString). JNDI returns these as byte[], whose toString() yields an + // unstable "[B@" — making every login look like a new identity. + // Convert binary values to a stable hexadecimal representation (mixed-endian GUID + // layout for 16-byte values) so the subject key remains stable across logins. + if (value instanceof byte[] bytes) { + return toStableGuidString(bytes); + } + return value.toString(); } } catch (Exception e) { // Ignore and return null @@ -349,6 +483,32 @@ public class LdapAuthService { return null; } + /** + * Converts a binary attribute value into a stable string suitable for use as an identity + * subject. Active Directory objectGUID is a 16-byte mixed-endian GUID; rearranging it into + * the canonical 8-4-4-4-12 hex layout yields the same string .NET/AD display, which is stable + * across JVM restarts and connections. Non-16-byte binaries fall back to plain hex so the + * value is still deterministic. + */ + private static String toStableGuidString(byte[] bytes) { + if (bytes.length == 16) { + // AD objectGUID layout: little-endian uint32, little-endian uint16 x2, big-endian rest. + return String.format("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[3] & 0xff, bytes[2] & 0xff, bytes[1] & 0xff, bytes[0] & 0xff, + bytes[5] & 0xff, bytes[4] & 0xff, + bytes[7] & 0xff, bytes[6] & 0xff, + bytes[8] & 0xff, bytes[9] & 0xff, + bytes[10] & 0xff, bytes[11] & 0xff, bytes[12] & 0xff, + bytes[13] & 0xff, bytes[14] & 0xff, bytes[15] & 0xff); + } + // Non-GUID binary attribute: deterministic plain hex so the value stays stable. + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + /** * Builds a PlatformPrincipal from a UserAccount. */ @@ -369,13 +529,33 @@ public class LdapAuthService { /** * Validates username to prevent LDAP injection attacks. - * Allows only alphanumeric characters and underscores, 3-64 characters. + * Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), + * 3-64 characters. */ private boolean isValidUsername(String username) { if (username == null || username.isEmpty()) { return false; } - // Allow alphanumeric, underscore, hyphen, dot, and @ for UPN formats - return username.matches("^[A-Za-z0-9_@.\\-]{3,64}$"); + return USERNAME_PATTERN.matcher(username).matches(); + } + + /** + * Validates all operator-configured LDAP attribute names that flow into JNDI calls. + * Rejects names that are null or fail the LDAP attribute-name pattern, preventing + * filter/attribute injection via misconfiguration before any directory call is made. + */ + private void validateAttributeNames() { + String[] attrNames = { + ldapProperties.getUserSearchAttribute(), + ldapProperties.getSubjectAttribute(), + ldapProperties.getDisplayNameAttribute(), + ldapProperties.getEmailAttribute() + }; + for (String name : attrNames) { + if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) { + log.error("Invalid LDAP attribute name configured: {}", name); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.fetchUserFailed"); + } + } } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index db6b57de..4812b57c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.transaction.annotation.Transactional; /** @@ -51,7 +52,7 @@ public class LocalAuthService { private final PasswordEncoder passwordEncoder; private final Clock clock; private final LdapProperties ldapProperties; - private final LdapAuthService ldapAuthService; + private final ObjectProvider ldapAuthServiceProvider; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -61,7 +62,7 @@ public class LocalAuthService { PasswordEncoder passwordEncoder, Clock clock, LdapProperties ldapProperties, - LdapAuthService ldapAuthService) { + ObjectProvider ldapAuthServiceProvider) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -70,7 +71,7 @@ public class LocalAuthService { this.passwordEncoder = passwordEncoder; this.clock = clock; this.ldapProperties = ldapProperties; - this.ldapAuthService = ldapAuthService; + this.ldapAuthServiceProvider = ldapAuthServiceProvider; } /** @@ -133,10 +134,15 @@ public class LocalAuthService { // Fallback to LDAP authentication if enabled if (ldapProperties.isEnabled()) { + LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable(); + if (ldapAuthService == null) { + log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username); + throw invalidCredentials(); + } log.info("Local user not found, attempting LDAP authentication for username: {}", username); log.debug("LDAP enabled: {}, host: {}, base: {}", ldapProperties.isEnabled(), - safeLogHost(ldapProperties.getUrl()), + LdapAuthService.safeLogHost(ldapProperties.getUrl()), ldapProperties.getBase()); try { PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password); @@ -144,7 +150,17 @@ public class LocalAuthService { return ldapPrincipal; } catch (AuthFlowException e) { log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage()); - // LDAP authentication failed, throw invalid credentials + // Propagate account-state, availability, and email-conflict errors instead of + // masking them as invalid credentials, so the frontend can show the right message. + // A 409 emailConflict is only reached after a successful LDAP bind, so the + // credentials are valid; masking it as 401 would mislead the user into thinking + // their password is wrong. Only genuine credential failures (userNotFound / + // invalidCredentials) fall back to the generic response to avoid enumeration. + HttpStatus status = e.getStatus(); + if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE + || status == HttpStatus.CONFLICT) { + throw e; + } throw invalidCredentials(); } } else { @@ -271,24 +287,4 @@ public class LocalAuthService { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"); } } - - /** - * Safely extracts host from LDAP URL for logging, avoiding credential exposure. - */ - private static String safeLogHost(String url) { - if (url == null || url.isEmpty()) { - return ""; - } - try { - String withoutProtocol = url.replaceFirst("^ldaps?://", ""); - int atIndex = withoutProtocol.indexOf('@'); - if (atIndex > 0) { - withoutProtocol = withoutProtocol.substring(atIndex + 1); - } - int colonIndex = withoutProtocol.indexOf(':'); - return colonIndex > 0 ? withoutProtocol.substring(0, colonIndex) : withoutProtocol; - } catch (Exception e) { - return "[url-parse-error]"; - } - } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java new file mode 100644 index 00000000..c008bf9b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthServiceTest.java @@ -0,0 +1,271 @@ + +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import java.lang.reflect.Method; +import java.util.Optional; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +/** + * Behavior-level unit tests for {@link LdapAuthService} identity provisioning. + * + *

These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via + * reflection, with all repositories mocked, so they cover the security-critical behavior called out + * in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute + * synchronization, and disabled-account rejection) without requiring a live LDAP directory. + */ +class LdapAuthServiceTest { + + private static final String SUBJECT = "entry-uuid-123"; + private static final String EMAIL = "alice@example.com"; + private static final String DISPLAY_NAME = "Alice"; + + private LdapProperties ldapProperties; + private UserAccountRepository userAccountRepository; + private UserRoleBindingRepository userRoleBindingRepository; + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private IdentityBindingRepository identityBindingRepository; + private LdapAuthService ldapAuthService; + + @BeforeEach + void setUp() { + ldapProperties = new LdapProperties(); + userAccountRepository = mock(UserAccountRepository.class); + userRoleBindingRepository = mock(UserRoleBindingRepository.class); + globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + identityBindingRepository = mock(IdentityBindingRepository.class); + ldapAuthService = new LdapAuthService( + ldapProperties, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + identityBindingRepository); + } + + /** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */ + private static Attributes directoryAttributes(String subject, String email, String displayName) { + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", subject)); + attrs.put(new BasicAttribute("mail", email)); + attrs.put(new BasicAttribute("displayName", displayName)); + return attrs; + } + + private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class); + m.setAccessible(true); + return (UserAccount) m.invoke(ldapAuthService, username, attrs); + } + + @Test + void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception { + // Given — no existing binding and no email collision + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When + UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — new active account bound to the LDAP subject; placeholder never used as identity key + assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME); + assertThat(created.getEmail()).isEqualTo(EMAIL); + verify(globalNamespaceMembershipService).ensureMember(created.getId()); + verify(identityBindingRepository).save(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception { + // Given — the LDAP subject is already bound to an account (prior login) + String existingUserId = "usr_existing"; + UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null); + existing.setStatus(UserStatus.ACTIVE); + IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(binding)); + given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — same subject logs in again + UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + + // Then — returns the same account, never provisions a new one + assertThat(result.getId()).isEqualTo(existingUserId); + verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat( + u -> !existingUserId.equals(u.getId()))); + // Critical: no new binding written on repeat login + verify(identityBindingRepository, never()).save(any(IdentityBinding.class)); + } + + @Test + void repeatLogin_refreshesAttributesFromDirectory() throws Exception { + // Given — a returning user whose display name and email changed in the directory + String userId = "usr_alice"; + UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice"))); + given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing)); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + // When — directory now reports a new display name and email + UserAccount result = invokeFindOrCreate("alice", + directoryAttributes(SUBJECT, "new@example.com", "New Name")); + + // Then — attributes are refreshed on this login (not only at first creation) + assertThat(result.getDisplayName()).isEqualTo("New Name"); + assertThat(result.getEmail()).isEqualTo("new@example.com"); + } + + @Test + void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception { + // Given — a different identity provider already owns this email + String otherUserId = "usr_oauth"; + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); // no LDAP binding yet + given(userAccountRepository.findByEmailIgnoreCase(EMAIL)) + .willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null))); + + // When — must NOT silently inherit the OAuth account / its roles. + // Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert + // the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key. + AuthFlowException thrown = null; + try { + invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME)); + } catch (java.lang.reflect.InvocationTargetException ite) { + thrown = (AuthFlowException) ite.getCause(); + } + assertThat(thrown).isNotNull(); + assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict"); + + // No account created, no binding written + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(identityBindingRepository, never()).save(any(IdentityBinding.class)); + } + + @Test + void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception { + // Given — directory entry has no mail attribute; subject is the only stable key + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + // No email -> no email-collision lookup happens; placeholder email is generated + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("displayName", DISPLAY_NAME)); + + // When + UserAccount created = invokeFindOrCreate("bob", attrs); + + // Then — placeholder email follows the ldap:{username}@internal convention + assertThat(created.getEmail()).isEqualTo("ldap:bob@internal"); + verify(userAccountRepository, never()).findByEmailIgnoreCase(any()); + verify(identityBindingRepository).save(any(IdentityBinding.class)); + } + + @Test + void missingSubjectAttribute_throwsServiceUnavailable() { + // Given — bind succeeded but the entry lacks the configured subject attribute + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("mail", EMAIL)); + + // When & Then — a 503 (not a 401) so the user is not misled into thinking the password is wrong + assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs)) + .hasCauseInstanceOf(AuthFlowException.class); + try { + invokeFindOrCreate("alice", attrs); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } catch (Throwable t) { + throw new AssertionError(t); + } + } + + @Test + void ensureUserCanLogin_rejectsDisabledAccount() throws Exception { + // The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401) + UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null); + disabled.setStatus(UserStatus.DISABLED); + + Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class); + m.setAccessible(true); + try { + m.invoke(ldapAuthService, disabled); + } catch (java.lang.reflect.InvocationTargetException ite) { + AuthFlowException cause = (AuthFlowException) ite.getCause(); + assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled"); + } + } + + @Test + void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception { + // Given — directory has no displayName but has cn; configured fallback defaults to "cn" + assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn"); + given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT)) + .willReturn(Optional.empty()); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0)); + + BasicAttributes attrs = new BasicAttributes(); + attrs.put(new BasicAttribute("entryUUID", SUBJECT)); + attrs.put(new BasicAttribute("cn", "Common Name")); + + // When + UserAccount created = invokeFindOrCreate("carol", attrs); + + // Then — display name falls back to the configured cn attribute + assertThat(created.getDisplayName()).isEqualTo("Common Name"); + } + + @Test + void isTlsFailure_detectsSslHandshakeInCauseChain() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_detectsDeepCertificateException() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("TLS handshake failed", + new java.security.cert.CertificateException("not trusted"))); + + assertThat(LdapAuthService.isTlsFailure(comm)).isTrue(); + } + + @Test + void isTlsFailure_ignoresPlainConnectionFailures() { + javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed"); + comm.initCause(new java.io.IOException("Connection refused")); + + assertThat(LdapAuthService.isTlsFailure(comm)).isFalse(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java new file mode 100644 index 00000000..d64928bc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapSubjectGuidTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for AD objectGUID binary normalization. + * + *

Active Directory stores objectGUID as a 16-byte mixed-endian OctetString. Before the fix, + * {@code getAttributeValue} called {@code toString()} on the {@code byte[]} returned by JNDI, + * producing an unstable {@code "[B@"} that changed on every login and caused + * each login to provision a brand-new account. These tests lock in the stable canonical-GUID + * conversion via reflection on the private {@code toStableGuidString} helper. + */ +class LdapSubjectGuidTest { + + private static String invoke(byte[] bytes) throws Exception { + Method m = LdapAuthService.class.getDeclaredMethod("toStableGuidString", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, bytes); + } + + @Test + void objectGuid_byteArray_isStableCanonicalGuid() throws Exception { + // AD objectGUID {0x8b,0xe3,0x9d,0x4c,...} little-endian -> 4c9de38b-... + // The same logical GUID must always serialize to the same string regardless of which + // byte[] instance JNDI handed back, so repeat-login identity matching stays stable. + byte[] guid = new byte[]{ + (byte) 0x8b, (byte) 0xe3, (byte) 0x9d, 0x4c, // LE uint32 -> 4c9de38b + (byte) 0xb5, 0x55, // LE uint16 -> 55b5 + (byte) 0xe8, 0x42, // LE uint16 -> 42e8 + (byte) 0x8e, 0x2f, // big-endian -> 8e2f + (byte) 0x9a, 0x41, (byte) 0xc3, 0x77, (byte) 0xa6, 0x71 // node -> 9a41c377a671 + }; + + String first = invoke(guid); + String second = invoke(guid.clone()); // different instance, same bytes + + assertThat(first).isEqualTo("4c9de38b-55b5-42e8-8e2f-9a41c377a671"); + assertThat(second).isEqualTo(first); // deterministic across instances + } + + @Test + void nonGuidByteArray_fallsBackToDeterministicHex() throws Exception { + byte[] sid = new byte[]{0x01, 0x00, 0x04, (byte) 0x80, 0x14, 0x00, 0x00, 0x00}; + + String first = invoke(sid); + String second = invoke(sid.clone()); + + assertThat(first).isEqualTo("0100048014000000"); + assertThat(second).isEqualTo(first); // stable even for non-GUID binaries + } + + @Test + void sameGuid_differentInstances_produceSameString() throws Exception { + // The core regression: two independent byte[] with identical content must yield identical + // strings, proving identity matching will be stable across LDAP connections. + byte[] a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + byte[] b = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + + assertThat(invoke(a)).isEqualTo(invoke(b)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index ab5b87e7..0acea937 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -61,10 +61,14 @@ class LocalAuthServiceTest { @Mock private LdapAuthService ldapAuthService; + @Mock + private org.springframework.beans.factory.ObjectProvider ldapAuthServiceProvider; + private LocalAuthService service; @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService); service = new LocalAuthService( credentialRepository, userAccountRepository, @@ -74,7 +78,7 @@ class LocalAuthServiceTest { passwordEncoder, CLOCK, ldapProperties, - ldapAuthService + ldapAuthServiceProvider ); } @@ -308,6 +312,56 @@ class LocalAuthServiceTest { verify(ldapAuthService).login("ldapuser", "WrongPassword"); } + @Test + void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() { + // Given — LDAP directory unavailable should surface as 503, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable")); + + // When & Then — 503 must propagate so the frontend can show "try again later" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() { + // Given — a disabled LDAP account (403) should propagate, not be masked as 401 + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled")); + + // When & Then — 403 must propagate so the frontend can show "account disabled" + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() { + // Given — an email-conflict (409) is only raised after a successful LDAP bind, so the + // credentials are valid; it must propagate so the frontend can guide the user to an + // explicit account-link flow rather than reporting a misleading "wrong password". + given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty()); + given(ldapProperties.isEnabled()).willReturn(true); + + given(ldapAuthService.login("ldapuser", "password")) + .willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict")); + + // When & Then — 409 is propagated, not masked as 401 + assertThatThrownBy(() -> service.login("ldapuser", "password")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.CONFLICT); + } + @Test void login_withUnknownUsername_fails_whenLdapDisabled() { // Given