This commit is contained in:
Jangrui 2026-08-27 15:16:52 +08:00 committed by GitHub
commit 585052f542
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3244 additions and 21 deletions

1
.gitignore vendored
View file

@ -55,6 +55,7 @@ target/
coverage/
dist/
node_modules/
.pnpm-store/
*.tsbuildinfo
package-lock.json

View file

@ -121,6 +121,11 @@
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@ -129,6 +134,33 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<executions>
<execution>
<id>default-test</id>
<configuration>
<excludes>
<exclude>**/auth/ldap/LdapIntegrationTest.java</exclude>
</excludes>
</configuration>
</execution>
<execution>
<id>ldap-container-test</id>
<phase>test</phase>
<goals>
<goal>test</goal>
</goals>
<configuration>
<includes>
<include>**/auth/ldap/LdapIntegrationTest.java</include>
</includes>
<reuseForks>false</reuseForks>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,40 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.LdapBindRequest;
import com.iflytek.skillhub.service.LdapBindingAppService;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Endpoints for binding an LDAP identity to the currently authenticated account.
*/
@RestController
@RequestMapping("/api/v1/auth/ldap")
public class LdapAuthController extends BaseApiController {
private final LdapBindingAppService ldapBindingAppService;
public LdapAuthController(ApiResponseFactory responseFactory,
LdapBindingAppService ldapBindingAppService) {
super(responseFactory);
this.ldapBindingAppService = ldapBindingAppService;
}
@PostMapping("/bind")
public ApiResponse<Void> bind(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody LdapBindRequest request) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
ldapBindingAppService.bindLdapIdentity(principal.userId(), request.username(), request.password());
return ok("response.success", null);
}
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Binds an LDAP identity to the currently authenticated account using the user's LDAP
* credentials as proof of directory-identity ownership.
*/
public record LdapBindRequest(
@NotBlank(message = "LDAP 用户名不能为空")
String username,
@NotBlank(message = "LDAP 密码不能为空")
String password
) {}

View file

@ -0,0 +1,74 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.ldap.LdapAuthService;
import com.iflytek.skillhub.auth.ldap.LdapAuthService.LdapIdentity;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.util.Locale;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Explicit LDAP identity-binding flow: a signed-in user proves ownership of a directory identity
* with their LDAP credentials and attaches it to their current account. This is the
* self-service counterpart of the first-login email-conflict refusal instead of silently
* inheriting an existing account, the user consciously binds the LDAP identity to it.
*/
@Service
public class LdapBindingAppService {
private static final String LDAP_PROVIDER = "ldap";
private final ObjectProvider<LdapAuthService> ldapAuthServiceProvider;
private final IdentityBindingRepository identityBindingRepository;
private final UserAccountRepository userAccountRepository;
public LdapBindingAppService(ObjectProvider<LdapAuthService> ldapAuthServiceProvider,
IdentityBindingRepository identityBindingRepository,
UserAccountRepository userAccountRepository) {
this.ldapAuthServiceProvider = ldapAuthServiceProvider;
this.identityBindingRepository = identityBindingRepository;
this.userAccountRepository = userAccountRepository;
}
@Transactional
public void bindLdapIdentity(String currentUserId, String username, String password) {
LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable();
if (ldapAuthService == null) {
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled");
}
LdapIdentity identity = ldapAuthService.resolveIdentity(username, password);
var existingBinding = identityBindingRepository
.findByProviderCodeAndSubject(LDAP_PROVIDER, identity.subject());
if (existingBinding.isPresent()) {
if (!existingBinding.get().getUserId().equals(currentUserId)) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.bindingTaken");
}
// Already bound to the current account idempotent success.
return;
}
String email = identity.email();
if (email != null && !email.isEmpty()) {
userAccountRepository.findByEmailIgnoreCase(email.toLowerCase(Locale.ROOT))
.filter(existing -> !existing.getId().equals(currentUserId))
.ifPresent(existing -> {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict");
});
}
try {
identityBindingRepository.save(
new IdentityBinding(currentUserId, LDAP_PROVIDER, identity.subject(), identity.username()));
} catch (DataIntegrityViolationException e) {
// A concurrent bind for the same subject won the (provider_code, subject) race.
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.bindingTaken");
}
}
}

View file

@ -117,6 +117,29 @@ skillhub:
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
ldap:
# LDAP authentication configuration
# Set enabled to true and configure url/base/username/password to enable LDAP authentication
enabled: ${SKILLHUB_LDAP_ENABLED:false}
url: ${SKILLHUB_LDAP_URL:}
base: ${SKILLHUB_LDAP_BASE:}
username: ${SKILLHUB_LDAP_USERNAME:}
password: ${SKILLHUB_LDAP_PASSWORD:}
user-search-attribute: ${SKILLHUB_LDAP_USER_SEARCH_ATTRIBUTE:uid}
user-search-base: ${SKILLHUB_LDAP_USER_SEARCH_BASE:}
# Stable directory identifier used as the LDAP identity subject (entryUUID for OpenLDAP, objectGUID for AD)
subject-attribute: ${SKILLHUB_LDAP_SUBJECT_ATTRIBUTE:entryUUID}
display-name-attribute: ${SKILLHUB_LDAP_DISPLAY_NAME_ATTRIBUTE:displayName}
display-name-fallback-attribute: ${SKILLHUB_LDAP_DISPLAY_NAME_FALLBACK_ATTRIBUTE:cn}
email-attribute: ${SKILLHUB_LDAP_EMAIL_ATTRIBUTE:mail}
connect-timeout-millis: ${SKILLHUB_LDAP_CONNECT_TIMEOUT_MILLIS:5000}
read-timeout-millis: ${SKILLHUB_LDAP_READ_TIMEOUT_MILLIS:10000}
# Custom trust store for LDAPS certificate validation (internal/self-signed CAs).
# Installed at application startup by merging into the JVM-wide trust store (defaults are
# preserved). Leave empty to use the JVM default trust store.
tls-trust-store: ${SKILLHUB_LDAP_TLS_TRUST_STORE:}
tls-trust-store-password: ${SKILLHUB_LDAP_TLS_TRUST_STORE_PASSWORD:}
tls-trust-store-type: ${SKILLHUB_LDAP_TLS_TRUST_STORE_TYPE:JKS}
public:
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
access-policy:
@ -250,6 +273,8 @@ management:
health:
mail:
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}
ldap:
enabled: false
endpoints:
web:
exposure:

View file

@ -0,0 +1,7 @@
-- identity_binding.user_id references user_account(id) without ON DELETE CASCADE. When an
-- account is removed, its bindings would otherwise survive and block the identity from being
-- provisioned again. Cascade the deletion so bindings never outlive their account.
ALTER TABLE identity_binding DROP CONSTRAINT identity_binding_user_id_fkey;
ALTER TABLE identity_binding
ADD CONSTRAINT identity_binding_user_id_fkey
FOREIGN KEY (user_id) REFERENCES user_account(id) ON DELETE CASCADE;

View file

@ -46,6 +46,14 @@ error.auth.direct.providerUnsupported=Unsupported direct authentication provider
error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0}
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
error.auth.ldap.disabled=LDAP authentication is not enabled
error.auth.ldap.userNotFound=Invalid username or password
error.auth.ldap.invalidCredentials=Invalid username or password
error.auth.ldap.invalidConfiguration=LDAP authentication is misconfigured. Please contact an administrator
error.auth.ldap.directoryUnavailable=The directory server is temporarily unavailable. Please try again later
error.auth.ldap.tlsError=Failed to establish a secure connection to the directory server. Please check the TLS certificate configuration
error.auth.ldap.emailConflict=This email is already associated with an existing account. Please contact an administrator
error.auth.ldap.bindingTaken=This LDAP identity is already bound to another account
error.badRequest=Invalid request
error.methodNotAllowed=HTTP method is not supported
error.unsupportedMediaType=Unsupported media type

View file

@ -46,6 +46,14 @@ error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0}
error.auth.sessionBootstrap.disabled=会话引导能力未启用
error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0}
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
error.auth.ldap.disabled=LDAP 认证未启用
error.auth.ldap.userNotFound=用户名或密码错误
error.auth.ldap.invalidCredentials=用户名或密码错误
error.auth.ldap.invalidConfiguration=LDAP 认证配置有误,请联系管理员处理
error.auth.ldap.directoryUnavailable=目录服务器暂时不可用,请稍后重试
error.auth.ldap.tlsError=无法与目录服务器建立安全连接,请检查 TLS 证书配置
error.auth.ldap.emailConflict=该邮箱已关联已有账号,请联系管理员处理
error.auth.ldap.bindingTaken=该 LDAP 身份已绑定到其他账号
error.badRequest=请求参数不合法
error.methodNotAllowed=不支持的请求方法
error.unsupportedMediaType=不支持的请求内容类型

View file

@ -0,0 +1,166 @@
package com.iflytek.skillhub.auth.ldap;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.Container.ExecResult;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.MountableFile;
/**
* Concurrency coverage for LDAP first-login provisioning: two simultaneous first logins for the
* same LDAP subject must resolve to a single account and a single identity binding. One login
* wins the {@code (provider_code, subject)} unique-constraint race; the loser re-resolves the
* existing identity in a fresh transaction and still succeeds.
*/
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers(disabledWithoutDocker = true)
class ConcurrentLdapFirstLoginTest {
private static final String BASE_DN = "dc=example,dc=org";
private static final String BIND_DN = "cn=admin," + BASE_DN;
@Container
static final GenericContainer<?> LDAP = new GenericContainer<>("osixia/openldap:1.5.0")
.withEnv("LDAP_ORGANISATION", "Example Inc")
.withEnv("LDAP_DOMAIN", "example.org")
.withEnv("LDAP_ADMIN_PASSWORD", "admin")
.withExposedPorts(389);
@DynamicPropertySource
static void ldapProperties(DynamicPropertyRegistry registry) {
registry.add("skillhub.ldap.enabled", () -> "true");
registry.add("skillhub.ldap.url", () -> "ldap://" + LDAP.getHost() + ":" + LDAP.getMappedPort(389));
registry.add("skillhub.ldap.base", () -> BASE_DN);
registry.add("skillhub.ldap.username", () -> BIND_DN);
registry.add("skillhub.ldap.password", () -> "admin");
}
@Autowired
private LocalAuthService localAuthService;
@Autowired
private UserAccountRepository userAccountRepository;
@Autowired
private IdentityBindingRepository identityBindingRepository;
@Autowired
private NamespaceRepository namespaceRepository;
@BeforeEach
void ensureGlobalNamespace() {
namespaceRepository.findBySlug("global")
.orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap")));
}
@BeforeAll
static void seedDirectory() throws Exception {
LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif");
awaitLdapReady();
ExecResult add = LDAP.execInContainer("ldapadd", "-x", "-H", "ldap://localhost",
"-D", BIND_DN, "-w", "admin", "-f", "/tmp/seed-users.ldif");
assertThat(add.getExitCode())
.as("ldapadd failed: %s", add.getStdout() + add.getStderr())
.isZero();
}
private static void awaitLdapReady() throws Exception {
long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) {
try {
ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost",
"-b", BASE_DN, "-D", BIND_DN, "-w", "admin", "(objectClass=*)", "dn");
if (r.getExitCode() == 0) {
return;
}
} catch (Exception ignored) {
}
Thread.sleep(500);
}
throw new IllegalStateException("OpenLDAP did not become ready");
}
@Test
void concurrentFirstLogin_sameSubject_singleAccountAndBothSucceed() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<PlatformPrincipal> login = () -> localAuthService.login("alice", "alice123");
Future<PlatformPrincipal> first = pool.submit(login);
Future<PlatformPrincipal> second = pool.submit(login);
PlatformPrincipal r1 = first.get();
PlatformPrincipal r2 = second.get();
// Both concurrent first logins must succeed and resolve to the same account;
// exactly one identity binding and one account exist afterwards.
assertThat(r1.userId()).isEqualTo(r2.userId());
assertThat(identityBindingRepository.findAll()).hasSize(1);
assertThat(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).isPresent();
} finally {
pool.shutdownNow();
}
}
@Test
void concurrentFirstLogin_differentSubjectsSameEmail_oneAccountAndOneConflict() throws Exception {
// Two distinct LDAP subjects share one email and log in for the first time at the same
// moment. The email-collision check must be serialized: exactly one provisioning succeeds
// and the other receives 409 never two accounts with the same email.
long bindingsBefore = identityBindingRepository.findAll().size();
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<Object> eve = pool.submit(() -> (Object) localAuthService.login("eve", "eve123"));
Future<Object> frank = pool.submit(() -> (Object) localAuthService.login("frank", "frank123"));
List<Object> results = List.of(unwrap(eve), unwrap(frank));
long successes = results.stream().filter(PlatformPrincipal.class::isInstance).count();
long conflicts = results.stream()
.filter(t -> t instanceof AuthFlowException e && e.getStatus() == HttpStatus.CONFLICT)
.count();
assertThat(successes).as("exactly one of the two first logins succeeds").isEqualTo(1);
assertThat(conflicts).as("the other login is refused with 409").isEqualTo(1);
assertThat(userAccountRepository.findByEmailIgnoreCase("shared@example.com"))
.as("the successful login provisioned exactly one account for the shared email")
.isPresent();
assertThat(identityBindingRepository.findAll())
.as("only the successful subject is bound (one new binding, none for the 409 loser)")
.hasSize((int) bindingsBefore + 1);
} finally {
pool.shutdownNow();
}
}
private static Object unwrap(Future<?> future) throws Exception {
try {
return future.get();
} catch (ExecutionException e) {
return e.getCause();
}
}
}

View file

@ -0,0 +1,73 @@
package com.iflytek.skillhub.auth.ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.iflytek.skillhub.auth.config.LdapProperties;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import jakarta.persistence.EntityManager;
import java.io.IOException;
import java.net.ServerSocket;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.transaction.PlatformTransactionManager;
/**
* Integration coverage for the "directory unavailable" error classification required by the
* PR #437 review. A real JNDI connection is attempted against a guaranteed-closed localhost
* port, so the CommunicationException path (not a mocked exception) is exercised end to end.
*
* <p>This test intentionally does not use Testcontainers: it needs no directory, and keeping it
* standalone lets it run in any environment, including ones without a Docker daemon.
*/
class LdapDirectoryUnavailableTest {
@Test
void login_whenDirectoryUnreachable_returnsServiceUnavailable() {
LdapProperties props = new LdapProperties();
props.setEnabled(true);
props.setUrl("ldap://127.0.0.1:" + freePort());
props.setBase("dc=example,dc=org");
LdapContextSource contextSource = new LdapContextSource();
contextSource.setUrl(props.getUrl());
contextSource.setPooled(false);
contextSource.afterPropertiesSet();
LdapAuthService svc = new LdapAuthService(
props,
contextSource,
mock(UserAccountRepository.class),
mock(UserRoleBindingRepository.class),
mock(GlobalNamespaceMembershipService.class),
mock(IdentityBindingRepository.class),
mock(EntityManager.class),
mock(PlatformTransactionManager.class));
assertThatThrownBy(() -> svc.login("alice", "secret"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> {
AuthFlowException ex = (AuthFlowException) e;
assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.directoryUnavailable");
});
}
/**
* Reserves an ephemeral port and releases it, leaving a port that is (almost certainly)
* closed for the subsequent connection attempt.
*/
private static int freePort() {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
} catch (IOException e) {
throw new IllegalStateException("Failed to allocate an ephemeral port", e);
}
}
}

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.auth.ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.config.LdapProperties;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.ActiveProfiles;
/**
* Integration coverage for "startup with LDAP disabled" required by the PR #437 review: the full
* Spring context must boot without a directory configured, the conditional {@link LdapAuthService}
* bean must be absent, and the local login fallback must degrade to invalid credentials instead of
* failing on a missing LDAP bean.
*/
@SpringBootTest(properties = "skillhub.ldap.enabled=false")
@ActiveProfiles("test")
class LdapDisabledStartupTest {
@Autowired
private ApplicationContext context;
@Autowired
private LdapProperties ldapProperties;
@Autowired
private LocalAuthService localAuthService;
@Test
void contextStartsWithoutLdapAuthServiceBean() {
assertThat(ldapProperties.isEnabled()).isFalse();
// The bean is created only when skillhub.ldap.enabled=true; with LDAP disabled the
// context must start without it (LocalAuthService consumes it via ObjectProvider).
assertThat(context.getBeansOfType(LdapAuthService.class)).isEmpty();
assertThat(localAuthService).isNotNull();
}
@Test
void localLogin_withoutLdapBean_fallsBackToInvalidCredentials() {
assertThatThrownBy(() -> localAuthService.login("no-such-user", "wrong-password"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> assertThat(((AuthFlowException) e).getStatus())
.isEqualTo(HttpStatus.UNAUTHORIZED));
}
}

View file

@ -0,0 +1,420 @@
package com.iflytek.skillhub.auth.ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.config.LdapProperties;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.service.LdapBindingAppService;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import jakarta.persistence.EntityManager;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.testcontainers.containers.Container.ExecResult;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.MountableFile;
/**
* End-to-end LDAP coverage against a real OpenLDAP directory (Testcontainers) for the behavior
* required by PR #437 review: first-login provisioning, repeat-login identity stability,
* no-email placeholder handling, email-collision refusal, invalid credentials, attribute
* synchronization, and LDAPS TLS-failure classification.
*
* <p>The default {@code subject-attribute=entryUUID} is intentionally left untouched: OpenLDAP
* exposes entryUUID only as an operational attribute, which previously made every login fail
* with a 503 unless the operator changed the subject attribute.
*/
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers(disabledWithoutDocker = true)
class LdapIntegrationTest {
private static final String BASE_DN = "dc=example,dc=org";
private static final String BIND_DN = "cn=admin," + BASE_DN;
private static final String BIND_PASSWORD = "admin";
/**
* The image's baked-in TLS certificates expired in 2026, which makes any LDAPS success path
* impossible. Generate a fresh CA and server certificate with the JDK's keytool before the
* container starts, and let {@code withCopyFileToContainer} install them over the baked-in
* files. The container's entrypoint only generates certificates when the files are absent.
*/
private static final Path TLS_CERTS_DIR = prepareTlsCertificates();
/**
* The JDK LDAP provider resolves LDAPS trust from the JVM-wide SSL configuration and offers
* no per-connection trust-store injection point, so the test CA must be installed through the
* {@code javax.net.ssl.trustStore*} system properties before any JNDI connection (and thus
* before the JSSE default SSLContext is cached). The static initializer runs at class load,
* before the Spring context and the LDAP container are created.
*/
static {
try {
Path truststore = Files.createTempFile("ldap-truststore", ".p12");
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
try (InputStream in = Files.newInputStream(TLS_CERTS_DIR.resolve("ca.crt"))) {
ks.setCertificateEntry("ldap-ca", cf.generateCertificate(in));
}
try (OutputStream out = Files.newOutputStream(truststore)) {
ks.store(out, "changeit".toCharArray());
}
System.setProperty("javax.net.ssl.trustStore", truststore.toString());
System.setProperty("javax.net.ssl.trustStorePassword", "changeit");
System.setProperty("javax.net.ssl.trustStoreType", "PKCS12");
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Container
static final GenericContainer<?> LDAP = new GenericContainer<>("osixia/openldap:1.5.0")
.withEnv("LDAP_ORGANISATION", "Example Inc")
.withEnv("LDAP_DOMAIN", "example.org")
.withEnv("LDAP_ADMIN_PASSWORD", BIND_PASSWORD)
// The image defaults to olcTLSVerifyClient=demand, which requires client certificates
// during the TLS handshake. The tests exercise server-certificate validation only.
.withEnv("LDAP_TLS_VERIFY_CLIENT", "never")
.withExposedPorts(389, 636)
.withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ca.crt")),
"/container/service/slapd/assets/certs/ca.crt")
.withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.crt")),
"/container/service/slapd/assets/certs/ldap.crt")
.withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.key")),
"/container/service/slapd/assets/certs/ldap.key");
private static Path prepareTlsCertificates() {
try {
Path dir = Files.createTempDirectory("ldap-tls-certs");
runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "ca",
"-dname", "CN=SkillHub Test CA", "-validity", "3650", "-keyalg", "RSA",
"-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "ca.p12",
"-storepass", "changeit", "-keypass", "changeit",
"-ext", "BasicConstraints=ca:true"));
runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "server",
"-dname", "CN=ldap.example.org", "-validity", "3650", "-keyalg", "RSA",
"-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "server.p12",
"-storepass", "changeit", "-keypass", "changeit"));
runKeytool(dir, List.of("keytool", "-certreq", "-alias", "server",
"-keystore", "server.p12", "-storepass", "changeit", "-file", "server.csr"));
runKeytool(dir, List.of("keytool", "-gencert", "-alias", "ca",
"-keystore", "ca.p12", "-storepass", "changeit", "-infile", "server.csr",
"-rfc", "-validity", "3650",
"-ext", "BasicConstraints=ca:false",
"-ext", "KeyUsage=digitalSignature,keyEncipherment",
"-ext", "ExtendedKeyUsage=serverAuth",
"-outfile", "server.crt"));
runKeytool(dir, List.of("keytool", "-exportcert", "-alias", "ca",
"-keystore", "ca.p12", "-storepass", "changeit", "-rfc", "-file", "ca.crt"));
KeyStore ks = KeyStore.getInstance("PKCS12");
try (InputStream in = Files.newInputStream(dir.resolve("server.p12"))) {
ks.load(in, "changeit".toCharArray());
}
PrivateKey key = (PrivateKey) ks.getKey("server", "changeit".toCharArray());
String pem = "-----BEGIN PRIVATE KEY-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(key.getEncoded())
+ "\n-----END PRIVATE KEY-----\n";
Files.writeString(dir.resolve("ldap.key"), pem);
// slapd runs as the openldap user; the key must be world-readable inside the container.
Files.setPosixFilePermissions(dir.resolve("ldap.key"),
java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--"));
Files.copy(dir.resolve("server.crt"), dir.resolve("ldap.crt"));
return dir;
} catch (Exception e) {
throw new IllegalStateException("Failed to prepare LDAPS test certificates", e);
}
}
private static void runKeytool(Path dir, List<String> command) throws Exception {
Process process = new ProcessBuilder(command)
.directory(dir.toFile())
.redirectErrorStream(true)
.start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
int exit = process.waitFor();
if (exit != 0) {
throw new IllegalStateException(command.get(0) + " failed (" + exit + "): " + output);
}
}
@DynamicPropertySource
static void ldapProperties(DynamicPropertyRegistry registry) {
registry.add("skillhub.ldap.enabled", () -> "true");
registry.add("skillhub.ldap.url", () -> "ldap://" + LDAP.getHost() + ":" + LDAP.getMappedPort(389));
registry.add("skillhub.ldap.base", () -> BASE_DN);
registry.add("skillhub.ldap.username", () -> BIND_DN);
registry.add("skillhub.ldap.password", () -> BIND_PASSWORD);
registry.add("skillhub.ldap.user-search-attribute", () -> "uid");
// subject-attribute intentionally stays at its default (entryUUID).
}
@Autowired
private LocalAuthService localAuthService;
@Autowired
private UserAccountRepository userAccountRepository;
@Autowired
private IdentityBindingRepository identityBindingRepository;
@Autowired
private NamespaceRepository namespaceRepository;
@Autowired
private LdapBindingAppService ldapBindingAppService;
@BeforeEach
void ensureGlobalNamespace() {
namespaceRepository.findBySlug("global")
.orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap")));
}
@BeforeAll
static void seedDirectory() throws Exception {
// The OpenLDAP container's certificate is issued for cn=ldap.example.org, while the test
// connects through the container's mapped address. The JNDI LDAP provider performs
// endpoint identification (hostname verification) by default, which would reject the
// address even with a trusted CA. Disable endpoint identification for this test JVM so
// the custom-truststore success path exercises certificate-chain validation only.
System.setProperty("com.sun.jndi.ldap.object.disableEndpointIdentification", "true");
LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif");
LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/modify-dave.ldif"), "/tmp/modify-dave.ldif");
awaitLdapReady();
ExecResult add = LDAP.execInContainer("ldapadd", "-x", "-H", "ldap://localhost",
"-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/seed-users.ldif");
assertThat(add.getExitCode())
.as("ldapadd failed: %s", add.getStdout() + add.getStderr())
.isZero();
}
@AfterAll
static void restoreEndpointIdentification() {
System.clearProperty("com.sun.jndi.ldap.object.disableEndpointIdentification");
}
private static void awaitLdapReady() throws Exception {
long deadline = System.currentTimeMillis() + 30_000;
Exception last = null;
while (System.currentTimeMillis() < deadline) {
try {
ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost",
"-b", BASE_DN, "-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "dn");
if (r.getExitCode() == 0) {
return;
}
last = new IllegalStateException("ldapsearch exit " + r.getExitCode()
+ ": " + r.getStdout() + r.getStderr());
} catch (Exception e) {
last = e;
}
Thread.sleep(500);
}
throw new IllegalStateException("OpenLDAP did not become ready", last);
}
@Test
void firstLogin_withDefaultEntryUuidSubject_provisionsAccountAndBinding() throws Exception {
PlatformPrincipal principal = localAuthService.login("alice", "alice123");
assertThat(principal.userId()).isNotBlank();
Optional<UserAccount> account = userAccountRepository.findById(principal.userId());
assertThat(account).isPresent();
assertThat(account.get().getEmail()).isEqualTo("alice@example.com");
assertThat(account.get().getDisplayName()).isEqualTo("Alice Smith");
String entryUuid = directoryEntryUuid("alice");
Optional<IdentityBinding> binding =
identityBindingRepository.findByProviderCodeAndSubject("ldap", entryUuid);
assertThat(binding).as("identity binding keyed by OpenLDAP entryUUID").isPresent();
}
@Test
void repeatLogin_returnsSameAccount_andDoesNotDuplicate() throws Exception {
PlatformPrincipal first = localAuthService.login("alice", "alice123");
PlatformPrincipal second = localAuthService.login("alice", "alice123");
assertThat(second.userId()).isEqualTo(first.userId());
Optional<UserAccount> account = userAccountRepository.findByEmailIgnoreCase("alice@example.com");
assertThat(account).isPresent();
assertThat(account.get().getId()).isEqualTo(first.userId());
// Exactly one binding exists for this subject; no duplicate provisioning happened.
Optional<IdentityBinding> binding =
identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("alice"));
assertThat(binding).isPresent();
assertThat(binding.get().getUserId()).isEqualTo(first.userId());
}
@Test
void noEmailUser_usesPlaceholderEmail_andCnFallback() {
PlatformPrincipal principal = localAuthService.login("bob", "bob123");
assertThat(principal.email()).isEqualTo("ldap:bob@internal");
// bob has no displayName in the directory; the configured cn fallback must apply.
assertThat(principal.displayName()).isEqualTo("Bob Jones");
}
@Test
void emailCollision_returnsConflict_andDoesNotTakeOverExistingAccount() throws Exception {
UserAccount local = new UserAccount("usr_local_carol", "Carol Local", "carol@example.com", null);
userAccountRepository.save(local);
assertThatThrownBy(() -> localAuthService.login("carol", "carol123"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> {
AuthFlowException ex = (AuthFlowException) e;
assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT);
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict");
});
// The existing account is untouched and no LDAP binding was written.
Optional<UserAccount> untouched = userAccountRepository.findById(local.getId());
assertThat(untouched).isPresent();
assertThat(untouched.get().getDisplayName()).isEqualTo("Carol Local");
assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("carol")))
.isEmpty();
}
@Test
void wrongPassword_returnsUnauthorized() {
assertThatThrownBy(() -> localAuthService.login("alice", "wrong-password"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED));
}
@Test
void emptyPassword_returnsUnauthorized_notServerError() {
// Direct service-level coverage: a null password must be classified as a credential
// failure (401), never a NullPointerException/500. (The HTTP layer already rejects
// blank passwords via @NotBlank; this guards service-level callers.)
assertThatThrownBy(() -> localAuthService.login("alice", null))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED));
}
@Test
void attributeChanges_areRefreshedOnNextLogin() throws Exception {
PlatformPrincipal before = localAuthService.login("dave", "dave123");
assertThat(before.displayName()).isEqualTo("Dave Miller");
ExecResult mod = LDAP.execInContainer("ldapmodify", "-x", "-H", "ldap://localhost",
"-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/modify-dave.ldif");
assertThat(mod.getExitCode())
.as("ldapmodify failed: %s", mod.getStdout() + mod.getStderr())
.isZero();
PlatformPrincipal after = localAuthService.login("dave", "dave123");
assertThat(after.userId()).isEqualTo(before.userId());
assertThat(after.displayName()).isEqualTo("Dave D. Miller");
}
@Test
void ldaps_withCustomTrustStore_authenticatesSuccessfully() throws Exception {
// The test CA is installed JVM-wide by the static initializer (the JDK LDAP provider has
// no per-connection trust-store injection point). This test verifies the full LDAPS chain
// (search, bind, attribute read) succeeds with that trust store in place.
LdapProperties props = new LdapProperties();
props.setEnabled(true);
props.setUrl("ldaps://" + LDAP.getHost() + ":" + LDAP.getMappedPort(636));
props.setBase(BASE_DN);
props.setUsername(BIND_DN);
props.setPassword(BIND_PASSWORD);
UserAccountRepository userRepo = mock(UserAccountRepository.class);
when(userRepo.findByEmailIgnoreCase(any())).thenReturn(Optional.empty());
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
IdentityBindingRepository bindingRepo = mock(IdentityBindingRepository.class);
when(bindingRepo.findByProviderCodeAndSubject(any(), any())).thenReturn(Optional.empty());
LdapContextSource contextSource = new LdapContextSource();
contextSource.setUrl(props.getUrl());
contextSource.setUserDn(BIND_DN);
contextSource.setPassword(BIND_PASSWORD);
contextSource.setPooled(false);
contextSource.afterPropertiesSet();
LdapAuthService svc = new LdapAuthService(props,
contextSource,
userRepo,
mock(UserRoleBindingRepository.class),
mock(GlobalNamespaceMembershipService.class),
bindingRepo,
mock(EntityManager.class),
mock(PlatformTransactionManager.class));
PlatformPrincipal principal = svc.login("alice", "alice123");
assertThat(principal.userId()).isNotBlank();
assertThat(principal.email()).isEqualTo("alice@example.com");
}
@Test
void explicitBind_attachesLdapIdentity_thenLdapLoginResolvesToBoundAccount() throws Exception {
// Self-service binding: a local account proves ownership of the LDAP identity with the
// directory password, and subsequent LDAP logins resolve to that account.
UserAccount local = new UserAccount("usr_grace_bind", "Grace Local", "grace@example.com", null);
userAccountRepository.save(local);
ldapBindingAppService.bindLdapIdentity(local.getId(), "grace", "grace123");
assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("grace")))
.as("binding is persisted for the LDAP subject")
.isPresent()
.get()
.extracting(IdentityBinding::getUserId)
.isEqualTo(local.getId());
PlatformPrincipal principal = localAuthService.login("grace", "grace123");
assertThat(principal.userId()).isEqualTo(local.getId());
assertThat(principal.displayName()).isEqualTo("Grace Smith");
}
private static String directoryEntryUuid(String uid) throws Exception {
ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost",
"-b", "uid=" + uid + "," + BASE_DN, "-s", "base",
"-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "entryUUID");
assertThat(r.getExitCode())
.as("ldapsearch failed: %s", r.getStdout() + r.getStderr())
.isZero();
return r.getStdout().lines()
.filter(line -> line.startsWith("entryUUID:"))
.map(line -> line.substring("entryUUID:".length()).trim())
.findFirst()
.orElseThrow(() -> new AssertionError("entryUUID not returned by ldapsearch"));
}
}

View file

@ -0,0 +1,126 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.ldap.LdapAuthService;
import com.iflytek.skillhub.auth.ldap.LdapAuthService.LdapIdentity;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
class LdapBindingAppServiceTest {
private static final String CURRENT_USER = "usr_current";
private LdapAuthService ldapAuthService;
private IdentityBindingRepository identityBindingRepository;
private UserAccountRepository userAccountRepository;
private LdapBindingAppService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ldapAuthService = mock(LdapAuthService.class);
ObjectProvider<LdapAuthService> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(ldapAuthService);
identityBindingRepository = mock(IdentityBindingRepository.class);
userAccountRepository = mock(UserAccountRepository.class);
service = new LdapBindingAppService(provider, identityBindingRepository, userAccountRepository);
}
@Test
void bind_createsBindingForCurrentAccount() {
when(ldapAuthService.resolveIdentity("alice", "secret"))
.thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice"));
when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1"))
.thenReturn(Optional.empty());
when(userAccountRepository.findByEmailIgnoreCase("alice@example.com"))
.thenReturn(Optional.empty());
service.bindLdapIdentity(CURRENT_USER, "alice", "secret");
verify(identityBindingRepository).save(any(IdentityBinding.class));
}
@Test
void bind_whenSubjectBelongsToAnotherAccount_throwsConflict() {
when(ldapAuthService.resolveIdentity("alice", "secret"))
.thenReturn(new LdapIdentity("alice", "entry-uuid-1", null, "Alice"));
IdentityBinding other = new IdentityBinding("usr_other", "ldap", "entry-uuid-1", "alice");
when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1"))
.thenReturn(Optional.of(other));
assertThatThrownBy(() -> service.bindLdapIdentity(CURRENT_USER, "alice", "secret"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> {
AuthFlowException ex = (AuthFlowException) e;
assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT);
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.bindingTaken");
});
verify(identityBindingRepository, never()).save(any());
}
@Test
void bind_whenSubjectAlreadyBoundToCurrentAccount_isIdempotent() {
when(ldapAuthService.resolveIdentity("alice", "secret"))
.thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice"));
IdentityBinding own = new IdentityBinding(CURRENT_USER, "ldap", "entry-uuid-1", "alice");
when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1"))
.thenReturn(Optional.of(own));
when(userAccountRepository.findByEmailIgnoreCase("alice@example.com"))
.thenReturn(Optional.of(new UserAccount(CURRENT_USER, "Alice", "alice@example.com", null)));
service.bindLdapIdentity(CURRENT_USER, "alice", "secret");
verify(identityBindingRepository, never()).save(any());
}
@Test
void bind_whenEmailBelongsToAnotherAccount_throwsConflict() {
when(ldapAuthService.resolveIdentity("alice", "secret"))
.thenReturn(new LdapIdentity("alice", "entry-uuid-1", "alice@example.com", "Alice"));
when(identityBindingRepository.findByProviderCodeAndSubject("ldap", "entry-uuid-1"))
.thenReturn(Optional.empty());
when(userAccountRepository.findByEmailIgnoreCase("alice@example.com"))
.thenReturn(Optional.of(new UserAccount("usr_other", "Other", "alice@example.com", null)));
assertThatThrownBy(() -> service.bindLdapIdentity(CURRENT_USER, "alice", "secret"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> {
AuthFlowException ex = (AuthFlowException) e;
assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT);
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict");
});
verify(identityBindingRepository, never()).save(any());
}
@Test
void bind_whenLdapDisabled_throwsServiceUnavailable() {
ObjectProvider<LdapAuthService> emptyProvider = mock(ObjectProvider.class);
when(emptyProvider.getIfAvailable()).thenReturn(null);
LdapBindingAppService disabledService =
new LdapBindingAppService(emptyProvider, identityBindingRepository, userAccountRepository);
assertThatThrownBy(() -> disabledService.bindLdapIdentity(CURRENT_USER, "alice", "secret"))
.isInstanceOf(AuthFlowException.class)
.satisfies(e -> {
AuthFlowException ex = (AuthFlowException) e;
assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.disabled");
});
}
}

View file

@ -0,0 +1,4 @@
dn: uid=dave,dc=example,dc=org
changetype: modify
replace: displayName
displayName: Dave D. Miller

View file

@ -0,0 +1,81 @@
dn: uid=alice,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: alice
cn: Alice Smith
sn: Smith
displayName: Alice Smith
mail: alice@example.com
userPassword: alice123
dn: uid=bob,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: bob
cn: Bob Jones
sn: Jones
userPassword: bob123
dn: uid=carol,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: carol
cn: Carol King
sn: King
displayName: Carol King
mail: carol@example.com
userPassword: carol123
dn: uid=dave,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: dave
cn: Dave Miller
sn: Miller
displayName: Dave Miller
mail: dave@example.com
userPassword: dave123
dn: uid=eve,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: eve
cn: Eve Adams
sn: Adams
displayName: Eve Adams
mail: shared@example.com
userPassword: eve123
dn: uid=frank,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: frank
cn: Frank Brown
sn: Brown
displayName: Frank Brown
mail: shared@example.com
userPassword: frank123
dn: uid=grace,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: grace
cn: Grace Smith
sn: Smith
displayName: Grace Smith
mail: grace@example.com
userPassword: grace123

View file

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

View file

@ -0,0 +1,46 @@
package com.iflytek.skillhub.auth.config;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.ldap.core.support.LdapContextSource;
/**
* LDAP connection configuration, created only when {@code skillhub.ldap.enabled=true}.
* <p>
* The context source is the single place that maps {@link LdapProperties} onto JNDI
* connection settings (URL, base, bind credentials, connect/read timeouts). A custom
* trust store for LDAPS is installed JVM-wide before the context starts by
* {@link LdapTrustStoreEnvironmentPostProcessor} (the JDK LDAP provider has no per-context
* trust-store injection point).
*/
@Configuration
@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true")
public class LdapAutoConfiguration {
@Bean
public LdapContextSource ldapContextSource(LdapProperties ldapProperties) {
LdapContextSource contextSource = new LdapContextSource();
contextSource.setUrl(ldapProperties.getUrl());
// The search base is applied explicitly by LdapAuthService (user-search-base + base), so
// the context source must stay root-relative; otherwise the base would be applied twice
// and every search would fail.
if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) {
contextSource.setUserDn(ldapProperties.getUsername());
contextSource.setPassword(ldapProperties.getPassword());
}
contextSource.setPooled(false);
Map<String, Object> baseEnvironment = new HashMap<>();
baseEnvironment.put("com.sun.jndi.ldap.connect.timeout",
String.valueOf(ldapProperties.getConnectTimeoutMillis()));
baseEnvironment.put("com.sun.jndi.ldap.read.timeout",
String.valueOf(ldapProperties.getReadTimeoutMillis()));
contextSource.setBaseEnvironmentProperties(baseEnvironment);
contextSource.afterPropertiesSet();
return contextSource;
}
}

View file

@ -0,0 +1,246 @@
package com.iflytek.skillhub.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
/**
* Configuration properties for LDAP authentication.
*/
@Component
@ConfigurationProperties(prefix = "skillhub.ldap")
public class LdapProperties {
/**
* Whether LDAP authentication is enabled.
*/
private boolean enabled = false;
/**
* LDAP server URL (e.g., ldap://localhost:389).
*/
private String url;
/**
* Base DN for LDAP searches (e.g., dc=example,dc=com).
*/
private String base;
/**
* DN of the user to bind for LDAP searches.
*/
private String username;
/**
* Password for the LDAP bind user.
*/
private String password;
/**
* LDAP attribute to use for username lookup (e.g., uid, sAMAccountName).
*/
private String userSearchAttribute = "uid";
/**
* Search base for user lookup (relative to base).
*/
private String userSearchBase = "";
/**
* Stable directory identifier attribute used as the LDAP identity subject.
* OpenLDAP uses "entryUUID", Active Directory uses "objectGUID".
*/
private String subjectAttribute = "entryUUID";
/**
* LDAP attribute mapped to the local display name.
*/
private String displayNameAttribute = "displayName";
/**
* Fallback LDAP attribute for the display name when the primary
* {@link #displayNameAttribute} is absent or empty. Defaults to {@code cn}
* (common name), the conventional fallback for directories that do not
* populate a dedicated display name.
*/
private String displayNameFallbackAttribute = "cn";
/**
* LDAP attribute mapped to the local email.
*/
private String emailAttribute = "mail";
/**
* LDAP connection timeout in milliseconds.
*/
private int connectTimeoutMillis = 5000;
/**
* LDAP read timeout in milliseconds.
*/
private int readTimeoutMillis = 10000;
/**
* Path to a custom trust store used for LDAPS certificate validation. When empty,
* the JVM default trust store is used. Configure this for directories signed by
* internal/self-signed CAs.
*/
private String tlsTrustStorePath = "";
/**
* Password for the custom trust store. Only used when {@link #tlsTrustStorePath} is set.
*/
private String tlsTrustStorePassword = "";
/**
* Trust store type (JKS, PKCS12). Defaults to JKS for compatibility.
*/
private String tlsTrustStoreType = "JKS";
@PostConstruct
void validate() {
if (!enabled) {
return;
}
if (url == null || url.isBlank()) {
throw new IllegalStateException("skillhub.ldap.url must be configured when LDAP is enabled");
}
if (base == null || base.isBlank()) {
throw new IllegalStateException("skillhub.ldap.base must be configured when LDAP is enabled");
}
if (connectTimeoutMillis <= 0) {
throw new IllegalStateException("skillhub.ldap.connect-timeout-millis must be a positive number");
}
if (readTimeoutMillis <= 0) {
throw new IllegalStateException("skillhub.ldap.read-timeout-millis must be a positive number");
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getBase() {
return base;
}
public void setBase(String base) {
this.base = base;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getUserSearchAttribute() {
return userSearchAttribute;
}
public void setUserSearchAttribute(String userSearchAttribute) {
this.userSearchAttribute = userSearchAttribute;
}
public String getUserSearchBase() {
return userSearchBase;
}
public void setUserSearchBase(String userSearchBase) {
this.userSearchBase = userSearchBase;
}
public String getSubjectAttribute() {
return subjectAttribute;
}
public void setSubjectAttribute(String subjectAttribute) {
this.subjectAttribute = subjectAttribute;
}
public String getDisplayNameAttribute() {
return displayNameAttribute;
}
public void setDisplayNameAttribute(String displayNameAttribute) {
this.displayNameAttribute = displayNameAttribute;
}
public String getDisplayNameFallbackAttribute() {
return displayNameFallbackAttribute;
}
public void setDisplayNameFallbackAttribute(String displayNameFallbackAttribute) {
this.displayNameFallbackAttribute = displayNameFallbackAttribute;
}
public String getEmailAttribute() {
return emailAttribute;
}
public void setEmailAttribute(String emailAttribute) {
this.emailAttribute = emailAttribute;
}
public int getConnectTimeoutMillis() {
return connectTimeoutMillis;
}
public void setConnectTimeoutMillis(int connectTimeoutMillis) {
this.connectTimeoutMillis = connectTimeoutMillis;
}
public int getReadTimeoutMillis() {
return readTimeoutMillis;
}
public void setReadTimeoutMillis(int readTimeoutMillis) {
this.readTimeoutMillis = readTimeoutMillis;
}
public String getTlsTrustStorePath() {
return tlsTrustStorePath;
}
public void setTlsTrustStorePath(String tlsTrustStorePath) {
this.tlsTrustStorePath = tlsTrustStorePath;
}
public String getTlsTrustStorePassword() {
return tlsTrustStorePassword;
}
public void setTlsTrustStorePassword(String tlsTrustStorePassword) {
this.tlsTrustStorePassword = tlsTrustStorePassword;
}
public String getTlsTrustStoreType() {
return tlsTrustStoreType;
}
public void setTlsTrustStoreType(String tlsTrustStoreType) {
this.tlsTrustStoreType = tlsTrustStoreType;
}
}

View file

@ -0,0 +1,28 @@
package com.iflytek.skillhub.auth.config;
import com.iflytek.skillhub.auth.ldap.LdapTrustStoreInstaller;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
/**
* Installs the custom LDAPS trust store before the Spring context (and therefore any TLS
* connection) is created. Runs only when {@code skillhub.ldap.enabled=true} and
* {@code skillhub.ldap.tls-trust-store} is set.
*/
public class LdapTrustStoreEnvironmentPostProcessor implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (!Boolean.parseBoolean(environment.getProperty("skillhub.ldap.enabled", "false"))) {
return;
}
String path = environment.getProperty("skillhub.ldap.tls-trust-store", "");
if (path == null || path.isBlank()) {
return;
}
String password = environment.getProperty("skillhub.ldap.tls-trust-store-password", "");
String type = environment.getProperty("skillhub.ldap.tls-trust-store-type", "JKS");
LdapTrustStoreInstaller.install(path, password, type);
}
}

View file

@ -0,0 +1,780 @@
package com.iflytek.skillhub.auth.ldap;
import com.iflytek.skillhub.auth.config.LdapProperties;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.security.cert.CertPathBuilderException;
import java.security.cert.CertPathValidatorException;
import java.security.cert.CertificateException;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import jakarta.persistence.EntityManager;
import javax.net.ssl.SSLException;
import javax.naming.AuthenticationException;
import javax.naming.CommunicationException;
import javax.naming.NamingException;
import java.util.regex.Pattern;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.LdapName;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.TransactionDefinition;
/**
* Handles LDAP authentication for enterprise directory integration.
* <p>
* Identity is anchored on a stable directory identifier (entryUUID/objectGUID) via
* {@link IdentityBinding} (provider="ldap"), not on the user's email. This prevents
* silent account merging when an LDAP user's email collides with an existing
* local/OAuth account, and avoids duplicate accounts for email-less users.
*/
@Service
@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true")
public class LdapAuthService {
/**
* An LDAP identity resolved and verified against the directory without provisioning a local
* account. Used by the explicit bind flow to attach an LDAP identity to an existing account.
*/
public record LdapIdentity(String username, String subject, String email, String displayName) {
}
private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class);
private static final String LDAP_PROVIDER = "ldap";
// Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters.
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{3,64}$");
// LDAP attribute names only allow ASCII letters, digits, and hyphens.
private static final Pattern ATTRIBUTE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$");
/**
* Signals that a concurrent first login wrote the same LDAP subject binding first. The
* provisioning (sub-)transaction has already been rolled back cleanly; callers must
* re-resolve the identity by subject in a fresh transaction.
*/
private static final class LdapBindingRaceException extends RuntimeException {
LdapBindingRaceException(Throwable cause) {
super(cause);
}
}
private final LdapProperties ldapProperties;
private final LdapContextSource ldapContextSource;
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
private final IdentityBindingRepository identityBindingRepository;
private final EntityManager entityManager;
/**
* Programmatic REQUIRES_NEW template for account/binding provisioning. A separate physical
* transaction is required so a failed concurrent insert (which marks its transaction
* rollback-only) can be rolled back cleanly and the identity re-resolved in a fresh
* transaction. Spring's annotation-driven propagation cannot be used here because the
* provisioning methods are invoked internally (self-invocation bypasses the proxy).
*/
private final TransactionTemplate ldapProvisioningTx;
/**
* Striped monitors that serialize first-login email-collision checks across concurrent
* requests in this JVM. {@code user_account.email} intentionally has no UNIQUE constraint
* (other identity flows may share an email), so the application-level check-and-insert for
* the same email must be serialized to stop two distinct LDAP subjects from provisioning two
* accounts with the same email at the same time. A fixed stripe count keeps memory constant.
* Multi-instance deployments need an equivalent cross-node lock (database advisory lock or a
* unique index with the other flows migrated) on top of this.
*/
private final Object[] emailLockStripes = new Object[64];
public LdapAuthService(LdapProperties ldapProperties,
LdapContextSource ldapContextSource,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
GlobalNamespaceMembershipService globalNamespaceMembershipService,
IdentityBindingRepository identityBindingRepository,
EntityManager entityManager,
PlatformTransactionManager transactionManager) {
this.ldapProperties = ldapProperties;
this.ldapContextSource = ldapContextSource;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
this.identityBindingRepository = identityBindingRepository;
this.entityManager = entityManager;
this.ldapProvisioningTx = new TransactionTemplate(transactionManager);
this.ldapProvisioningTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
for (int i = 0; i < emailLockStripes.length; i++) {
emailLockStripes[i] = new Object();
}
}
/**
* Authenticates a user against the LDAP server.
* If the user doesn't exist in the local database, creates a new user based on LDAP attributes.
*
* @param username the username
* @param password the password
* @return PlatformPrincipal if authentication succeeds
* @throws AuthFlowException if authentication fails
*/
public PlatformPrincipal login(String username, String password) {
log.debug("Starting LDAP authentication for username: {}", username);
if (!ldapProperties.isEnabled()) {
log.warn("LDAP authentication is not enabled");
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled");
}
// Validate all LDAP attribute names that flow into JNDI calls to prevent filter/attribute
// injection via operator misconfiguration. These names are operator-controlled, not user input.
validateAttributeNames();
log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}",
safeLogHost(ldapProperties.getUrl()),
ldapProperties.getBase(),
ldapProperties.getUserSearchBase(),
ldapProperties.getUserSearchAttribute());
// First, try to find the user in LDAP and authenticate
Attributes userAttributes = authenticateAndFetch(username, password);
// Find or create local user account anchored on the stable LDAP subject. Account and
// binding creation run in their own (sub-)transaction; a concurrent first login for the
// same subject is recovered by re-resolving the identity in a fresh transaction. When the
// directory entry carries an email, the check-and-insert of that email is additionally
// serialized per email (striped monitor) so two different subjects cannot both pass the
// collision check and provision duplicate accounts simultaneously.
log.debug("Finding or creating local user account for username: {}", username);
String email = getAttributeValue(userAttributes, ldapProperties.getEmailAttribute());
UserAccount user = (email == null || email.isEmpty())
? provisionUser(username, userAttributes)
: provisionUserSerializedByEmail(username, userAttributes, email);
// Check if user can login (status check)
log.debug("Checking user status for user: {}, status: {}", username, user.getStatus());
ensureUserCanLogin(user);
log.debug("LDAP authentication successful for username: {}", username);
return buildPrincipal(user);
}
/**
* Resolves and verifies an LDAP identity (search, bind, attribute read) without provisioning
* a local account or identity binding. Serves the explicit account-binding flow: the caller
* has already authenticated (or is being authenticated) and uses the LDAP credentials to
* prove ownership of the directory identity.
*/
public LdapIdentity resolveIdentity(String username, String password) {
if (!ldapProperties.isEnabled()) {
log.warn("LDAP authentication is not enabled");
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled");
}
validateAttributeNames();
Attributes userAttributes = authenticateAndFetch(username, password);
String subject = getAttributeValue(userAttributes, ldapProperties.getSubjectAttribute());
if (subject == null || subject.isEmpty()) {
log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity",
username, ldapProperties.getSubjectAttribute());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration");
}
return new LdapIdentity(
username,
subject,
getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()),
resolveDisplayName(userAttributes, username)
);
}
/**
* Finds the user entry, verifies the password via a directory bind, and fetches the entry
* attributes. All errors are classified with the same semantics for login and binding.
*/
private Attributes authenticateAndFetch(String username, String password) {
String userDn = findUserDn(username);
log.debug("LDAP findUserDn result for {}: {}", username, userDn != null);
if (userDn == null) {
log.warn("User {} not found in LDAP directory", username);
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.userNotFound");
}
log.debug("Attempting LDAP bind for user DN: {}", userDn);
boolean authenticated = authenticateLdap(userDn, password);
log.debug("LDAP bind result for {}: {}", username, authenticated);
if (!authenticated) {
log.warn("LDAP authentication failed for username: {}", username);
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials");
}
log.debug("Fetching user attributes from LDAP for DN: {}", userDn);
Attributes userAttributes = getUserAttributes(userDn);
if (userAttributes == null) {
log.error("Failed to fetch user attributes from LDAP for DN: {}", userDn);
// Bind already succeeded, so the credentials are valid. This is a transient directory
// failure; surface a 503 with the directoryUnavailable message instead of masking it
// as a 401 "invalid credentials" (which would mislead the user about the password).
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable");
}
return userAttributes;
}
/**
* Provisions the local account and identity binding in a REQUIRES_NEW sub-transaction,
* recovering from a concurrent same-subject first login by re-resolving the existing account.
*/
private UserAccount provisionUser(String username, Attributes userAttributes) {
try {
return ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes));
} catch (LdapBindingRaceException e) {
log.warn("Concurrent first login detected for LDAP subject of username {}; resolving existing account", username);
return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username));
}
}
/**
* Serializes the check-and-insert of a non-empty LDAP email so concurrent first logins from
* different subjects sharing one email cannot both provision an account. The monitor is held
* until the provisioning sub-transaction commits, so the second caller observes the first
* account and receives the regular 409 email-conflict result.
*/
private UserAccount provisionUserSerializedByEmail(String username, Attributes userAttributes, String email) {
Object stripe = emailLockStripes[Math.floorMod(email.toLowerCase(Locale.ROOT).hashCode(), emailLockStripes.length)];
synchronized (stripe) {
return provisionUser(username, userAttributes);
}
}
/**
* Ensures the user account status allows login.
*/
private void ensureUserCanLogin(UserAccount user) {
if (user.getStatus() == UserStatus.DISABLED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled");
}
if (user.getStatus() == UserStatus.PENDING) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending");
}
if (user.getStatus() == UserStatus.MERGED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged");
}
}
/**
* Finds the DN (Distinguished Name) of a user in LDAP.
*/
private String findUserDn(String username) {
// LDAP injection prevention: validate username before search
if (!isValidUsername(username)) {
log.warn("Invalid username format for LDAP search: {}", username);
return null;
}
String searchAttr = ldapProperties.getUserSearchAttribute();
DirContext ctx = null;
javax.naming.NamingEnumeration<SearchResult> results = null;
try {
ctx = createLdapContext();
String searchFilter = "(" + searchAttr + "={0})";
String searchBase = ldapProperties.getUserSearchBase().isEmpty()
? ldapProperties.getBase()
: ldapProperties.getUserSearchBase() + "," + ldapProperties.getBase();
SearchControls searchControls = new SearchControls();
searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
searchControls.setReturningAttributes(new String[0]);
results = ctx.search(searchBase, searchFilter, new Object[]{username}, searchControls);
if (results.hasMore()) {
SearchResult result = results.next();
return result.getNameInNamespace();
}
return null;
} catch (CommunicationException e) {
if (isTlsFailure(e)) {
log.warn("LDAP TLS/certificate failure while searching for user {}: {}", username, e.getMessage());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError");
}
log.warn("LDAP directory unavailable while searching for user {}: {}", username, e.getMessage());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable");
} catch (AuthenticationException e) {
log.warn("LDAP bind authentication failed while searching for user {}: {}", username, e.getMessage());
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials");
} catch (NamingException e) {
log.warn("LDAP naming error while searching for user {}: {}", username, e.getMessage());
return null;
} finally {
// Close NamingEnumeration to prevent resource leaks
if (results != null) {
try {
results.close();
} catch (Exception e) {
log.warn("Failed to close LDAP search results", e);
}
}
closeContext(ctx);
}
}
/**
* Creates an LDAP context for searching.
*/
private DirContext createLdapContext() throws NamingException {
// Bind-account context for directory searches/reads; delegates to the shared factory.
return createLdapContext(null, null);
}
/**
* Creates an LDAP context authenticated with the given principal/credentials. When both are
{@code null}, falls back to the configured bind account (or anonymous if none is set). This is
the single place that obtains contexts, so connection/timeout/TLS settings configured on the
shared {@link LdapContextSource} stay consistent across search, bind, and attribute-read
operations.
*/
private DirContext createLdapContext(String principal, String credentials) throws NamingException {
try {
// Explicit principal/credentials take precedence; otherwise use the configured bind account.
String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername();
String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword();
if (bindPrincipal != null && !bindPrincipal.isEmpty()) {
return ldapContextSource.getContext(bindPrincipal, bindCredentials);
}
// No bind account configured: anonymous read context for directory searches/reads.
return ldapContextSource.getReadOnlyContext();
} catch (org.springframework.ldap.CommunicationException e) {
// Spring LDAP wraps JNDI failures into unchecked org.springframework.ldap.* exceptions;
// translate them back so the callers' javax.naming.* classification stays unchanged.
throw (javax.naming.CommunicationException) new javax.naming.CommunicationException(e.getMessage())
.initCause(e);
} catch (org.springframework.ldap.AuthenticationException e) {
throw (javax.naming.AuthenticationException) new javax.naming.AuthenticationException(e.getMessage())
.initCause(e);
} catch (org.springframework.ldap.NameNotFoundException e) {
throw (javax.naming.NameNotFoundException) new javax.naming.NameNotFoundException(e.getMessage())
.initCause(e);
}
}
/**
* Closes an LDAP context.
*/
private void closeContext(DirContext ctx) {
if (ctx != null) {
try {
ctx.close();
} catch (NamingException e) {
// Ignore
}
}
}
/**
* Safely extracts host:port from LDAP URL for logging, avoiding credential exposure.
* Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host
*/
public static String safeLogHost(String url) {
if (url == null || url.isEmpty()) {
return "";
}
try {
String withoutProtocol = url.replaceFirst("^ldaps?://", "");
int atIndex = withoutProtocol.indexOf('@');
if (atIndex > 0) {
withoutProtocol = withoutProtocol.substring(atIndex + 1);
}
// IPv6 literal: ldap://[::1]:389 keep the bracketed address plus port
int bracketEnd = withoutProtocol.indexOf(']');
if (bracketEnd > 0) {
return withoutProtocol.substring(0, Math.min(bracketEnd + 1, withoutProtocol.length()));
}
int slashIndex = withoutProtocol.indexOf('/');
String hostPort = slashIndex > 0 ? withoutProtocol.substring(0, slashIndex) : withoutProtocol;
return hostPort;
} catch (Exception e) {
return "[url-parse-error]";
}
}
/**
* Returns whether the throwable chain indicates a TLS/trust failure (LDAPS handshake or
* certificate validation). JNDI wraps TLS failures in a {@link CommunicationException}, so
* without this check a certificate problem is indistinguishable from an unreachable directory.
*/
static boolean isTlsFailure(Throwable t) {
for (Throwable c = t; c != null; c = c.getCause()) {
if (c instanceof SSLException || c instanceof CertificateException
|| c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) {
return true;
}
}
return false;
}
/**
* Authenticates a user against the LDAP server using their DN and password.
*/
private boolean authenticateLdap(String userDn, String password) {
// Reject null/empty passwords explicitly: a null value would otherwise reach
// Hashtable.put (NPE -> 500), and an empty password could be accepted by directories
// that allow anonymous/weak binds. Treat both as credential failures (401).
if (password == null || password.isEmpty()) {
log.debug("LDAP bind rejected: empty password for DN {}", userDn);
return false;
}
DirContext ctx = null;
try {
// Bind as the authenticating user to verify credentials (shared factory handles env).
ctx = createLdapContext(userDn, password);
return true;
} catch (AuthenticationException e) {
// Invalid credentials expected, return false to signal auth failure
return false;
} catch (CommunicationException e) {
if (isTlsFailure(e)) {
log.warn("LDAP TLS/certificate failure while authenticating DN {}: {}", userDn, e.getMessage());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError");
}
log.warn("LDAP directory unavailable while authenticating DN {}: {}", userDn, e.getMessage());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable");
} catch (NamingException e) {
log.warn("LDAP naming error while authenticating DN {}: {}", userDn, e.getMessage());
return false;
} finally {
closeContext(ctx);
}
}
/**
* Retrieves user attributes from LDAP.
*/
private Attributes getUserAttributes(String userDn) {
DirContext ctx = null;
try {
// Read attributes via the bind-account context (shared factory handles env/timeout).
ctx = createLdapContext();
Attributes attrs;
try {
// Request user attributes ("*") and operational attributes ("+") so stable
// directory identifiers (OpenLDAP entryUUID, AD objectGUID) are included in the
// response. Without the explicit request, operational attributes are omitted and
// the subject key would be null on every login.
attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"});
} catch (NamingException e) {
// Some directories reject the "*"/"+" attribute-request syntax; fall back to the
// default attribute set for protocol/request-level failures only. Connection and
// authentication failures must not trigger a retry the outer catch blocks
// classify them as TLS error vs directory-unavailable vs bind failure.
if (e instanceof CommunicationException || e instanceof AuthenticationException) {
throw e;
}
attrs = ctx.getAttributes(new LdapName(userDn));
}
return attrs;
} catch (CommunicationException e) {
if (isTlsFailure(e)) {
log.warn("LDAP TLS/certificate failure while fetching attributes for DN {}: {}", userDn, e.getMessage());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.tlsError");
}
log.warn("LDAP directory unavailable while fetching attributes for DN {}: {}", userDn, e.getMessage());
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable");
} catch (Exception e) {
log.warn("Failed to fetch user attributes from LDAP for DN {}: {}", userDn, e.getMessage());
return null;
} finally {
closeContext(ctx);
}
}
/**
* Finds an existing LDAP user or creates a new one based on LDAP attributes.
* <p>
* Identity is anchored on the stable LDAP subject attribute (entryUUID/objectGUID)
* via {@link IdentityBinding}, not on the user's email. This prevents:
* <ul>
* <li>Silent account merging when an LDAP email collides with a local/OAuth account</li>
* <li>Duplicate accounts for email-less LDAP users on repeated logins</li>
* </ul>
*/
UserAccount findOrCreateLdapUser(String username, Attributes attributes) {
String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute());
String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute());
String displayName = resolveDisplayName(attributes, username);
if (subject == null || subject.isEmpty()) {
log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity",
username, ldapProperties.getSubjectAttribute());
// Bind already succeeded. The directory entry lacks the configured subject attribute,
// which is a configuration/schema issue the user cannot fix. Surface a 503 with the
// invalidConfiguration message instead of a 401 that would look like a wrong password.
// Operators can locate the cause via the log line above.
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration");
}
// Anchor on the stable LDAP subject: an existing binding means this identity is already known.
IdentityBinding binding = identityBindingRepository
.findByProviderCodeAndSubject(LDAP_PROVIDER, subject)
.orElse(null);
if (binding != null) {
// Returning user refresh attributes from the directory on each login.
var existing = userAccountRepository.findById(binding.getUserId());
if (existing.isPresent()) {
UserAccount user = existing.get();
updateFromAttributes(user, displayName, email);
if (!username.equals(binding.getLoginName())) {
binding.setLoginName(username);
identityBindingRepository.save(binding);
}
return userAccountRepository.save(user);
}
// The bound account no longer exists (e.g. deleted by an administrator). The stale
// binding would otherwise block this subject forever with a 500. Remove it and fall
// through to the first-login provisioning path, which creates a fresh account for
// the directory identity.
log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding",
subject, binding.getUserId());
identityBindingRepository.delete(binding);
}
// First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth
// account that happens to share the same email that would be a privilege escalation.
if (email != null && !email.isEmpty()) {
String normalizedEmail = email.toLowerCase();
UserAccount existingByEmail = userAccountRepository
.findByEmailIgnoreCase(normalizedEmail).orElse(null);
if (existingByEmail != null) {
// The email already belongs to another account. Refuse to silently create a second
// account (two distinct LDAP subjects sharing one email would both map to it, and
// user_account.email has no UNIQUE constraint, so this would otherwise happen
// silently). This covers both cross-provider collisions and the same-issuer case
// (a different LDAP subject under the same email). If an entry's stable subject
// legitimately changes (e.g. after an AD objectGUID migration), an administrator
// must remove the stale binding before the new subject can log in.
log.warn("LDAP user {} email {} collides with an existing account {} (subject differs); refusing to create a duplicate account",
username, normalizedEmail, existingByEmail.getId());
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict");
}
}
// Create a new user account. The placeholder email is only used to satisfy the NOT NULL
// constraint and never serves as an identity key.
String normalizedEmail = email != null && !email.isEmpty()
? email.toLowerCase()
: LDAP_PROVIDER + ":" + username + "@internal";
UserAccount user = new UserAccount(
"usr_" + UUID.randomUUID(),
displayName,
normalizedEmail,
null
);
user.setStatus(UserStatus.ACTIVE);
user = userAccountRepository.save(user);
globalNamespaceMembershipService.ensureMember(user.getId());
try {
IdentityBinding newBinding = new IdentityBinding(user.getId(), LDAP_PROVIDER, subject, username);
// saveAndFlush surfaces the unique (provider_code, subject) constraint immediately, so
// a concurrent first login for the same subject is detected here and the whole
// sub-transaction (account + membership + binding) is rolled back.
identityBindingRepository.saveAndFlush(newBinding);
} catch (DataIntegrityViolationException e) {
// A concurrent first login for the same subject committed its binding first. Clear the
// failed persist state (otherwise Hibernate throws AssertionFailure while rolling back
// the session), then signal the race so the identity is re-resolved in a fresh
// transaction. The insert failure already marked this sub-transaction rollback-only,
// so it can never be committed with the re-resolved state.
entityManager.clear();
throw new LdapBindingRaceException(e);
}
return user;
}
/**
* Re-resolves a returning LDAP user in a fresh transaction after a concurrent first-login
* race. Called only when the racing transaction has committed its binding, so the lookup is
* guaranteed to hit the existing account.
*/
UserAccount resolveReturningUser(Attributes attributes, String username) {
String subject = getAttributeValue(attributes, ldapProperties.getSubjectAttribute());
String email = getAttributeValue(attributes, ldapProperties.getEmailAttribute());
String displayName = resolveDisplayName(attributes, username);
IdentityBinding binding = identityBindingRepository
.findByProviderCodeAndSubject(LDAP_PROVIDER, subject)
.orElse(null);
if (binding == null) {
// The racing transaction rolled back after all (rare), or the binding was cleaned up
// concurrently. Fall back to the regular provisioning path.
throw new LdapBindingRaceException(new IllegalStateException("No binding found after race for " + subject));
}
var existing = userAccountRepository.findById(binding.getUserId());
if (existing.isPresent()) {
UserAccount user = existing.get();
updateFromAttributes(user, displayName, email);
if (!username.equals(binding.getLoginName())) {
binding.setLoginName(username);
identityBindingRepository.save(binding);
}
return userAccountRepository.save(user);
}
// Stale binding for a deleted account: remove it and retry provisioning from scratch.
log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding",
subject, binding.getUserId());
identityBindingRepository.delete(binding);
throw new LdapBindingRaceException(new IllegalStateException("Stale binding removed for " + subject));
}
private String resolveDisplayName(Attributes attributes, String username) {
String displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameAttribute());
if (displayName == null || displayName.isEmpty()) {
displayName = getAttributeValue(attributes, ldapProperties.getDisplayNameFallbackAttribute());
}
if (displayName == null || displayName.isEmpty()) {
displayName = username;
}
return displayName;
}
private void updateFromAttributes(UserAccount user, String displayName, String email) {
if (displayName != null && !displayName.isEmpty()) {
user.setDisplayName(displayName);
}
if (email != null && !email.isEmpty()) {
String normalizedEmail = email.toLowerCase(Locale.ROOT);
// A bound user may update their own email, but must never silently adopt an email
// that already belongs to a different account (same rule as first login).
if (user.getEmail() == null || !user.getEmail().equalsIgnoreCase(normalizedEmail)) {
UserAccount existingByEmail = userAccountRepository
.findByEmailIgnoreCase(normalizedEmail).orElse(null);
if (existingByEmail != null && !existingByEmail.getId().equals(user.getId())) {
log.warn("LDAP user {} email {} collides with another account {} on refresh; refusing to update",
user.getDisplayName(), normalizedEmail, existingByEmail.getId());
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict");
}
}
user.setEmail(normalizedEmail);
}
}
/**
* Gets a string attribute value from LDAP attributes.
*/
private String getAttributeValue(Attributes attributes, String attrName) {
try {
Attribute attr = attributes.get(attrName);
if (attr != null && attr.get() != null) {
Object value = attr.get();
// Active Directory stores stable identifiers such as objectGUID / objectSid as
// binary (OctetString). JNDI returns these as byte[], whose toString() yields an
// unstable "[B@<identityHashCode>" making every login look like a new identity.
// Convert binary values to a stable hexadecimal representation (mixed-endian GUID
// layout for 16-byte values) so the subject key remains stable across logins.
if (value instanceof byte[] bytes) {
return toStableGuidString(bytes);
}
return value.toString();
}
} catch (Exception e) {
// Ignore and return null
}
return null;
}
/**
* Converts a binary attribute value into a stable string suitable for use as an identity
* subject. Active Directory objectGUID is a 16-byte mixed-endian GUID; rearranging it into
* the canonical 8-4-4-4-12 hex layout yields the same string .NET/AD display, which is stable
* across JVM restarts and connections. Non-16-byte binaries fall back to plain hex so the
* value is still deterministic.
*/
private static String toStableGuidString(byte[] bytes) {
if (bytes.length == 16) {
// AD objectGUID layout: little-endian uint32, little-endian uint16 x2, big-endian rest.
return String.format("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
bytes[3] & 0xff, bytes[2] & 0xff, bytes[1] & 0xff, bytes[0] & 0xff,
bytes[5] & 0xff, bytes[4] & 0xff,
bytes[7] & 0xff, bytes[6] & 0xff,
bytes[8] & 0xff, bytes[9] & 0xff,
bytes[10] & 0xff, bytes[11] & 0xff, bytes[12] & 0xff,
bytes[13] & 0xff, bytes[14] & 0xff, bytes[15] & 0xff);
}
// Non-GUID binary attribute: deterministic plain hex so the value stays stable.
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
}
/**
* Builds a PlatformPrincipal from a UserAccount.
*/
private PlatformPrincipal buildPrincipal(UserAccount user) {
Set<String> roles = userRoleBindingRepository.findByUserId(user.getId()).stream()
.map(binding -> binding.getRole().getCode())
.collect(Collectors.toSet());
roles = PlatformRoleDefaults.withDefaultUserRole(roles);
return new PlatformPrincipal(
user.getId(),
user.getDisplayName(),
user.getEmail(),
user.getAvatarUrl(),
"ldap",
roles
);
}
/**
* Validates username to prevent LDAP injection attacks.
* Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats),
* 3-64 characters.
*/
private boolean isValidUsername(String username) {
if (username == null || username.isEmpty()) {
return false;
}
return USERNAME_PATTERN.matcher(username).matches();
}
/**
* Validates all operator-configured LDAP attribute names that flow into JNDI calls.
* Rejects names that are null or fail the LDAP attribute-name pattern, preventing
* filter/attribute injection via misconfiguration before any directory call is made.
*/
private void validateAttributeNames() {
String[] attrNames = {
ldapProperties.getUserSearchAttribute(),
ldapProperties.getSubjectAttribute(),
ldapProperties.getDisplayNameAttribute(),
ldapProperties.getDisplayNameFallbackAttribute(),
ldapProperties.getEmailAttribute()
};
for (String name : attrNames) {
if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) {
log.error("Invalid LDAP attribute name configured: {}", name);
throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.invalidConfiguration");
}
}
}
}

View file

@ -0,0 +1,95 @@
package com.iflytek.skillhub.auth.ldap;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.util.Enumeration;
import java.util.UUID;
/**
* Installs a custom trust store for LDAPS by merging it with the JVM's default trust store and
* pointing the {@code javax.net.ssl.trustStore*} system properties at the merged store.
* <p>
* This is the only reliable injection point for LDAPS trust in a JNDI-based client: the JDK
* LDAP provider builds its sockets from the JVM-wide SSL configuration and ignores both
* {@code javax.net.ssl.trustStore*} context-environment entries and the
* {@code java.naming.ldap.factory.socket} property (verified against the JDK 21 implementation),
* and Spring LDAP 3.x no longer offers a per-context socket factory. Because the JSSE default
* SSLContext is cached on first use, the installer must run before any TLS connection is made;
* it is invoked from an {@code EnvironmentPostProcessor} during application startup.
* <p>
* Merging (rather than replacing) keeps public-CA connectivity intact: the resulting store
* contains the JVM defaults plus the configured internal CA.
*/
public final class LdapTrustStoreInstaller {
private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit";
private LdapTrustStoreInstaller() {
}
/**
* Merges the configured custom trust store into the JVM default trust store and installs the
* result through the {@code javax.net.ssl.trustStore*} system properties.
*
* @param customPath the custom trust store path
* @param customPassword the custom trust store password (may be empty)
* @param customType the custom trust store type (JKS, PKCS12, ...)
*/
public static void install(String customPath, String customPassword, String customType) {
try {
KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType());
merged.load(null, null);
copyCertificateEntries(loadDefaultTrustStore(), merged);
copyCertificateEntries(loadCustomTrustStore(customPath, customPassword, customType), merged);
String password = "skillhub-" + UUID.randomUUID();
Path file = Files.createTempFile("skillhub-truststore", ".p12");
try (OutputStream out = Files.newOutputStream(file)) {
merged.store(out, password.toCharArray());
}
System.setProperty("javax.net.ssl.trustStore", file.toString());
System.setProperty("javax.net.ssl.trustStorePassword", password);
System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType());
} catch (Exception e) {
throw new IllegalStateException(
"Failed to install LDAPS trust store from " + customPath, e);
}
}
private static KeyStore loadDefaultTrustStore() throws Exception {
String systemPath = System.getProperty("javax.net.ssl.trustStore");
String systemPassword = System.getProperty("javax.net.ssl.trustStorePassword",
DEFAULT_TRUSTSTORE_PASSWORD);
String systemType = System.getProperty("javax.net.ssl.trustStoreType",
KeyStore.getDefaultType());
Path path = systemPath != null && !systemPath.isEmpty()
? Path.of(systemPath)
: Path.of(System.getProperty("java.home"), "lib", "security", "cacerts");
KeyStore keyStore = KeyStore.getInstance(systemType);
try (InputStream in = Files.newInputStream(path)) {
keyStore.load(in, systemPassword.toCharArray());
}
return keyStore;
}
private static KeyStore loadCustomTrustStore(String path, String password, String type) throws Exception {
KeyStore keyStore = KeyStore.getInstance(type);
try (InputStream in = Files.newInputStream(Path.of(path))) {
keyStore.load(in, password.toCharArray());
}
return keyStore;
}
private static void copyCertificateEntries(KeyStore source, KeyStore target) throws Exception {
Enumeration<String> aliases = source.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (source.isCertificateEntry(alias)) {
target.setCertificateEntry(alias, source.getCertificate(alias));
}
}
}
}

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.config.LdapProperties;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.ldap.LdapAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
@ -16,9 +18,14 @@ import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
/**
@ -28,6 +35,8 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class LocalAuthService {
private static final Logger log = LoggerFactory.getLogger(LocalAuthService.class);
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$");
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
private static final int MAX_FAILED_ATTEMPTS = 5;
@ -44,6 +53,15 @@ public class LocalAuthService {
private final PasswordPolicyValidator passwordPolicyValidator;
private final PasswordEncoder passwordEncoder;
private final Clock clock;
private final LdapProperties ldapProperties;
private final ObjectProvider<LdapAuthService> ldapAuthServiceProvider;
/**
* Transaction boundary for the local-credential login path. The {@code login} method itself
* is intentionally NOT transactional: the LDAP fallback performs directory network calls
* (up to connect+read timeout) and must not hold a database connection/transaction open
* while blocked on the directory. Only the local credential database work is wrapped.
*/
private final TransactionTemplate transactionTemplate;
public LocalAuthService(LocalCredentialRepository credentialRepository,
UserAccountRepository userAccountRepository,
@ -51,7 +69,10 @@ public class LocalAuthService {
GlobalNamespaceMembershipService globalNamespaceMembershipService,
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder,
Clock clock) {
Clock clock,
LdapProperties ldapProperties,
ObjectProvider<LdapAuthService> ldapAuthServiceProvider,
PlatformTransactionManager transactionManager) {
this.credentialRepository = credentialRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
@ -59,6 +80,9 @@ public class LocalAuthService {
this.passwordPolicyValidator = passwordPolicyValidator;
this.passwordEncoder = passwordEncoder;
this.clock = clock;
this.ldapProperties = ldapProperties;
this.ldapAuthServiceProvider = ldapAuthServiceProvider;
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
/**
@ -107,33 +131,72 @@ public class LocalAuthService {
/**
* Authenticates a local account and returns the principal snapshot used to
* establish a web session.
* If the user is not found locally, falls back to LDAP authentication if enabled.
*/
@Transactional
public PlatformPrincipal login(String username, String password) {
String normalizedUsername = normalizeUsername(username);
LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
.orElse(null);
LocalCredential credential = transactionTemplate.execute(status ->
credentialRepository.findByUsernameIgnoreCase(normalizedUsername).orElse(null));
if (credential == null) {
// Blur timing to prevent username enumeration
passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH);
// Fallback to LDAP authentication if enabled
if (ldapProperties.isEnabled()) {
LdapAuthService ldapAuthService = ldapAuthServiceProvider.getIfAvailable();
if (ldapAuthService == null) {
log.warn("LDAP is enabled but LdapAuthService bean is unavailable; rejecting login for username: {}", username);
throw invalidCredentials();
}
log.debug("Local user not found, attempting LDAP authentication for username: {}", username);
log.debug("LDAP enabled: {}, host: {}, base: {}",
ldapProperties.isEnabled(),
LdapAuthService.safeLogHost(ldapProperties.getUrl()),
ldapProperties.getBase());
try {
PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password);
log.debug("LDAP authentication successful for username: {}", username);
return ldapPrincipal;
} catch (AuthFlowException e) {
log.warn("LDAP authentication failed for username: {}, error: {}", username, e.getMessage());
// Propagate account-state, availability, and email-conflict errors instead of
// masking them as invalid credentials, so the frontend can show the right message.
// A 409 emailConflict is only reached after a successful LDAP bind, so the
// credentials are valid; masking it as 401 would mislead the user into thinking
// their password is wrong. Only genuine credential failures (userNotFound /
// invalidCredentials) fall back to the generic response to avoid enumeration.
HttpStatus status = e.getStatus();
if (status == HttpStatus.FORBIDDEN || status == HttpStatus.SERVICE_UNAVAILABLE
|| status == HttpStatus.CONFLICT) {
throw e;
}
throw invalidCredentials();
}
} else {
log.debug("LDAP authentication is disabled, rejecting login for username: {}", username);
}
throw invalidCredentials();
}
UserAccount user = userAccountRepository.findById(credential.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for local credential"));
return transactionTemplate.execute(status -> {
UserAccount user = userAccountRepository.findById(credential.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for local credential"));
ensureUserCanLogin(user);
ensureNotLocked(credential);
ensureUserCanLogin(user);
ensureNotLocked(credential);
if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
throw invalidCredentials();
}
if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
throw invalidCredentials();
}
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
return buildPrincipal(user);
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
return buildPrincipal(user);
});
}
/**

View file

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

View file

@ -0,0 +1,41 @@
package com.iflytek.skillhub.auth.config;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
/**
* Startup configuration validation for the LDAP properties block.
*/
class LdapPropertiesTest {
@Test
void validate_requiresUrlAndBase_whenEnabled() {
LdapProperties props = new LdapProperties();
props.setEnabled(true);
assertThatThrownBy(props::validate)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("skillhub.ldap.url");
}
@Test
void validate_rejectsNonPositiveTimeouts_whenEnabled() {
LdapProperties props = new LdapProperties();
props.setEnabled(true);
props.setUrl("ldap://localhost:389");
props.setBase("dc=example,dc=org");
props.setConnectTimeoutMillis(0);
assertThatThrownBy(props::validate)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("connect-timeout-millis");
}
@Test
void validate_skipsChecks_whenDisabled() {
LdapProperties props = new LdapProperties();
assertThatCode(props::validate).doesNotThrowAnyException();
}
}

View file

@ -0,0 +1,358 @@
package com.iflytek.skillhub.auth.ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.auth.config.LdapProperties;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import java.lang.reflect.Method;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import javax.naming.directory.Attributes;
import javax.naming.directory.BasicAttribute;
import javax.naming.directory.BasicAttributes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.transaction.PlatformTransactionManager;
/**
* Behavior-level unit tests for {@link LdapAuthService} identity provisioning.
*
* <p>These tests exercise the {@code findOrCreateLdapUser} / {@code ensureUserCanLogin} logic via
* reflection, with all repositories mocked, so they cover the security-critical behavior called out
* in the PR review (email-collision takeover, duplicate provisioning on repeat login, attribute
* synchronization, and disabled-account rejection) without requiring a live LDAP directory.
*/
class LdapAuthServiceTest {
private static final String SUBJECT = "entry-uuid-123";
private static final String EMAIL = "alice@example.com";
private static final String DISPLAY_NAME = "Alice";
private LdapProperties ldapProperties;
private UserAccountRepository userAccountRepository;
private UserRoleBindingRepository userRoleBindingRepository;
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
private IdentityBindingRepository identityBindingRepository;
private EntityManager entityManager;
private PlatformTransactionManager transactionManager;
private LdapContextSource ldapContextSource;
private LdapAuthService ldapAuthService;
@BeforeEach
void setUp() {
ldapProperties = new LdapProperties();
userAccountRepository = mock(UserAccountRepository.class);
userRoleBindingRepository = mock(UserRoleBindingRepository.class);
globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class);
identityBindingRepository = mock(IdentityBindingRepository.class);
entityManager = mock(EntityManager.class);
transactionManager = mock(PlatformTransactionManager.class);
ldapContextSource = mock(LdapContextSource.class);
ldapAuthService = new LdapAuthService(
ldapProperties,
ldapContextSource,
userAccountRepository,
userRoleBindingRepository,
globalNamespaceMembershipService,
identityBindingRepository,
entityManager,
transactionManager);
}
/** Directory attributes: subject=entryUUID, email=mail, displayName=displayName. */
private static Attributes directoryAttributes(String subject, String email, String displayName) {
BasicAttributes attrs = new BasicAttributes();
attrs.put(new BasicAttribute("entryUUID", subject));
attrs.put(new BasicAttribute("mail", email));
attrs.put(new BasicAttribute("displayName", displayName));
return attrs;
}
private UserAccount invokeFindOrCreate(String username, Attributes attrs) throws Exception {
Method m = LdapAuthService.class.getDeclaredMethod("findOrCreateLdapUser", String.class, Attributes.class);
m.setAccessible(true);
return (UserAccount) m.invoke(ldapAuthService, username, attrs);
}
@Test
void firstLogin_provisionsNewAccountAndBindsSubject() throws Exception {
// Given no existing binding and no email collision
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.empty());
given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty());
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
// When
UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME));
// Then new active account bound to the LDAP subject; placeholder never used as identity key
assertThat(created.getStatus()).isEqualTo(UserStatus.ACTIVE);
assertThat(created.getDisplayName()).isEqualTo(DISPLAY_NAME);
assertThat(created.getEmail()).isEqualTo(EMAIL);
verify(globalNamespaceMembershipService).ensureMember(created.getId());
verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class));
}
@Test
void repeatLogin_hitsExistingBindingBySubject_noDuplicateAccount() throws Exception {
// Given the LDAP subject is already bound to an account (prior login)
String existingUserId = "usr_existing";
UserAccount existing = new UserAccount(existingUserId, "Old Name", EMAIL, null);
existing.setStatus(UserStatus.ACTIVE);
IdentityBinding binding = new IdentityBinding(existingUserId, "ldap", SUBJECT, "alice");
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.of(binding));
given(userAccountRepository.findById(existingUserId)).willReturn(Optional.of(existing));
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
// When same subject logs in again
UserAccount result = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME));
// Then returns the same account, never provisions a new one
assertThat(result.getId()).isEqualTo(existingUserId);
verify(userAccountRepository, never()).save(org.mockito.ArgumentMatchers.argThat(
u -> !existingUserId.equals(u.getId())));
// Critical: no new binding written on repeat login
verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class));
}
@Test
void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception {
// Given the binding points to an account that no longer exists
IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice");
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.of(stale));
given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty());
given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty());
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
// When the same subject logs in again
UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME));
// Then the stale binding is removed and a fresh account is provisioned
assertThat(created.getEmail()).isEqualTo(EMAIL);
assertThat(created.getId()).isNotEqualTo("usr_deleted");
verify(identityBindingRepository).delete(stale);
verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class));
}
@Test
void repeatLogin_refreshesAttributesFromDirectory() throws Exception {
// Given a returning user whose display name and email changed in the directory
String userId = "usr_alice";
UserAccount existing = new UserAccount(userId, "Old Name", "old@example.com", null);
existing.setStatus(UserStatus.ACTIVE);
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice")));
given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing));
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
// When directory now reports a new display name and email
UserAccount result = invokeFindOrCreate("alice",
directoryAttributes(SUBJECT, "new@example.com", "New Name"));
// Then attributes are refreshed on this login (not only at first creation)
assertThat(result.getDisplayName()).isEqualTo("New Name");
assertThat(result.getEmail()).isEqualTo("new@example.com");
}
@Test
void emailCollision_refusesSilentInheritance_throwsConflict() throws Exception {
// Given a different identity provider already owns this email
String otherUserId = "usr_oauth";
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.empty()); // no LDAP binding yet
given(userAccountRepository.findByEmailIgnoreCase(EMAIL))
.willReturn(Optional.of(new UserAccount(otherUserId, "OAuth User", EMAIL, null)));
// When must NOT silently inherit the OAuth account / its roles.
// Reflection wraps checked exceptions in InvocationTargetException, so unwrap and assert
// the inner AuthFlowException carries a 409 CONFLICT with the emailConflict message key.
AuthFlowException thrown = null;
try {
invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME));
} catch (java.lang.reflect.InvocationTargetException ite) {
thrown = (AuthFlowException) ite.getCause();
}
assertThat(thrown).isNotNull();
assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT);
assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict");
// No account created, no binding written
verify(userAccountRepository, never()).save(any(UserAccount.class));
verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class));
}
@Test
void noEmail_usesPlaceholderAccount_doesNotCollideAcrossLogins() throws Exception {
// Given directory entry has no mail attribute; subject is the only stable key
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.empty());
// No email -> no email-collision lookup happens; placeholder email is generated
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
BasicAttributes attrs = new BasicAttributes();
attrs.put(new BasicAttribute("entryUUID", SUBJECT));
attrs.put(new BasicAttribute("displayName", DISPLAY_NAME));
// When
UserAccount created = invokeFindOrCreate("bob", attrs);
// Then placeholder email follows the ldap:{username}@internal convention
assertThat(created.getEmail()).isEqualTo("ldap:bob@internal");
verify(userAccountRepository, never()).findByEmailIgnoreCase(any());
verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class));
}
@Test
void missingSubjectAttribute_throwsServiceUnavailable() {
// Given bind succeeded but the entry lacks the configured subject attribute
BasicAttributes attrs = new BasicAttributes();
attrs.put(new BasicAttribute("mail", EMAIL));
// When & Then a 503 (not a 401) so the user is not misled into thinking the password is wrong
assertThatThrownBy(() -> invokeFindOrCreate("alice", attrs))
.hasCauseInstanceOf(AuthFlowException.class);
try {
invokeFindOrCreate("alice", attrs);
} catch (java.lang.reflect.InvocationTargetException ite) {
AuthFlowException cause = (AuthFlowException) ite.getCause();
assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration");
} catch (Throwable t) {
throw new AssertionError(t);
}
}
@Test
void ensureUserCanLogin_rejectsDisabledAccount() throws Exception {
// The disabled-account path must surface a FORBIDDEN (propagated, not masked as 401)
UserAccount disabled = new UserAccount("usr_x", "X", "x@example.com", null);
disabled.setStatus(UserStatus.DISABLED);
Method m = LdapAuthService.class.getDeclaredMethod("ensureUserCanLogin", UserAccount.class);
m.setAccessible(true);
try {
m.invoke(ldapAuthService, disabled);
} catch (java.lang.reflect.InvocationTargetException ite) {
AuthFlowException cause = (AuthFlowException) ite.getCause();
assertThat(cause.getStatus()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(cause.getMessageCode()).isEqualTo("error.auth.local.accountDisabled");
}
}
@Test
void displayNameFallsBackToCn_whenDisplayNameAttributeAbsent() throws Exception {
// Given directory has no displayName but has cn; configured fallback defaults to "cn"
assertThat(ldapProperties.getDisplayNameFallbackAttribute()).isEqualTo("cn");
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.empty());
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
BasicAttributes attrs = new BasicAttributes();
attrs.put(new BasicAttribute("entryUUID", SUBJECT));
attrs.put(new BasicAttribute("cn", "Common Name"));
// When
UserAccount created = invokeFindOrCreate("carol", attrs);
// Then display name falls back to the configured cn attribute
assertThat(created.getDisplayName()).isEqualTo("Common Name");
}
@Test
void returningUser_emailCollision_refusesSilentUpdate_throwsConflict() throws Exception {
// Given the LDAP subject is bound, but the directory now reports an email that already
// belongs to a different account. The refresh must refuse to adopt it (409), matching the
// first-login email-collision rule.
String userId = "usr_alice";
UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null);
existing.setStatus(UserStatus.ACTIVE);
UserAccount other = new UserAccount("usr_other", "Other User", "other@example.com", null);
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice")));
given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing));
given(userAccountRepository.findByEmailIgnoreCase("other@example.com")).willReturn(Optional.of(other));
AuthFlowException thrown = null;
try {
invokeFindOrCreate("alice", directoryAttributes(SUBJECT, "other@example.com", "Alice"));
} catch (java.lang.reflect.InvocationTargetException ite) {
thrown = (AuthFlowException) ite.getCause();
}
assertThat(thrown).isNotNull();
assertThat(thrown.getStatus()).isEqualTo(HttpStatus.CONFLICT);
assertThat(thrown.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict");
// The user's own email must remain untouched.
assertThat(existing.getEmail()).isEqualTo("alice@example.com");
}
@Test
void returningUser_sameEmail_isAllowedToRefresh() throws Exception {
// Given the directory reports the same email the bound account already owns; the
// collision lookup must exclude the user's own account.
String userId = "usr_alice";
UserAccount existing = new UserAccount(userId, "Alice", "alice@example.com", null);
existing.setStatus(UserStatus.ACTIVE);
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
.willReturn(Optional.of(new IdentityBinding(userId, "ldap", SUBJECT, "alice")));
given(userAccountRepository.findById(userId)).willReturn(Optional.of(existing));
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
UserAccount result = invokeFindOrCreate("alice",
directoryAttributes(SUBJECT, "alice@example.com", "Alice Smith"));
assertThat(result.getId()).isEqualTo(userId);
assertThat(result.getEmail()).isEqualTo("alice@example.com");
}
@Test
void isTlsFailure_detectsSslHandshakeInCauseChain() {
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
comm.initCause(new javax.net.ssl.SSLHandshakeException("PKIX path building failed"));
assertThat(LdapAuthService.isTlsFailure(comm)).isTrue();
}
@Test
void isTlsFailure_detectsDeepCertificateException() {
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
comm.initCause(new java.io.IOException("TLS handshake failed",
new java.security.cert.CertificateException("not trusted")));
assertThat(LdapAuthService.isTlsFailure(comm)).isTrue();
}
@Test
void isTlsFailure_detectsCertPathValidatorWithoutSslException() {
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate"));
assertThat(LdapAuthService.isTlsFailure(comm)).isTrue();
}
@Test
void isTlsFailure_ignoresPlainConnectionFailures() {
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
comm.initCause(new java.io.IOException("Connection refused"));
assertThat(LdapAuthService.isTlsFailure(comm)).isFalse();
}
}

View file

@ -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.
*
* <p>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@<identityHashCode>"} 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));
}
}

View file

@ -0,0 +1,99 @@
package com.iflytek.skillhub.auth.ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.util.Enumeration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class LdapTrustStoreInstallerTest {
@TempDir
Path tempDir;
private String previousTrustStore;
private String previousPassword;
private String previousType;
@AfterEach
void restoreSystemProperties() {
restore("javax.net.ssl.trustStore", previousTrustStore);
restore("javax.net.ssl.trustStorePassword", previousPassword);
restore("javax.net.ssl.trustStoreType", previousType);
}
@Test
void install_mergesCustomTrustStoreIntoDefaults() throws Exception {
previousTrustStore = System.getProperty("javax.net.ssl.trustStore");
previousPassword = System.getProperty("javax.net.ssl.trustStorePassword");
previousType = System.getProperty("javax.net.ssl.trustStoreType");
// Build a custom trust store containing one certificate copied from the JVM defaults.
KeyStore defaults = defaultTrustStore();
String sourceAlias = firstCertificateAlias(defaults);
Path custom = tempDir.resolve("custom.p12");
KeyStore customStore = KeyStore.getInstance("PKCS12");
customStore.load(null, null);
customStore.setCertificateEntry("custom-ca", defaults.getCertificate(sourceAlias));
try (OutputStream out = Files.newOutputStream(custom)) {
customStore.store(out, "changeit".toCharArray());
}
LdapTrustStoreInstaller.install(custom.toString(), "changeit", "PKCS12");
String installedPath = System.getProperty("javax.net.ssl.trustStore");
assertThat(installedPath).isNotBlank();
KeyStore installed = KeyStore.getInstance(System.getProperty("javax.net.ssl.trustStoreType"));
try (InputStream in = Files.newInputStream(Path.of(installedPath))) {
installed.load(in, System.getProperty("javax.net.ssl.trustStorePassword").toCharArray());
}
assertThat(installed.containsAlias("custom-ca")).as("custom CA is merged").isTrue();
assertThat(installed.containsAlias(sourceAlias)).as("default certificates are preserved").isTrue();
}
@Test
void install_withMissingFile_failsFast() {
assertThatThrownBy(() -> LdapTrustStoreInstaller.install(
tempDir.resolve("missing.p12").toString(), "changeit", "PKCS12"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Failed to install LDAPS trust store");
}
private static KeyStore defaultTrustStore() throws Exception {
String systemPath = System.getProperty("javax.net.ssl.trustStore");
Path path = systemPath != null && !systemPath.isEmpty()
? Path.of(systemPath)
: Path.of(System.getProperty("java.home"), "lib", "security", "cacerts");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
try (InputStream in = Files.newInputStream(path)) {
keyStore.load(in, "changeit".toCharArray());
}
return keyStore;
}
private static String firstCertificateAlias(KeyStore keyStore) throws Exception {
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (keyStore.isCertificateEntry(alias)) {
return alias;
}
}
throw new IllegalStateException("default trust store has no certificate entries");
}
private static void restore(String property, String value) {
if (value == null) {
System.clearProperty(property);
} else {
System.setProperty(property, value);
}
}
}

View file

@ -9,9 +9,12 @@ 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.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.ldap.LdapAuthService;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
@ -22,6 +25,7 @@ import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -51,10 +55,25 @@ class LocalAuthServiceTest {
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private LdapProperties ldapProperties;
@Mock
private LdapAuthService ldapAuthService;
@Mock
private org.springframework.beans.factory.ObjectProvider<LdapAuthService> ldapAuthServiceProvider;
@Mock
private org.springframework.transaction.PlatformTransactionManager transactionManager;
private LocalAuthService service;
@BeforeEach
void setUp() {
org.mockito.Mockito.lenient().when(ldapAuthServiceProvider.getIfAvailable()).thenReturn(ldapAuthService);
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(org.mockito.ArgumentMatchers.any()))
.thenReturn(org.mockito.Mockito.mock(org.springframework.transaction.TransactionStatus.class));
service = new LocalAuthService(
credentialRepository,
userAccountRepository,
@ -62,7 +81,10 @@ class LocalAuthServiceTest {
globalNamespaceMembershipService,
new PasswordPolicyValidator(),
passwordEncoder,
CLOCK
CLOCK,
ldapProperties,
ldapAuthServiceProvider,
transactionManager
);
}
@ -161,6 +183,7 @@ class LocalAuthServiceTest {
@Test
void login_withUnknownUsername_stillPerformsDummyPasswordCheck() {
given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(false);
given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu")))
.willReturn(false);
@ -261,4 +284,116 @@ class LocalAuthServiceTest {
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("validation.auth.local.email.notBlank");
}
@Test
void login_withUnknownUsername_fallsBackToLdap_whenEnabled() {
// Given
given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(true);
UserAccount ldapUser = new UserAccount("usr_ldap", "ldapuser", "ldapuser@example.com", null);
ldapUser.setStatus(UserStatus.ACTIVE);
PlatformPrincipal ldapPrincipal = new PlatformPrincipal(
"usr_ldap",
"ldapuser",
"ldapuser@example.com",
null,
"ldap",
Set.of("USER")
);
given(ldapAuthService.login("ldapuser", "LdapPassword123!")).willReturn(ldapPrincipal);
// When
var principal = service.login("ldapuser", "LdapPassword123!");
// Then
assertThat(principal.userId()).isEqualTo("usr_ldap");
assertThat(principal.displayName()).isEqualTo("ldapuser");
assertThat(principal.email()).isEqualTo("ldapuser@example.com");
verify(ldapAuthService).login("ldapuser", "LdapPassword123!");
}
@Test
void login_withUnknownUsername_fails_whenLdapAuthenticationFails() {
// Given
given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(true);
given(ldapAuthService.login("ldapuser", "WrongPassword"))
.willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "LDAP authentication failed"));
// When & Then
assertThatThrownBy(() -> service.login("ldapuser", "WrongPassword"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);
verify(ldapAuthService).login("ldapuser", "WrongPassword");
}
@Test
void login_withUnknownUsername_propagatesServiceUnavailable_whenLdapDirectoryDown() {
// Given LDAP directory unavailable should surface as 503, not be masked as 401
given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(true);
given(ldapAuthService.login("ldapuser", "password"))
.willThrow(new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable"));
// When & Then 503 must propagate so the frontend can show "try again later"
assertThatThrownBy(() -> service.login("ldapuser", "password"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
}
@Test
void login_withUnknownUsername_propagatesForbidden_whenLdapAccountDisabled() {
// Given a disabled LDAP account (403) should propagate, not be masked as 401
given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(true);
given(ldapAuthService.login("ldapuser", "password"))
.willThrow(new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.ldap.disabled"));
// When & Then 403 must propagate so the frontend can show "account disabled"
assertThatThrownBy(() -> service.login("ldapuser", "password"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void login_withUnknownUsername_propagatesEmailConflict_whenEmailCollidesWithExistingAccount() {
// Given an email-conflict (409) is only raised after a successful LDAP bind, so the
// credentials are valid; it must propagate so the frontend can guide the user to an
// explicit account-link flow rather than reporting a misleading "wrong password".
given(credentialRepository.findByUsernameIgnoreCase("ldapuser")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(true);
given(ldapAuthService.login("ldapuser", "password"))
.willThrow(new AuthFlowException(HttpStatus.CONFLICT, "error.auth.ldap.emailConflict"));
// When & Then 409 is propagated, not masked as 401
assertThatThrownBy(() -> service.login("ldapuser", "password"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.CONFLICT);
}
@Test
void login_withUnknownUsername_fails_whenLdapDisabled() {
// Given
given(credentialRepository.findByUsernameIgnoreCase("localuser")).willReturn(Optional.empty());
given(ldapProperties.isEnabled()).willReturn(false);
// When & Then
assertThatThrownBy(() -> service.login("localuser", "password"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);
verify(ldapAuthService, never()).login(any(), any());
}
}

View file

@ -14,6 +14,7 @@ import type {
MergeInitiateRequest,
MergeInitiateResponse,
MergeVerifyRequest,
LdapBindRequest,
ReviewSkillDetail,
ReviewTask,
PromotionSortBy,
@ -463,6 +464,18 @@ export const accountApi = {
},
}
export const ldapApi = {
async bindIdentity(request: LdapBindRequest): Promise<void> {
await fetchJson<void>('/api/v1/auth/ldap/bind', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
}
export const skillLifecycleApi = {
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace

View file

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

View file

@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query'
import { ldapApi } from '@/api/client'
import type { LdapBindRequest } from '@/api/types'
/**
* Binds the LDAP identity verified by the given directory credentials to the currently
* authenticated account.
*/
export function useLdapBind() {
return useMutation({
mutationFn: (request: LdapBindRequest) => ldapApi.bindIdentity(request),
})
}

View file

@ -794,7 +794,15 @@
"confirming": "Confirming...",
"confirm": "Confirm & Complete Merge",
"confirmSuccess": "Account merge completed",
"confirmError": "Merge confirmation failed"
"confirmError": "Merge confirmation failed",
"ldapBindTitle": "Bind LDAP Account",
"ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.",
"ldapUsername": "LDAP Username",
"ldapPassword": "LDAP Password",
"ldapBinding": "Binding...",
"ldapBind": "Bind",
"ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP",
"ldapBindError": "LDAP binding failed"
},
"namespace": {
"notFound": "Namespace not found",

View file

@ -794,7 +794,15 @@
"confirming": "确认中...",
"confirm": "确认并完成合并",
"confirmSuccess": "账号合并已完成",
"confirmError": "确认合并失败"
"confirmError": "确认合并失败",
"ldapBindTitle": "绑定 LDAP 账号",
"ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。",
"ldapUsername": "LDAP 用户名",
"ldapPassword": "LDAP 密码",
"ldapBinding": "绑定中...",
"ldapBind": "绑定",
"ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录",
"ldapBindError": "LDAP 绑定失败"
},
"namespace": {
"notFound": "命名空间不存在",

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge'
import { useLdapBind } from '@/features/auth/use-ldap-bind'
import { truncateErrorMessage } from '@/shared/lib/error-display'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
@ -13,6 +14,9 @@ import { Input } from '@/shared/ui/input'
*/
export function AccountSettingsPage() {
const { t } = useTranslation()
const [ldapUsername, setLdapUsername] = useState('')
const [ldapPassword, setLdapPassword] = useState('')
const [ldapStatusMessage, setLdapStatusMessage] = useState('')
const [secondaryIdentifier, setSecondaryIdentifier] = useState('')
const [mergeRequestId, setMergeRequestId] = useState('')
const [verificationToken, setVerificationToken] = useState('')
@ -21,6 +25,25 @@ export function AccountSettingsPage() {
const initiateMutation = useInitiateAccountMerge()
const verifyMutation = useVerifyAccountMerge()
const confirmMutation = useConfirmAccountMerge()
const ldapBindMutation = useLdapBind()
/**
* Binds the LDAP identity proven by the given directory credentials to the current account.
*/
async function handleLdapBind(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setLdapStatusMessage('')
try {
await ldapBindMutation.mutateAsync({ username: ldapUsername, password: ldapPassword })
setLdapUsername('')
setLdapPassword('')
setLdapStatusMessage(t('accounts.ldapBindSuccess'))
} catch (error) {
setLdapStatusMessage(
truncateErrorMessage(error instanceof Error ? error.message : t('accounts.ldapBindError')) ?? t('accounts.ldapBindError'),
)
}
}
/**
* Starts the merge flow and surfaces the request id plus verification token
@ -77,6 +100,40 @@ export function AccountSettingsPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('accounts.ldapBindTitle')}</CardTitle>
<CardDescription>{t('accounts.ldapBindDesc')}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleLdapBind}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ldap-username">{t('accounts.ldapUsername')}</label>
<Input
id="ldap-username"
autoComplete="username"
value={ldapUsername}
onChange={(event) => setLdapUsername(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ldap-password">{t('accounts.ldapPassword')}</label>
<Input
id="ldap-password"
type="password"
autoComplete="current-password"
value={ldapPassword}
onChange={(event) => setLdapPassword(event.target.value)}
/>
</div>
<Button type="submit" disabled={ldapBindMutation.isPending}>
{ldapBindMutation.isPending ? t('accounts.ldapBinding') : t('accounts.ldapBind')}
</Button>
</form>
{ldapStatusMessage ? <p className="mt-4 text-sm text-muted-foreground">{ldapStatusMessage}</p> : null}
</CardContent>
</Card>
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('accounts.initiateTitle')}</CardTitle>

View file

@ -57,6 +57,30 @@ describe('handleApiError', () => {
expect(errorSpy).toHaveBeenLastCalledWith('Server said no')
})
it('shows the server message for 403 errors when present', async () => {
const { ApiError, handleApiError } = await import('./api-error')
handleApiError(new ApiError('apiError.forbidden', 403, '账号已被禁用,请联系管理员'))
expect(errorSpy).toHaveBeenLastCalledWith('账号已被禁用,请联系管理员')
})
it('shows the server message for 5xx errors when present', async () => {
const { ApiError, handleApiError } = await import('./api-error')
handleApiError(new ApiError('apiError.serverError', 503, '目录服务器暂时不可用,请稍后重试'))
expect(errorSpy).toHaveBeenLastCalledWith('目录服务器暂时不可用,请稍后重试')
})
it('falls back to generic text for 403/5xx without a server message', async () => {
const { ApiError, handleApiError } = await import('./api-error')
handleApiError(new ApiError('apiError.forbidden', 403))
expect(errorSpy).toHaveBeenLastCalledWith(i18n.t('apiError.forbidden'))
})
it('shows network error message when status is 0 (network disconnected)', async () => {
const { ApiError, handleApiError } = await import('./api-error')

View file

@ -72,7 +72,9 @@ export function handleApiError(error: unknown): void {
}
if (status === 403) {
toast.error(i18n.t('apiError.forbidden'))
// The backend may attach a specific business message (e.g. a disabled account or an
// LDAP conflict); prefer it over the generic forbidden text.
toast.error(error.serverMessage || i18n.t('apiError.forbidden'))
return
}
@ -82,7 +84,9 @@ export function handleApiError(error: unknown): void {
}
if (status >= 500) {
toast.error(i18n.t('apiError.serverError'))
// The backend returns localized operational messages for service-level failures such as
// "directory unavailable" or "TLS misconfiguration"; surface them when present.
toast.error(error.serverMessage || i18n.t('apiError.serverError'))
return
}