Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ target/
coverage/
dist/
node_modules/
.pnpm-store/
*.tsbuildinfo
package-lock.json

Expand Down
32 changes: 32 additions & 0 deletions server/skillhub-app/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand All @@ -111,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>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
25 changes: 25 additions & 0 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,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:
Expand Down Expand Up @@ -206,6 +229,8 @@ management:
health:
mail:
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}
ldap:
enabled: false
endpoints:
web:
exposure:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.forbidden=Forbidden
error.request.timeout=Request timed out
Expand Down
8 changes: 8 additions & 0 deletions server/skillhub-app/src/main/resources/messages_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.forbidden=没有权限执行该操作
error.request.timeout=请求超时
Expand Down
Loading
Loading