mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge 89099c70d0 into 26f49e6819
This commit is contained in:
commit
585052f542
37 changed files with 3244 additions and 21 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -55,6 +55,7 @@ target/
|
|||
coverage/
|
||||
dist/
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
*.tsbuildinfo
|
||||
package-lock.json
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=不支持的请求内容类型
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
dn: uid=dave,dc=example,dc=org
|
||||
changetype: modify
|
||||
replace: displayName
|
||||
displayName: Dave D. Miller
|
||||
81
server/skillhub-app/src/test/resources/ldap/seed-users.ldif
Normal file
81
server/skillhub-app/src/test/resources/ldap/seed-users.ldif
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ export interface MergeInitiateRequest {
|
|||
secondaryIdentifier: string
|
||||
}
|
||||
|
||||
export interface LdapBindRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MergeInitiateResponse {
|
||||
mergeRequestId: number
|
||||
secondaryUserId: string
|
||||
|
|
|
|||
13
web/src/features/auth/use-ldap-bind.ts
Normal file
13
web/src/features/auth/use-ldap-bind.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "命名空间不存在",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue