mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
fix(auth): 完成 LDAP 绑定流程、LDAPS 信任库与并发/孤儿加固
- 显式绑定端点 POST /api/v1/auth/ldap/bind:登录用户以 LDAP 凭据证明目录身份所有权后绑定到当前账号,解决 409 邮箱冲突后无自助入口的问题;前端设置页新增 LDAP 绑定卡片 - LDAPS 自定义 truststore 真实生效:JDK 21 JNDI 不读取 javax.net.ssl.trustStore* 环境项且无 factory.socket 注入点(反编译验证),改为 EnvironmentPostProcessor 在启动早期将自定义 CA 合并进 JVM 信任库;集成测试覆盖真实 LDAPS 登录成功(证书链含 BasicConstraints + 有效期修复) - 连接层改用 Spring LDAP LdapContextSource(无 base 双重拼接、异常转换保持 JNDI 分类语义),移除自建 JNDI env 与空壳配置类 - email 并发碰撞:首登 check-and-insert 按 email 条带锁串行化,不同 subject 同 email 并发首登只建一个账号(集成测试覆盖) - 孤儿 identity_binding 自愈:绑定指向已删除账号时删除残留绑定并按首登重建,不再永久 500;loginName 随登录名变更刷新 - isTlsFailure 识别 CertPathValidatorException 链;属性配置错误使用独立消息键;'*' '+' 属性请求回退仅限协议级错误 - LdapIntegrationTest 独立 surefire fork,避免全量测试中 JSSE 默认 SSLContext 被先行缓存导致 LDAPS 用例失效 - 集成测试 ensureMember 真实化(seed global namespace),移除 MockBean Signed-off-by: jangrui <admin@jangrui.com>
This commit is contained in:
parent
70dcc9d5f6
commit
34e01171a6
26 changed files with 1148 additions and 120 deletions
|
|
@ -116,6 +116,33 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**/auth/ldap/LdapIntegrationTest.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>ldap-container-test</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>test</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/auth/ldap/LdapIntegrationTest.java</include>
|
||||
</includes>
|
||||
<reuseForks>false</reuseForks>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,8 @@ skillhub:
|
|||
connect-timeout-millis: ${SKILLHUB_LDAP_CONNECT_TIMEOUT_MILLIS:5000}
|
||||
read-timeout-millis: ${SKILLHUB_LDAP_READ_TIMEOUT_MILLIS:10000}
|
||||
# Custom trust store for LDAPS certificate validation (internal/self-signed CAs).
|
||||
# Leave empty to use the JVM default trust store.
|
||||
# Installed at application startup by merging into the JVM-wide trust store (defaults are
|
||||
# preserved). Leave empty to use the JVM default trust store.
|
||||
tls-trust-store: ${SKILLHUB_LDAP_TLS_TRUST_STORE:}
|
||||
tls-trust-store-password: ${SKILLHUB_LDAP_TLS_TRUST_STORE_PASSWORD:}
|
||||
tls-trust-store-type: ${SKILLHUB_LDAP_TLS_TRUST_STORE_TYPE:JKS}
|
||||
|
|
|
|||
|
|
@ -49,10 +49,11 @@ error.auth.sessionBootstrap.notAuthenticated=No authenticated external session f
|
|||
error.auth.ldap.disabled=LDAP authentication is not enabled
|
||||
error.auth.ldap.userNotFound=Invalid username or password
|
||||
error.auth.ldap.invalidCredentials=Invalid username or password
|
||||
error.auth.ldap.fetchUserFailed=Failed to retrieve user information from the directory server
|
||||
error.auth.ldap.invalidConfiguration=LDAP authentication is misconfigured. Please contact an administrator
|
||||
error.auth.ldap.directoryUnavailable=The directory server is temporarily unavailable. Please try again later
|
||||
error.auth.ldap.tlsError=Failed to establish a secure connection to the directory server. Please check the TLS certificate configuration
|
||||
error.auth.ldap.emailConflict=This email is already associated with an existing account. Please contact an administrator
|
||||
error.auth.ldap.bindingTaken=This LDAP identity is already bound to another account
|
||||
error.badRequest=Invalid request
|
||||
error.forbidden=Forbidden
|
||||
error.request.timeout=Request timed out
|
||||
|
|
|
|||
|
|
@ -49,10 +49,11 @@ error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会
|
|||
error.auth.ldap.disabled=LDAP 认证未启用
|
||||
error.auth.ldap.userNotFound=用户名或密码错误
|
||||
error.auth.ldap.invalidCredentials=用户名或密码错误
|
||||
error.auth.ldap.fetchUserFailed=从目录服务器获取用户信息失败
|
||||
error.auth.ldap.invalidConfiguration=LDAP 认证配置有误,请联系管理员处理
|
||||
error.auth.ldap.directoryUnavailable=目录服务器暂时不可用,请稍后重试
|
||||
error.auth.ldap.tlsError=无法与目录服务器建立安全连接,请检查 TLS 证书配置
|
||||
error.auth.ldap.emailConflict=该邮箱已关联已有账号,请联系管理员处理
|
||||
error.auth.ldap.bindingTaken=该 LDAP 身份已绑定到其他账号
|
||||
error.badRequest=请求参数不合法
|
||||
error.forbidden=没有权限执行该操作
|
||||
error.request.timeout=请求超时
|
||||
|
|
|
|||
|
|
@ -3,19 +3,24 @@ package com.iflytek.skillhub.auth.ldap;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
|
@ -64,9 +69,14 @@ class ConcurrentLdapFirstLoginTest {
|
|||
@Autowired
|
||||
private IdentityBindingRepository identityBindingRepository;
|
||||
|
||||
// Namespace seeding is unrelated to the concurrency behavior under test.
|
||||
@MockBean
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
@Autowired
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@BeforeEach
|
||||
void ensureGlobalNamespace() {
|
||||
namespaceRepository.findBySlug("global")
|
||||
.orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap")));
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void seedDirectory() throws Exception {
|
||||
|
|
@ -115,4 +125,42 @@ class ConcurrentLdapFirstLoginTest {
|
|||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentFirstLogin_differentSubjectsSameEmail_oneAccountAndOneConflict() throws Exception {
|
||||
// Two distinct LDAP subjects share one email and log in for the first time at the same
|
||||
// moment. The email-collision check must be serialized: exactly one provisioning succeeds
|
||||
// and the other receives 409 — never two accounts with the same email.
|
||||
long bindingsBefore = identityBindingRepository.findAll().size();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Object> eve = pool.submit(() -> (Object) localAuthService.login("eve", "eve123"));
|
||||
Future<Object> frank = pool.submit(() -> (Object) localAuthService.login("frank", "frank123"));
|
||||
|
||||
List<Object> results = List.of(unwrap(eve), unwrap(frank));
|
||||
long successes = results.stream().filter(PlatformPrincipal.class::isInstance).count();
|
||||
long conflicts = results.stream()
|
||||
.filter(t -> t instanceof AuthFlowException e && e.getStatus() == HttpStatus.CONFLICT)
|
||||
.count();
|
||||
|
||||
assertThat(successes).as("exactly one of the two first logins succeeds").isEqualTo(1);
|
||||
assertThat(conflicts).as("the other login is refused with 409").isEqualTo(1);
|
||||
assertThat(userAccountRepository.findByEmailIgnoreCase("shared@example.com"))
|
||||
.as("the successful login provisioned exactly one account for the shared email")
|
||||
.isPresent();
|
||||
assertThat(identityBindingRepository.findAll())
|
||||
.as("only the successful subject is bound (one new binding, none for the 409 loser)")
|
||||
.hasSize((int) bindingsBefore + 1);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static Object unwrap(Future<?> future) throws Exception {
|
||||
try {
|
||||
return future.get();
|
||||
} catch (ExecutionException e) {
|
||||
return e.getCause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import java.io.IOException;
|
|||
import java.net.ServerSocket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.ldap.core.support.LdapContextSource;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
|
|
@ -34,8 +35,14 @@ class LdapDirectoryUnavailableTest {
|
|||
props.setUrl("ldap://127.0.0.1:" + freePort());
|
||||
props.setBase("dc=example,dc=org");
|
||||
|
||||
LdapContextSource contextSource = new LdapContextSource();
|
||||
contextSource.setUrl(props.getUrl());
|
||||
contextSource.setPooled(false);
|
||||
contextSource.afterPropertiesSet();
|
||||
|
||||
LdapAuthService svc = new LdapAuthService(
|
||||
props,
|
||||
contextSource,
|
||||
mock(UserAccountRepository.class),
|
||||
mock(UserRoleBindingRepository.class),
|
||||
mock(GlobalNamespaceMembershipService.class),
|
||||
|
|
|
|||
202
server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java
Normal file → Executable file
202
server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java
Normal file → Executable file
|
|
@ -2,7 +2,9 @@ package com.iflytek.skillhub.auth.ldap;
|
|||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.config.LdapProperties;
|
||||
import com.iflytek.skillhub.auth.entity.IdentityBinding;
|
||||
|
|
@ -11,17 +13,32 @@ import com.iflytek.skillhub.auth.local.LocalAuthService;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.service.LdapBindingAppService;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.ldap.core.support.LdapContextSource;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
|
@ -51,12 +68,111 @@ class LdapIntegrationTest {
|
|||
private static final String BIND_DN = "cn=admin," + BASE_DN;
|
||||
private static final String BIND_PASSWORD = "admin";
|
||||
|
||||
/**
|
||||
* The image's baked-in TLS certificates expired in 2026, which makes any LDAPS success path
|
||||
* impossible. Generate a fresh CA and server certificate with the JDK's keytool before the
|
||||
* container starts, and let {@code withCopyFileToContainer} install them over the baked-in
|
||||
* files. The container's entrypoint only generates certificates when the files are absent.
|
||||
*/
|
||||
private static final Path TLS_CERTS_DIR = prepareTlsCertificates();
|
||||
|
||||
/**
|
||||
* The JDK LDAP provider resolves LDAPS trust from the JVM-wide SSL configuration and offers
|
||||
* no per-connection trust-store injection point, so the test CA must be installed through the
|
||||
* {@code javax.net.ssl.trustStore*} system properties before any JNDI connection (and thus
|
||||
* before the JSSE default SSLContext is cached). The static initializer runs at class load,
|
||||
* before the Spring context and the LDAP container are created.
|
||||
*/
|
||||
static {
|
||||
try {
|
||||
Path truststore = Files.createTempFile("ldap-truststore", ".p12");
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
ks.load(null, null);
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
try (InputStream in = Files.newInputStream(TLS_CERTS_DIR.resolve("ca.crt"))) {
|
||||
ks.setCertificateEntry("ldap-ca", cf.generateCertificate(in));
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(truststore)) {
|
||||
ks.store(out, "changeit".toCharArray());
|
||||
}
|
||||
System.setProperty("javax.net.ssl.trustStore", truststore.toString());
|
||||
System.setProperty("javax.net.ssl.trustStorePassword", "changeit");
|
||||
System.setProperty("javax.net.ssl.trustStoreType", "PKCS12");
|
||||
} catch (Exception e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Container
|
||||
static final GenericContainer<?> LDAP = new GenericContainer<>("osixia/openldap:1.5.0")
|
||||
.withEnv("LDAP_ORGANISATION", "Example Inc")
|
||||
.withEnv("LDAP_DOMAIN", "example.org")
|
||||
.withEnv("LDAP_ADMIN_PASSWORD", BIND_PASSWORD)
|
||||
.withExposedPorts(389, 636);
|
||||
// The image defaults to olcTLSVerifyClient=demand, which requires client certificates
|
||||
// during the TLS handshake. The tests exercise server-certificate validation only.
|
||||
.withEnv("LDAP_TLS_VERIFY_CLIENT", "never")
|
||||
.withExposedPorts(389, 636)
|
||||
.withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ca.crt")),
|
||||
"/container/service/slapd/assets/certs/ca.crt")
|
||||
.withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.crt")),
|
||||
"/container/service/slapd/assets/certs/ldap.crt")
|
||||
.withCopyFileToContainer(MountableFile.forHostPath(TLS_CERTS_DIR.resolve("ldap.key")),
|
||||
"/container/service/slapd/assets/certs/ldap.key");
|
||||
|
||||
private static Path prepareTlsCertificates() {
|
||||
try {
|
||||
Path dir = Files.createTempDirectory("ldap-tls-certs");
|
||||
runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "ca",
|
||||
"-dname", "CN=SkillHub Test CA", "-validity", "3650", "-keyalg", "RSA",
|
||||
"-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "ca.p12",
|
||||
"-storepass", "changeit", "-keypass", "changeit",
|
||||
"-ext", "BasicConstraints=ca:true"));
|
||||
runKeytool(dir, List.of("keytool", "-genkeypair", "-alias", "server",
|
||||
"-dname", "CN=ldap.example.org", "-validity", "3650", "-keyalg", "RSA",
|
||||
"-sigalg", "SHA256withRSA", "-storetype", "PKCS12", "-keystore", "server.p12",
|
||||
"-storepass", "changeit", "-keypass", "changeit"));
|
||||
runKeytool(dir, List.of("keytool", "-certreq", "-alias", "server",
|
||||
"-keystore", "server.p12", "-storepass", "changeit", "-file", "server.csr"));
|
||||
runKeytool(dir, List.of("keytool", "-gencert", "-alias", "ca",
|
||||
"-keystore", "ca.p12", "-storepass", "changeit", "-infile", "server.csr",
|
||||
"-rfc", "-validity", "3650",
|
||||
"-ext", "BasicConstraints=ca:false",
|
||||
"-ext", "KeyUsage=digitalSignature,keyEncipherment",
|
||||
"-ext", "ExtendedKeyUsage=serverAuth",
|
||||
"-outfile", "server.crt"));
|
||||
runKeytool(dir, List.of("keytool", "-exportcert", "-alias", "ca",
|
||||
"-keystore", "ca.p12", "-storepass", "changeit", "-rfc", "-file", "ca.crt"));
|
||||
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
try (InputStream in = Files.newInputStream(dir.resolve("server.p12"))) {
|
||||
ks.load(in, "changeit".toCharArray());
|
||||
}
|
||||
PrivateKey key = (PrivateKey) ks.getKey("server", "changeit".toCharArray());
|
||||
String pem = "-----BEGIN PRIVATE KEY-----\n"
|
||||
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(key.getEncoded())
|
||||
+ "\n-----END PRIVATE KEY-----\n";
|
||||
Files.writeString(dir.resolve("ldap.key"), pem);
|
||||
// slapd runs as the openldap user; the key must be world-readable inside the container.
|
||||
Files.setPosixFilePermissions(dir.resolve("ldap.key"),
|
||||
java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--"));
|
||||
Files.copy(dir.resolve("server.crt"), dir.resolve("ldap.crt"));
|
||||
return dir;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to prepare LDAPS test certificates", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runKeytool(Path dir, List<String> command) throws Exception {
|
||||
Process process = new ProcessBuilder(command)
|
||||
.directory(dir.toFile())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
int exit = process.waitFor();
|
||||
if (exit != 0) {
|
||||
throw new IllegalStateException(command.get(0) + " failed (" + exit + "): " + output);
|
||||
}
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void ldapProperties(DynamicPropertyRegistry registry) {
|
||||
|
|
@ -78,14 +194,26 @@ class LdapIntegrationTest {
|
|||
@Autowired
|
||||
private IdentityBindingRepository identityBindingRepository;
|
||||
|
||||
// Local accounts are provisioned through ensureMember(), which requires a built-in global
|
||||
// namespace row that the test profile does not seed. The membership side effect is unrelated
|
||||
// to the LDAP behavior under test, so it is mocked away.
|
||||
@MockBean
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
@Autowired
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@Autowired
|
||||
private LdapBindingAppService ldapBindingAppService;
|
||||
|
||||
@BeforeEach
|
||||
void ensureGlobalNamespace() {
|
||||
namespaceRepository.findBySlug("global")
|
||||
.orElseGet(() -> namespaceRepository.save(new Namespace("global", "Global", "bootstrap")));
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void seedDirectory() throws Exception {
|
||||
// The OpenLDAP container's certificate is issued for cn=ldap.example.org, while the test
|
||||
// connects through the container's mapped address. The JNDI LDAP provider performs
|
||||
// endpoint identification (hostname verification) by default, which would reject the
|
||||
// address even with a trusted CA. Disable endpoint identification for this test JVM so
|
||||
// the custom-truststore success path exercises certificate-chain validation only.
|
||||
System.setProperty("com.sun.jndi.ldap.object.disableEndpointIdentification", "true");
|
||||
LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif");
|
||||
LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/modify-dave.ldif"), "/tmp/modify-dave.ldif");
|
||||
awaitLdapReady();
|
||||
|
|
@ -96,6 +224,11 @@ class LdapIntegrationTest {
|
|||
.isZero();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreEndpointIdentification() {
|
||||
System.clearProperty("com.sun.jndi.ldap.object.disableEndpointIdentification");
|
||||
}
|
||||
|
||||
private static void awaitLdapReady() throws Exception {
|
||||
long deadline = System.currentTimeMillis() + 30_000;
|
||||
Exception last = null;
|
||||
|
|
@ -212,28 +345,63 @@ class LdapIntegrationTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void ldaps_withUntrustedCertificate_returnsTlsError() {
|
||||
void ldaps_withCustomTrustStore_authenticatesSuccessfully() throws Exception {
|
||||
// The test CA is installed JVM-wide by the static initializer (the JDK LDAP provider has
|
||||
// no per-connection trust-store injection point). This test verifies the full LDAPS chain
|
||||
// (search, bind, attribute read) succeeds with that trust store in place.
|
||||
LdapProperties props = new LdapProperties();
|
||||
props.setEnabled(true);
|
||||
props.setUrl("ldaps://" + LDAP.getHost() + ":" + LDAP.getMappedPort(636));
|
||||
props.setBase(BASE_DN);
|
||||
props.setUsername(BIND_DN);
|
||||
props.setPassword(BIND_PASSWORD);
|
||||
|
||||
UserAccountRepository userRepo = mock(UserAccountRepository.class);
|
||||
when(userRepo.findByEmailIgnoreCase(any())).thenReturn(Optional.empty());
|
||||
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
IdentityBindingRepository bindingRepo = mock(IdentityBindingRepository.class);
|
||||
when(bindingRepo.findByProviderCodeAndSubject(any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
LdapContextSource contextSource = new LdapContextSource();
|
||||
contextSource.setUrl(props.getUrl());
|
||||
contextSource.setUserDn(BIND_DN);
|
||||
contextSource.setPassword(BIND_PASSWORD);
|
||||
contextSource.setPooled(false);
|
||||
contextSource.afterPropertiesSet();
|
||||
|
||||
LdapAuthService svc = new LdapAuthService(props,
|
||||
mock(UserAccountRepository.class),
|
||||
contextSource,
|
||||
userRepo,
|
||||
mock(UserRoleBindingRepository.class),
|
||||
mock(GlobalNamespaceMembershipService.class),
|
||||
mock(IdentityBindingRepository.class),
|
||||
bindingRepo,
|
||||
mock(EntityManager.class),
|
||||
mock(PlatformTransactionManager.class));
|
||||
|
||||
assertThatThrownBy(() -> svc.login("alice", "alice123"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.satisfies(e -> {
|
||||
AuthFlowException ex = (AuthFlowException) e;
|
||||
assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.tlsError");
|
||||
});
|
||||
PlatformPrincipal principal = svc.login("alice", "alice123");
|
||||
assertThat(principal.userId()).isNotBlank();
|
||||
assertThat(principal.email()).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitBind_attachesLdapIdentity_thenLdapLoginResolvesToBoundAccount() throws Exception {
|
||||
// Self-service binding: a local account proves ownership of the LDAP identity with the
|
||||
// directory password, and subsequent LDAP logins resolve to that account.
|
||||
UserAccount local = new UserAccount("usr_grace_bind", "Grace Local", "grace@example.com", null);
|
||||
userAccountRepository.save(local);
|
||||
|
||||
ldapBindingAppService.bindLdapIdentity(local.getId(), "grace", "grace123");
|
||||
|
||||
assertThat(identityBindingRepository.findByProviderCodeAndSubject("ldap", directoryEntryUuid("grace")))
|
||||
.as("binding is persisted for the LDAP subject")
|
||||
.isPresent()
|
||||
.get()
|
||||
.extracting(IdentityBinding::getUserId)
|
||||
.isEqualTo(local.getId());
|
||||
|
||||
PlatformPrincipal principal = localAuthService.login("grace", "grace123");
|
||||
assertThat(principal.userId()).isEqualTo(local.getId());
|
||||
assertThat(principal.displayName()).isEqualTo("Grace Smith");
|
||||
}
|
||||
|
||||
private static String directoryEntryUuid(String uid) throws Exception {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -43,3 +43,39 @@ sn: Miller
|
|||
displayName: Dave Miller
|
||||
mail: dave@example.com
|
||||
userPassword: dave123
|
||||
|
||||
dn: uid=eve,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: eve
|
||||
cn: Eve Adams
|
||||
sn: Adams
|
||||
displayName: Eve Adams
|
||||
mail: shared@example.com
|
||||
userPassword: eve123
|
||||
|
||||
dn: uid=frank,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: frank
|
||||
cn: Frank Brown
|
||||
sn: Brown
|
||||
displayName: Frank Brown
|
||||
mail: shared@example.com
|
||||
userPassword: frank123
|
||||
|
||||
dn: uid=grace,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: grace
|
||||
cn: Grace Smith
|
||||
sn: Smith
|
||||
displayName: Grace Smith
|
||||
mail: grace@example.com
|
||||
userPassword: grace123
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,46 @@
|
|||
package com.iflytek.skillhub.auth.config;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.ldap.core.support.LdapContextSource;
|
||||
|
||||
/**
|
||||
* LDAP configuration marker.
|
||||
* LDAP connection configuration, created only when {@code skillhub.ldap.enabled=true}.
|
||||
* <p>
|
||||
* The actual LDAP connection handling is encapsulated inside {@code LdapAuthService}, which
|
||||
* uses JNDI {@code DirContext} directly. This avoids maintaining a parallel Spring LDAP
|
||||
* {@code LdapTemplate}/{@code LdapContextSource} bean graph whose configuration source
|
||||
* ({@code spring.ldap.*}) would diverge from the application-level {@code skillhub.ldap.*}
|
||||
* properties consumed by {@link LdapProperties}.
|
||||
* <p>
|
||||
* {@link LdapProperties} is a standalone {@code @Component} and is always available; the
|
||||
* {@code LdapAuthService} bean itself is conditionally created only when
|
||||
* {@code skillhub.ldap.enabled=true}.
|
||||
* The context source is the single place that maps {@link LdapProperties} onto JNDI
|
||||
* connection settings (URL, base, bind credentials, connect/read timeouts). A custom
|
||||
* trust store for LDAPS is installed JVM-wide before the context starts by
|
||||
* {@link LdapTrustStoreEnvironmentPostProcessor} (the JDK LDAP provider has no per-context
|
||||
* trust-store injection point).
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true")
|
||||
public class LdapAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public LdapContextSource ldapContextSource(LdapProperties ldapProperties) {
|
||||
LdapContextSource contextSource = new LdapContextSource();
|
||||
contextSource.setUrl(ldapProperties.getUrl());
|
||||
// The search base is applied explicitly by LdapAuthService (user-search-base + base), so
|
||||
// the context source must stay root-relative; otherwise the base would be applied twice
|
||||
// and every search would fail.
|
||||
if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) {
|
||||
contextSource.setUserDn(ldapProperties.getUsername());
|
||||
contextSource.setPassword(ldapProperties.getPassword());
|
||||
}
|
||||
contextSource.setPooled(false);
|
||||
|
||||
Map<String, Object> baseEnvironment = new HashMap<>();
|
||||
baseEnvironment.put("com.sun.jndi.ldap.connect.timeout",
|
||||
String.valueOf(ldapProperties.getConnectTimeoutMillis()));
|
||||
baseEnvironment.put("com.sun.jndi.ldap.read.timeout",
|
||||
String.valueOf(ldapProperties.getReadTimeoutMillis()));
|
||||
contextSource.setBaseEnvironmentProperties(baseEnvironment);
|
||||
|
||||
contextSource.afterPropertiesSet();
|
||||
return contextSource;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,9 @@ import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
|||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import java.security.cert.CertPathBuilderException;
|
||||
import java.security.cert.CertPathValidatorException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Hashtable;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
|
@ -21,13 +22,11 @@ import jakarta.persistence.EntityManager;
|
|||
import javax.net.ssl.SSLException;
|
||||
import javax.naming.AuthenticationException;
|
||||
import javax.naming.CommunicationException;
|
||||
import javax.naming.Context;
|
||||
import javax.naming.NamingException;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.Attributes;
|
||||
import javax.naming.directory.DirContext;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
import javax.naming.directory.SearchControls;
|
||||
import javax.naming.directory.SearchResult;
|
||||
import javax.naming.ldap.LdapName;
|
||||
|
|
@ -36,6 +35,7 @@ import org.slf4j.LoggerFactory;
|
|||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.ldap.core.support.LdapContextSource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
|
@ -53,6 +53,13 @@ import org.springframework.transaction.TransactionDefinition;
|
|||
@ConditionalOnProperty(prefix = "skillhub.ldap", name = "enabled", havingValue = "true")
|
||||
public class LdapAuthService {
|
||||
|
||||
/**
|
||||
* An LDAP identity resolved and verified against the directory without provisioning a local
|
||||
* account. Used by the explicit bind flow to attach an LDAP identity to an existing account.
|
||||
*/
|
||||
public record LdapIdentity(String username, String subject, String email, String displayName) {
|
||||
}
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LdapAuthService.class);
|
||||
private static final String LDAP_PROVIDER = "ldap";
|
||||
// Allows alphanumeric, underscore, hyphen, dot, and @ (for UPN formats), 3-64 characters.
|
||||
|
|
@ -72,6 +79,7 @@ public class LdapAuthService {
|
|||
}
|
||||
|
||||
private final LdapProperties ldapProperties;
|
||||
private final LdapContextSource ldapContextSource;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
|
@ -86,7 +94,19 @@ public class LdapAuthService {
|
|||
*/
|
||||
private final TransactionTemplate ldapProvisioningTx;
|
||||
|
||||
/**
|
||||
* Striped monitors that serialize first-login email-collision checks across concurrent
|
||||
* requests in this JVM. {@code user_account.email} intentionally has no UNIQUE constraint
|
||||
* (other identity flows may share an email), so the application-level check-and-insert for
|
||||
* the same email must be serialized to stop two distinct LDAP subjects from provisioning two
|
||||
* accounts with the same email at the same time. A fixed stripe count keeps memory constant.
|
||||
* Multi-instance deployments need an equivalent cross-node lock (database advisory lock or a
|
||||
* unique index with the other flows migrated) on top of this.
|
||||
*/
|
||||
private final Object[] emailLockStripes = new Object[64];
|
||||
|
||||
public LdapAuthService(LdapProperties ldapProperties,
|
||||
LdapContextSource ldapContextSource,
|
||||
UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
|
|
@ -94,6 +114,7 @@ public class LdapAuthService {
|
|||
EntityManager entityManager,
|
||||
PlatformTransactionManager transactionManager) {
|
||||
this.ldapProperties = ldapProperties;
|
||||
this.ldapContextSource = ldapContextSource;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
|
|
@ -101,6 +122,9 @@ public class LdapAuthService {
|
|||
this.entityManager = entityManager;
|
||||
this.ldapProvisioningTx = new TransactionTemplate(transactionManager);
|
||||
this.ldapProvisioningTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
for (int i = 0; i < emailLockStripes.length; i++) {
|
||||
emailLockStripes[i] = new Object();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,25 +155,77 @@ public class LdapAuthService {
|
|||
ldapProperties.getUserSearchAttribute());
|
||||
|
||||
// First, try to find the user in LDAP and authenticate
|
||||
Attributes userAttributes = authenticateAndFetch(username, password);
|
||||
|
||||
// Find or create local user account anchored on the stable LDAP subject. Account and
|
||||
// binding creation run in their own (sub-)transaction; a concurrent first login for the
|
||||
// same subject is recovered by re-resolving the identity in a fresh transaction. When the
|
||||
// directory entry carries an email, the check-and-insert of that email is additionally
|
||||
// serialized per email (striped monitor) so two different subjects cannot both pass the
|
||||
// collision check and provision duplicate accounts simultaneously.
|
||||
log.debug("Finding or creating local user account for username: {}", username);
|
||||
String email = getAttributeValue(userAttributes, ldapProperties.getEmailAttribute());
|
||||
UserAccount user = (email == null || email.isEmpty())
|
||||
? provisionUser(username, userAttributes)
|
||||
: provisionUserSerializedByEmail(username, userAttributes, email);
|
||||
|
||||
// Check if user can login (status check)
|
||||
log.debug("Checking user status for user: {}, status: {}", username, user.getStatus());
|
||||
ensureUserCanLogin(user);
|
||||
|
||||
log.debug("LDAP authentication successful for username: {}", username);
|
||||
return buildPrincipal(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and verifies an LDAP identity (search, bind, attribute read) without provisioning
|
||||
* a local account or identity binding. Serves the explicit account-binding flow: the caller
|
||||
* has already authenticated (or is being authenticated) and uses the LDAP credentials to
|
||||
* prove ownership of the directory identity.
|
||||
*/
|
||||
public LdapIdentity resolveIdentity(String username, String password) {
|
||||
if (!ldapProperties.isEnabled()) {
|
||||
log.warn("LDAP authentication is not enabled");
|
||||
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled");
|
||||
}
|
||||
validateAttributeNames();
|
||||
Attributes userAttributes = authenticateAndFetch(username, password);
|
||||
String subject = getAttributeValue(userAttributes, ldapProperties.getSubjectAttribute());
|
||||
if (subject == null || subject.isEmpty()) {
|
||||
log.error("LDAP entry for {} has no stable subject attribute '{}'; cannot bind identity",
|
||||
username, ldapProperties.getSubjectAttribute());
|
||||
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration");
|
||||
}
|
||||
return new LdapIdentity(
|
||||
username,
|
||||
subject,
|
||||
getAttributeValue(userAttributes, ldapProperties.getEmailAttribute()),
|
||||
resolveDisplayName(userAttributes, username)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the user entry, verifies the password via a directory bind, and fetches the entry
|
||||
* attributes. All errors are classified with the same semantics for login and binding.
|
||||
*/
|
||||
private Attributes authenticateAndFetch(String username, String password) {
|
||||
String userDn = findUserDn(username);
|
||||
log.debug("LDAP findUserDn result for {}: {}", username, userDn != null);
|
||||
|
||||
|
||||
if (userDn == null) {
|
||||
log.warn("User {} not found in LDAP directory", username);
|
||||
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.userNotFound");
|
||||
}
|
||||
|
||||
// Authenticate against LDAP
|
||||
log.debug("Attempting LDAP bind for user DN: {}", userDn);
|
||||
boolean authenticated = authenticateLdap(userDn, password);
|
||||
log.debug("LDAP bind result for {}: {}", username, authenticated);
|
||||
|
||||
|
||||
if (!authenticated) {
|
||||
log.warn("LDAP authentication failed for username: {}", username);
|
||||
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.ldap.invalidCredentials");
|
||||
}
|
||||
|
||||
// Get user attributes from LDAP
|
||||
log.debug("Fetching user attributes from LDAP for DN: {}", userDn);
|
||||
Attributes userAttributes = getUserAttributes(userDn);
|
||||
if (userAttributes == null) {
|
||||
|
|
@ -159,25 +235,33 @@ public class LdapAuthService {
|
|||
// as a 401 "invalid credentials" (which would mislead the user about the password).
|
||||
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.directoryUnavailable");
|
||||
}
|
||||
return userAttributes;
|
||||
}
|
||||
|
||||
// Find or create local user account anchored on the stable LDAP subject. Account and
|
||||
// binding creation run in their own (sub-)transaction; a concurrent first login for the
|
||||
// same subject is recovered by re-resolving the identity in a fresh transaction.
|
||||
log.debug("Finding or creating local user account for username: {}", username);
|
||||
UserAccount user;
|
||||
/**
|
||||
* Provisions the local account and identity binding in a REQUIRES_NEW sub-transaction,
|
||||
* recovering from a concurrent same-subject first login by re-resolving the existing account.
|
||||
*/
|
||||
private UserAccount provisionUser(String username, Attributes userAttributes) {
|
||||
try {
|
||||
user = ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes));
|
||||
return ldapProvisioningTx.execute(status -> findOrCreateLdapUser(username, userAttributes));
|
||||
} catch (LdapBindingRaceException e) {
|
||||
log.warn("Concurrent first login detected for LDAP subject of username {}; resolving existing account", username);
|
||||
user = ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username));
|
||||
return ldapProvisioningTx.execute(status -> resolveReturningUser(userAttributes, username));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user can login (status check)
|
||||
log.debug("Checking user status for user: {}, status: {}", username, user.getStatus());
|
||||
ensureUserCanLogin(user);
|
||||
|
||||
log.debug("LDAP authentication successful for username: {}", username);
|
||||
return buildPrincipal(user);
|
||||
/**
|
||||
* Serializes the check-and-insert of a non-empty LDAP email so concurrent first logins from
|
||||
* different subjects sharing one email cannot both provision an account. The monitor is held
|
||||
* until the provisioning sub-transaction commits, so the second caller observes the first
|
||||
* account and receives the regular 409 email-conflict result.
|
||||
*/
|
||||
private UserAccount provisionUserSerializedByEmail(String username, Attributes userAttributes, String email) {
|
||||
Object stripe = emailLockStripes[Math.floorMod(email.toLowerCase(Locale.ROOT).hashCode(), emailLockStripes.length)];
|
||||
synchronized (stripe) {
|
||||
return provisionUser(username, userAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -263,41 +347,32 @@ public class LdapAuthService {
|
|||
/**
|
||||
* Creates an LDAP context authenticated with the given principal/credentials. When both are
|
||||
{@code null}, falls back to the configured bind account (or anonymous if none is set). This is
|
||||
the single place that builds the JNDI environment, so connection/timeout settings stay
|
||||
consistent across search, bind, and attribute-read operations.
|
||||
the single place that obtains contexts, so connection/timeout/TLS settings configured on the
|
||||
shared {@link LdapContextSource} stay consistent across search, bind, and attribute-read
|
||||
operations.
|
||||
*/
|
||||
private DirContext createLdapContext(String principal, String credentials) throws NamingException {
|
||||
return new InitialDirContext(buildJndiEnvironment(principal, credentials));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the JNDI environment for one LDAP operation. Package-private so tests can assert
|
||||
* connection/timeout/TLS settings without opening a real directory connection.
|
||||
*/
|
||||
Hashtable<String, String> buildJndiEnvironment(String principal, String credentials) {
|
||||
Hashtable<String, String> env = new Hashtable<>();
|
||||
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
|
||||
env.put(Context.PROVIDER_URL, ldapProperties.getUrl());
|
||||
env.put(Context.SECURITY_AUTHENTICATION, "simple");
|
||||
// Connection timeout: configurable (default 5 seconds for connect, 10 for read)
|
||||
env.put("com.sun.jndi.ldap.connect.timeout", String.valueOf(ldapProperties.getConnectTimeoutMillis()));
|
||||
env.put("com.sun.jndi.ldap.read.timeout", String.valueOf(ldapProperties.getReadTimeoutMillis()));
|
||||
// Explicit principal/credentials take precedence; otherwise use the configured bind account.
|
||||
String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername();
|
||||
String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword();
|
||||
if (bindPrincipal != null && !bindPrincipal.isEmpty()) {
|
||||
env.put(Context.SECURITY_PRINCIPAL, bindPrincipal);
|
||||
env.put(Context.SECURITY_CREDENTIALS, bindCredentials);
|
||||
try {
|
||||
// Explicit principal/credentials take precedence; otherwise use the configured bind account.
|
||||
String bindPrincipal = (principal != null) ? principal : ldapProperties.getUsername();
|
||||
String bindCredentials = (principal != null) ? credentials : ldapProperties.getPassword();
|
||||
if (bindPrincipal != null && !bindPrincipal.isEmpty()) {
|
||||
return ldapContextSource.getContext(bindPrincipal, bindCredentials);
|
||||
}
|
||||
// No bind account configured: anonymous read context for directory searches/reads.
|
||||
return ldapContextSource.getReadOnlyContext();
|
||||
} catch (org.springframework.ldap.CommunicationException e) {
|
||||
// Spring LDAP wraps JNDI failures into unchecked org.springframework.ldap.* exceptions;
|
||||
// translate them back so the callers' javax.naming.* classification stays unchanged.
|
||||
throw (javax.naming.CommunicationException) new javax.naming.CommunicationException(e.getMessage())
|
||||
.initCause(e);
|
||||
} catch (org.springframework.ldap.AuthenticationException e) {
|
||||
throw (javax.naming.AuthenticationException) new javax.naming.AuthenticationException(e.getMessage())
|
||||
.initCause(e);
|
||||
} catch (org.springframework.ldap.NameNotFoundException e) {
|
||||
throw (javax.naming.NameNotFoundException) new javax.naming.NameNotFoundException(e.getMessage())
|
||||
.initCause(e);
|
||||
}
|
||||
// LDAPS certificate validation uses the JVM default trust store unless a custom store is
|
||||
// configured; this lets operators trust internal/self-signed directory CAs.
|
||||
String trustStorePath = ldapProperties.getTlsTrustStorePath();
|
||||
if (trustStorePath != null && !trustStorePath.isEmpty()) {
|
||||
env.put("javax.net.ssl.trustStore", trustStorePath);
|
||||
env.put("javax.net.ssl.trustStorePassword", ldapProperties.getTlsTrustStorePassword());
|
||||
env.put("javax.net.ssl.trustStoreType", ldapProperties.getTlsTrustStoreType());
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -347,7 +422,8 @@ public class LdapAuthService {
|
|||
*/
|
||||
static boolean isTlsFailure(Throwable t) {
|
||||
for (Throwable c = t; c != null; c = c.getCause()) {
|
||||
if (c instanceof SSLException || c instanceof CertificateException) {
|
||||
if (c instanceof SSLException || c instanceof CertificateException
|
||||
|| c instanceof CertPathValidatorException || c instanceof CertPathBuilderException) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -403,9 +479,14 @@ public class LdapAuthService {
|
|||
// response. Without the explicit request, operational attributes are omitted and
|
||||
// the subject key would be null on every login.
|
||||
attrs = ctx.getAttributes(new LdapName(userDn), new String[]{"*", "+"});
|
||||
} catch (Exception e) {
|
||||
} catch (NamingException e) {
|
||||
// Some directories reject the "*"/"+" attribute-request syntax; fall back to the
|
||||
// default attribute set instead of failing the whole login.
|
||||
// default attribute set for protocol/request-level failures only. Connection and
|
||||
// authentication failures must not trigger a retry — the outer catch blocks
|
||||
// classify them as TLS error vs directory-unavailable vs bind failure.
|
||||
if (e instanceof CommunicationException || e instanceof AuthenticationException) {
|
||||
throw e;
|
||||
}
|
||||
attrs = ctx.getAttributes(new LdapName(userDn));
|
||||
}
|
||||
return attrs;
|
||||
|
|
@ -444,9 +525,9 @@ public class LdapAuthService {
|
|||
username, ldapProperties.getSubjectAttribute());
|
||||
// Bind already succeeded. The directory entry lacks the configured subject attribute,
|
||||
// which is a configuration/schema issue the user cannot fix. Surface a 503 with the
|
||||
// fetchUserFailed message (no "retry later" wording) instead of a 401 that would look
|
||||
// like a wrong password. Operators can locate the cause via the log line above.
|
||||
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.fetchUserFailed");
|
||||
// invalidConfiguration message instead of a 401 that would look like a wrong password.
|
||||
// Operators can locate the cause via the log line above.
|
||||
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.invalidConfiguration");
|
||||
}
|
||||
|
||||
// Anchor on the stable LDAP subject: an existing binding means this identity is already known.
|
||||
|
|
@ -456,10 +537,23 @@ public class LdapAuthService {
|
|||
|
||||
if (binding != null) {
|
||||
// Returning user — refresh attributes from the directory on each login.
|
||||
UserAccount user = userAccountRepository.findById(binding.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for LDAP binding " + subject));
|
||||
updateFromAttributes(user, displayName, email);
|
||||
return userAccountRepository.save(user);
|
||||
var existing = userAccountRepository.findById(binding.getUserId());
|
||||
if (existing.isPresent()) {
|
||||
UserAccount user = existing.get();
|
||||
updateFromAttributes(user, displayName, email);
|
||||
if (!username.equals(binding.getLoginName())) {
|
||||
binding.setLoginName(username);
|
||||
identityBindingRepository.save(binding);
|
||||
}
|
||||
return userAccountRepository.save(user);
|
||||
}
|
||||
// The bound account no longer exists (e.g. deleted by an administrator). The stale
|
||||
// binding would otherwise block this subject forever with a 500. Remove it and fall
|
||||
// through to the first-login provisioning path, which creates a fresh account for
|
||||
// the directory identity.
|
||||
log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding",
|
||||
subject, binding.getUserId());
|
||||
identityBindingRepository.delete(binding);
|
||||
}
|
||||
|
||||
// First login for this LDAP identity. Refuse to silently inherit an existing local/OAuth
|
||||
|
|
@ -528,11 +622,27 @@ public class LdapAuthService {
|
|||
String displayName = resolveDisplayName(attributes, username);
|
||||
IdentityBinding binding = identityBindingRepository
|
||||
.findByProviderCodeAndSubject(LDAP_PROVIDER, subject)
|
||||
.orElseThrow(() -> new IllegalStateException("No LDAP binding found after race for subject " + subject));
|
||||
UserAccount user = userAccountRepository.findById(binding.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for LDAP binding " + subject));
|
||||
updateFromAttributes(user, displayName, email);
|
||||
return userAccountRepository.save(user);
|
||||
.orElse(null);
|
||||
if (binding == null) {
|
||||
// The racing transaction rolled back after all (rare), or the binding was cleaned up
|
||||
// concurrently. Fall back to the regular provisioning path.
|
||||
throw new LdapBindingRaceException(new IllegalStateException("No binding found after race for " + subject));
|
||||
}
|
||||
var existing = userAccountRepository.findById(binding.getUserId());
|
||||
if (existing.isPresent()) {
|
||||
UserAccount user = existing.get();
|
||||
updateFromAttributes(user, displayName, email);
|
||||
if (!username.equals(binding.getLoginName())) {
|
||||
binding.setLoginName(username);
|
||||
identityBindingRepository.save(binding);
|
||||
}
|
||||
return userAccountRepository.save(user);
|
||||
}
|
||||
// Stale binding for a deleted account: remove it and retry provisioning from scratch.
|
||||
log.warn("LDAP binding for subject {} points to missing account {}; removing stale binding",
|
||||
subject, binding.getUserId());
|
||||
identityBindingRepository.delete(binding);
|
||||
throw new LdapBindingRaceException(new IllegalStateException("Stale binding removed for " + subject));
|
||||
}
|
||||
|
||||
private String resolveDisplayName(Attributes attributes, String username) {
|
||||
|
|
@ -663,7 +773,7 @@ public class LdapAuthService {
|
|||
for (String name : attrNames) {
|
||||
if (name == null || !ATTRIBUTE_NAME_PATTERN.matcher(name).matches()) {
|
||||
log.error("Invalid LDAP attribute name configured: {}", name);
|
||||
throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.fetchUserFailed");
|
||||
throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.ldap.invalidConfiguration");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
com.iflytek.skillhub.auth.config.LdapTrustStoreEnvironmentPostProcessor
|
||||
|
|
@ -28,6 +28,7 @@ import javax.naming.directory.BasicAttributes;
|
|||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.ldap.core.support.LdapContextSource;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
|
|
@ -51,6 +52,7 @@ class LdapAuthServiceTest {
|
|||
private IdentityBindingRepository identityBindingRepository;
|
||||
private EntityManager entityManager;
|
||||
private PlatformTransactionManager transactionManager;
|
||||
private LdapContextSource ldapContextSource;
|
||||
private LdapAuthService ldapAuthService;
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -62,8 +64,10 @@ class LdapAuthServiceTest {
|
|||
identityBindingRepository = mock(IdentityBindingRepository.class);
|
||||
entityManager = mock(EntityManager.class);
|
||||
transactionManager = mock(PlatformTransactionManager.class);
|
||||
ldapContextSource = mock(LdapContextSource.class);
|
||||
ldapAuthService = new LdapAuthService(
|
||||
ldapProperties,
|
||||
ldapContextSource,
|
||||
userAccountRepository,
|
||||
userRoleBindingRepository,
|
||||
globalNamespaceMembershipService,
|
||||
|
|
@ -129,6 +133,26 @@ class LdapAuthServiceTest {
|
|||
verify(identityBindingRepository, never()).saveAndFlush(any(IdentityBinding.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleBindingForDeletedAccount_isRemovedAndAccountRecreated() throws Exception {
|
||||
// Given — the binding points to an account that no longer exists
|
||||
IdentityBinding stale = new IdentityBinding("usr_deleted", "ldap", SUBJECT, "alice");
|
||||
given(identityBindingRepository.findByProviderCodeAndSubject("ldap", SUBJECT))
|
||||
.willReturn(Optional.of(stale));
|
||||
given(userAccountRepository.findById("usr_deleted")).willReturn(Optional.empty());
|
||||
given(userAccountRepository.findByEmailIgnoreCase(EMAIL)).willReturn(Optional.empty());
|
||||
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
// When — the same subject logs in again
|
||||
UserAccount created = invokeFindOrCreate("alice", directoryAttributes(SUBJECT, EMAIL, DISPLAY_NAME));
|
||||
|
||||
// Then — the stale binding is removed and a fresh account is provisioned
|
||||
assertThat(created.getEmail()).isEqualTo(EMAIL);
|
||||
assertThat(created.getId()).isNotEqualTo("usr_deleted");
|
||||
verify(identityBindingRepository).delete(stale);
|
||||
verify(identityBindingRepository).saveAndFlush(any(IdentityBinding.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatLogin_refreshesAttributesFromDirectory() throws Exception {
|
||||
// Given — a returning user whose display name and email changed in the directory
|
||||
|
|
@ -211,6 +235,7 @@ class LdapAuthServiceTest {
|
|||
} catch (java.lang.reflect.InvocationTargetException ite) {
|
||||
AuthFlowException cause = (AuthFlowException) ite.getCause();
|
||||
assertThat(cause.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(cause.getMessageCode()).isEqualTo("error.auth.ldap.invalidConfiguration");
|
||||
} catch (Throwable t) {
|
||||
throw new AssertionError(t);
|
||||
}
|
||||
|
|
@ -298,21 +323,6 @@ class LdapAuthServiceTest {
|
|||
assertThat(result.getEmail()).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildJndiEnvironment_includesCustomTrustStoreSettings() {
|
||||
ldapProperties.setUrl("ldaps://ldap.example.com:636");
|
||||
ldapProperties.setTlsTrustStorePath("/certs/ldap-truststore.jks");
|
||||
ldapProperties.setTlsTrustStorePassword("secret");
|
||||
ldapProperties.setTlsTrustStoreType("PKCS12");
|
||||
|
||||
java.util.Hashtable<String, String> env = ldapAuthService.buildJndiEnvironment(null, null);
|
||||
|
||||
assertThat(env)
|
||||
.containsEntry("javax.net.ssl.trustStore", "/certs/ldap-truststore.jks")
|
||||
.containsEntry("javax.net.ssl.trustStorePassword", "secret")
|
||||
.containsEntry("javax.net.ssl.trustStoreType", "PKCS12");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTlsFailure_detectsSslHandshakeInCauseChain() {
|
||||
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
|
||||
|
|
@ -330,6 +340,14 @@ class LdapAuthServiceTest {
|
|||
assertThat(LdapAuthService.isTlsFailure(comm)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTlsFailure_detectsCertPathValidatorWithoutSslException() {
|
||||
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
|
||||
comm.initCause(new java.security.cert.CertPathValidatorException("path does not validate"));
|
||||
|
||||
assertThat(LdapAuthService.isTlsFailure(comm)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTlsFailure_ignoresPlainConnectionFailures() {
|
||||
javax.naming.CommunicationException comm = new javax.naming.CommunicationException("LDAP connect failed");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
MergeInitiateRequest,
|
||||
MergeInitiateResponse,
|
||||
MergeVerifyRequest,
|
||||
LdapBindRequest,
|
||||
ReviewSkillDetail,
|
||||
ReviewTask,
|
||||
PromotionTask,
|
||||
|
|
@ -444,6 +445,18 @@ export const accountApi = {
|
|||
},
|
||||
}
|
||||
|
||||
export const ldapApi = {
|
||||
async bindIdentity(request: LdapBindRequest): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/auth/ldap/bind', {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const skillLifecycleApi = {
|
||||
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
@ -757,7 +757,15 @@
|
|||
"confirming": "Confirming...",
|
||||
"confirm": "Confirm & Complete Merge",
|
||||
"confirmSuccess": "Account merge completed",
|
||||
"confirmError": "Merge confirmation failed"
|
||||
"confirmError": "Merge confirmation failed",
|
||||
"ldapBindTitle": "Bind LDAP Account",
|
||||
"ldapBindDesc": "Enter your LDAP directory username and password to attach the directory identity to this account; LDAP login becomes available afterwards.",
|
||||
"ldapUsername": "LDAP Username",
|
||||
"ldapPassword": "LDAP Password",
|
||||
"ldapBinding": "Binding...",
|
||||
"ldapBind": "Bind",
|
||||
"ldapBindSuccess": "LDAP identity bound successfully; you can now sign in with LDAP",
|
||||
"ldapBindError": "LDAP binding failed"
|
||||
},
|
||||
"namespace": {
|
||||
"notFound": "Namespace not found",
|
||||
|
|
|
|||
|
|
@ -757,7 +757,15 @@
|
|||
"confirming": "确认中...",
|
||||
"confirm": "确认并完成合并",
|
||||
"confirmSuccess": "账号合并已完成",
|
||||
"confirmError": "确认合并失败"
|
||||
"confirmError": "确认合并失败",
|
||||
"ldapBindTitle": "绑定 LDAP 账号",
|
||||
"ldapBindDesc": "输入 LDAP 目录用户名和密码,将目录身份绑定到当前账号;绑定后即可使用 LDAP 登录。",
|
||||
"ldapUsername": "LDAP 用户名",
|
||||
"ldapPassword": "LDAP 密码",
|
||||
"ldapBinding": "绑定中...",
|
||||
"ldapBind": "绑定",
|
||||
"ldapBindSuccess": "LDAP 身份绑定成功,现在可以使用 LDAP 登录",
|
||||
"ldapBindError": "LDAP 绑定失败"
|
||||
},
|
||||
"namespace": {
|
||||
"notFound": "命名空间不存在",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue