Skip to content

feat(auth): 企业 LDAP 登录与身份绑定 - #437

Open
jangrui wants to merge 14 commits into
iflytek:mainfrom
jangrui:pr-283
Open

feat(auth): 企业 LDAP 登录与身份绑定#437
jangrui wants to merge 14 commits into
iflytek:mainfrom
jangrui:pr-283

Conversation

@jangrui

@jangrui jangrui commented May 14, 2026

Copy link
Copy Markdown
Contributor

基于 PR #283 的 LDAP 目录登录实现,按 #437 review 要求完整实现企业级 LDAP 认证。

认证与身份:

  • LDAP 作为本地认证失败后的回退,支持 OpenLDAP(entryUUID)/ AD(objectGUID)稳定目录标识
  • 通过 IdentityBinding 绑定本地账号,首次登录持久化、重复登录按 subject 命中,不重复建号
  • 禁止邮箱静默合并:邮箱碰撞返回 409,不接管既有本地/OAuth 账号
  • 新增显式绑定端点(POST /api/v1/auth/ldap/bind),解决邮箱冲突后的自助绑定入口
  • 可配置属性映射(username/displayName/email/subject)与登录属性同步

安全与配置:

  • LDAPS 自定义信任库(EnvironmentPostProcessor 合并 CA),支持企业自签证书
  • 错误四分类(用户不存在/密码错误/目录不可用/TLS 证书)+ 中英文消息键
  • LDAP 条件化装配(disabled 不初始化连接),日志脱敏 URL 凭据,用户名正则防注入
  • 连接超时可配,移除 LdapTemplate 死依赖

前端:

  • 设置页 LDAP 绑定卡片
  • api-error 展示后端业务消息(账号禁用/目录不可用/TLS 错误)

测试:

  • LdapAuthService 专属单元测试(身份/碰撞/同步/禁用)
  • 真实 OpenLDAP Testcontainers 集成测试(登录全链路、LDAPS、并发首登、目录不可用、disabled 启动)

@CLAassistant

CLAassistant commented May 14, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces LDAP authentication as a fallback mechanism when local authentication fails. Key changes include the addition of LDAP configuration properties, a new LdapAuthService for directory integration, and updates to LocalAuthService to handle the fallback logic. Feedback focuses on critical issues such as potential account duplication when the email attribute is missing, resource leaks due to unclosed LDAP contexts, and the recommendation to use LdapTemplate instead of manual JNDI operations. Additionally, a compilation error in the unit tests regarding the PlatformPrincipal constructor was identified.

Comment on lines +260 to +265
if (email != null && !email.isEmpty()) {
user = userAccountRepository.findByEmailIgnoreCase(email.toLowerCase()).orElse(null);
}

// If not found, create a new user
if (user == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

如果 LDAP 的 mail 属性缺失或为空,user 变量将保持为 null,导致每次登录时都会创建并保存一个新的 UserAccount。这将导致同一个 LDAP 用户产生重复账号,并在不同会话之间丢失用户数据。建议使用唯一且稳定的 LDAP 标识符(如 uidentryUUID)将 LDAP 用户链接到本地账户,或者至少确保在缺少邮箱时使用 username 作为识别回退。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复

修复方案:使用 ldap:{username}@internal 作为唯一标识符,确保同一 LDAP 用户始终映射到同一个本地账号,防止重复账号创建和用户数据丢失。

提交: d8b507b

Comment on lines +263 to +270
PlatformPrincipal ldapPrincipal = new PlatformPrincipal(
"usr_ldap",
"ldapuser",
"ldapuser@example.com",
Set.of("USER"),
Set.of(),
Set.of()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

此处使用的 PlatformPrincipal 构造函数与 LocalAuthServiceLdapAuthService 中的实际调用不匹配。服务代码期望的参数列表为 (String id, String displayName, String email, String avatarUrl, String provider, Set<String> roles),但此测试传递的是 (String, String, String, Set, Set, Set)。这将导致编译错误。

Suggested change
PlatformPrincipal ldapPrincipal = new PlatformPrincipal(
"usr_ldap",
"ldapuser",
"ldapuser@example.com",
Set.of("USER"),
Set.of(),
Set.of()
);
PlatformPrincipal ldapPrincipal = new PlatformPrincipal(
"usr_ldap",
"ldapuser",
"ldapuser@example.com",
null,
"ldap",
Set.of("USER")
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复

修复方案:更正 PlatformPrincipal 构造函数参数顺序为:
(userId, displayName, email, avatarUrl, oauthProvider, roles)

与 LocalAuthService 和 LdapAuthService 中的调用保持一致。

提交: d8b507b

Comment on lines +220 to +237
private Attributes getUserAttributes(String userDn) {
try {
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");

// Use bind DN if configured, otherwise anonymous bind
if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) {
env.put(Context.SECURITY_PRINCIPAL, ldapProperties.getUsername());
env.put(Context.SECURITY_CREDENTIALS, ldapProperties.getPassword());
}

DirContext ctx = new InitialDirContext(env);
Attributes attrs = ctx.getAttributes(new LdapName(userDn));
ctx.close();
return attrs;
} catch (Exception e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

DirContext 没有在 finally 块中或使用 try-with-resources 正确关闭。如果 ctx.getAttributes 抛出异常,LDAP 连接将会泄露。此外,LDAP 搜索产生的 NamingEnumeration 结果(例如第 153 行)也应显式关闭以释放资源。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复

修复方案:在 finally 块中显式关闭 NamingEnumeration 和 DirContext,防止资源泄露。

finally {
    if (results != null) {
        try {
            results.close();
        } catch (Exception e) {
            log.warn("Failed to close LDAP search results", e);
        }
    }
    closeContext(ctx);
}

提交: d8b507b

Comment on lines +42 to +57
private final LdapTemplate ldapTemplate;
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;

public LdapAuthService(LdapProperties ldapProperties,
LdapTemplate ldapTemplate,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
this.ldapProperties = ldapProperties;
this.ldapTemplate = ldapTemplate;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

该类注入了 LdapTemplate,但仍使用 InitialDirContext 执行手动 JNDI 操作。这是冗余且容易出错的。你应该利用 LdapTemplate 进行搜索和认证,它会自动处理连接池、资源管理和异常转换。这将显著简化代码并消除潜在的资源泄露风险。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️️ 不采纳

原因:

  1. 保持代码一致性:原 PR 新增支持LDAP登录https://github.com/iflytek/skillhub/issues/260 #283 使用手动 JNDI 操作,为保持一致性,我们沿用了该模式
  2. 资源管理已修复:通过显式关闭 NamingEnumerationDirContext,资源泄露问题已解决
  3. 最小化变更原则:作为 bug 修复 PR,应尽量减少架构性变更
  4. 功能完整性:手动 JNDI 操作提供了更细粒度的控制,便于调试和错误处理

未来改进建议:如果进行大规模重构,可以考虑迁移到 LdapTemplate

提交: d8b507b

jangrui added a commit to jangrui/skillhub that referenced this pull request May 14, 2026
基于 PR iflytek#283 的代码审查反馈,修复了多个关键问题:

## 🔴 Critical 修复

### 1. 修复 LDAP 账号重复创建问题
**问题**: 如果 LDAP 用户的 mail 属性缺失或为空,每次登录都会创建新的 UserAccount,导致:
- 同一用户产生多个账号
- 用户数据在不同会话间丢失
- 数据库中出现大量重复账号

**修复**:
- 当 mail 属性为空时,使用 "ldap:{username}@internal" 作为唯一标识符
- 确保同一 LDAP 用户始终映射到同一个本地账号
- 保持用户数据的连续性

```java
// 修复前:email 为 null 时会创建新账号
String normalizedEmail = email != null ? email.toLowerCase() : null;

// 修复后:使用稳定的唯一标识符
String normalizedEmail = email != null ? email.toLowerCase() : "ldap:" + username + "@internal";
```

## 🟠 High 优先级修复

### 2. 修复测试代码中的 PlatformPrincipal 构造函数错误
**问题**: 测试中使用了错误的构造函数参数,导致编译错误

**修复**: 更正为正确的参数顺序:
```java
// 修复前
new PlatformPrincipal(userId, displayName, email, Set.of(), Set.of(), Set.of())

// 修复后
new PlatformPrincipal(userId, displayName, email, null, "ldap", Set.of("USER"))
```

### 3. 修复资源泄露问题
**问题**: `NamingEnumeration<SearchResult>` 没有正确关闭,导致 LDAP 连接泄露

**修复**: 在 finally 块中显式关闭 NamingEnumeration
```java
finally {
    if (results != null) {
        try {
            results.close();
        } catch (Exception e) {
            log.warn("Failed to close LDAP search results", e);
        }
    }
    closeContext(ctx);
}
```

## 📝 测试覆盖

- 添加了 3 个新测试用例验证 LDAP 回退功能
- 所有测试用例使用正确的构造函数
- 确保原有测试不受 LDAP 功能影响

## 🔗 相关链接

- 原始 PR: iflytek#283
- 修复 PR: iflytek#437
- Issue: iflytek#260

这些修复确保了 LDAP 认证功能的稳定性和正确性。
jangrui added a commit to jangrui/skillhub that referenced this pull request May 14, 2026
基于 PR iflytek#283 的代码审查反馈,修复了多个关键问题:

## 🔴 Critical 修复

### 1. 修复 LDAP 账号重复创建问题
**问题**: 如果 LDAP 用户的 mail 属性缺失或为空,每次登录都会创建新的 UserAccount,导致:
- 同一用户产生多个账号
- 用户数据在不同会话间丢失
- 数据库中出现大量重复账号

**修复**:
- 当 mail 属性为空时,使用 "ldap:{username}@internal" 作为唯一标识符
- 确保同一 LDAP 用户始终映射到同一个本地账号
- 保持用户数据的连续性

```java
// 修复前:email 为 null 时会创建新账号
String normalizedEmail = email != null ? email.toLowerCase() : null;

// 修复后:使用稳定的唯一标识符
String normalizedEmail = email != null ? email.toLowerCase() : "ldap:" + username + "@internal";
```

## 🟠 High 优先级修复

### 2. 修复测试代码中的 PlatformPrincipal 构造函数错误
**问题**: 测试中使用了错误的构造函数参数,导致编译错误

**修复**: 更正为正确的参数顺序:
```java
// 修复前
new PlatformPrincipal(userId, displayName, email, Set.of(), Set.of(), Set.of())

// 修复后
new PlatformPrincipal(userId, displayName, email, null, "ldap", Set.of("USER"))
```

### 3. 修复资源泄露问题
**问题**: `NamingEnumeration<SearchResult>` 没有正确关闭,导致 LDAP 连接泄露

**修复**: 在 finally 块中显式关闭 NamingEnumeration
```java
finally {
    if (results != null) {
        try {
            results.close();
        } catch (Exception e) {
            log.warn("Failed to close LDAP search results", e);
        }
    }
    closeContext(ctx);
}
```

## 📝 测试覆盖

- 添加了 3 个新测试用例验证 LDAP 回退功能
- 所有测试用例使用正确的构造函数
- 确保原有测试不受 LDAP 功能影响

## 🔗 相关链接

- 原始 PR: iflytek#283
- 修复 PR: iflytek#437
- Issue: iflytek#260

这些修复确保了 LDAP 认证功能的稳定性和正确性。
@jangrui

jangrui commented May 14, 2026

Copy link
Copy Markdown
Contributor Author

@dongmucat

您好!这个 PR 修复了 PR #283 的测试问题,包括:

  • 明确配置 LDAP mock 默认行为
  • 添加 LDAP 回退功能测试
  • 修复 Gemini Code Assist 指出的关键问题(重复账号、资源泄露)

当前有 2 个 workflows 需要您的批准:

  • PR Tests
  • PR E2E Tests

烦请批准运行,谢谢!

@dongmucat

Copy link
Copy Markdown
Collaborator

@dongmucat

您好!这个 PR 修复了 PR #283 的测试问题,包括:

  • 明确配置 LDAP mock 默认行为
  • 添加 LDAP 回退功能测试
  • 修复 Gemini Code Assist 指出的关键问题(重复账号、资源泄露)

当前有 2 个 workflows 需要您的批准:

  • PR Tests
  • PR E2E Tests

烦请批准运行,谢谢!

好的,已批准

@XiaoSeS

XiaoSeS commented May 19, 2026

Copy link
Copy Markdown
Collaborator

@jangrui 辛苦了,接力推进 LDAP 这个 PR 不容易,几个关键修复都很到位 👍 我从外部看了一下 #283#437 的对比,整理一些想法供参考。

相比 #283 已经修好的几处都很关键:

  • NamingEnumeration / DirContextfinally 里关闭 — 高并发场景下的资源泄露隐患解决了
  • ldap:{username}@internal 作 email key 避免重复账号 — 这个思路挺巧
  • 测试里 PlatformPrincipal 构造器顺序修正 + ldapProperties.isEnabled() mock 显式化,CI 失败的根因都覆盖到了
  • 补的 2 个 LDAP fallback 测试用例守住了核心新逻辑

一些可以再讨论的点:

  1. spring-boot-starter-data-ldap 的 AutoConfiguration 副作用

    这个依赖会在启动时尝试初始化 LDAP 连接,即使 SKILLHUB_LDAP_ENABLED=falseapplication.yml 里加的 management.health.ldap.enabled: false 只关掉了健康检查端点,AutoConfiguration 本身仍会触发。对于不用 LDAP 的存量部署可能产生启动日志噪音或连接错误。

    一个相对小的改法是在 application.yml 里补一行:

    spring:
      ldap:
        urls: ""

    或者把 LdapTemplate 的 Bean 改成按 skillhub.ldap.enabled 条件化创建。完全理解你"最小化变更"的考量,但这个不属于架构重构,更接近修一个隐性 bug。

  2. ldap:{username}@internal 这个标识符约定

    解决重复账号的方向是对的,不过这个格式现在是隐式约定,将来读到 email = ldap:xxx@internal 的人会很困惑。能否在 LdapAuthService 里加几行注释说明这是 LDAP 用户的内部唯一键?另外如果 LDAP 里两个不同 OU 下有同 uid 的用户,会撞 key —— 是否考虑用 entryUUID / objectGUID 之类的稳定标识?这个不阻塞合并,作为后续改进点提一下。

  3. debug 日志可能泄露 LDAP URL 里的密码

    LdapAuthService.login()LocalAuthService.login() 里都有 log.debug("LDAP URL: {}", ldapProperties.getUrl())。如果运维把 URL 写成 ldap://binduser:password@host 这种形式(不常见但合法),debug 模式下会明文打印。建议要么去掉这行,要么只打印 host 部分。

  4. PR 描述与实际内容

    PR 标题是"修复 PR 新增支持LDAP登录https://github.com/iflytek/skillhub/issues/260 #283 的测试问题",但实际包含了 新增支持LDAP登录https://github.com/iflytek/skillhub/issues/260 #283 的全部业务代码。Maintainer 第一眼看可能会以为只是测试改动,建议在描述里说明"这是基于 新增支持LDAP登录https://github.com/iflytek/skillhub/issues/260 #283 的完整实现,附带 Gemini 指出的修复",方便后续 review。

关于合并路径建议:

#437#283 是 100% 重叠的,建议 maintainer 考虑直接合并 #437,同时关闭 #283 并在关闭说明里注明"由 #437 替代,感谢 @cw1427 的原始贡献",保留对原作者的归属感谢。合并前如果能顺手处理一下上面的 #1(AutoConfiguration 启动副作用)和 #3(日志泄露)就更稳了。

不管最终怎么定,你这次的接力工作都很有价值,给 LDAP 功能补上了真实可用的实现 🙌

@jangrui

jangrui commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

@XiaoSeS

感谢 Review,已按建议修复:

  1. AutoConfiguration 副作用 — 在 application.yml 中添加 spring.ldap.urls: "",阻止 Spring Boot 默认 LDAP 自动配置在未启用时尝试初始化连接

  2. 日志泄露密码风险 — 新增 safeLogHost() 方法,从 LDAP URL 中提取 host 时移除用户凭据部分,避免日志中打印密码

  3. 资源泄露 — 在 findUserDn()authenticateLdap()getUserAttributes() 方法中添加 try-finally 块,确保 DirContextNamingEnumeration 正确关闭

  4. 连接超时 — 为所有 LDAP 连接添加 5 秒连接超时和 10 秒读取超时,防止无限等待

  5. LDAP 注入防护 — 新增 isValidUsername() 方法,对用户名进行正则校验,阻止恶意输入

  6. 配置可维护性 — 新增 LdapAutoConfiguration 类,通过 @ConditionalOnProperty 实现零配置开关,仅设置 skillhub.ldap.enabled=true 即可启用

请再次 Review。

Shawn Chen and others added 6 commits June 4, 2026 07:07
Signed-off-by: jangrui <admin@jangrui.com>
问题分析:
- 原有测试未显式设置 ldapProperties.isEnabled() 的返回值
- 虽然 Mockito 默认返回 false,但为了测试稳定性应显式配置
- 缺少对 LDAP 回退功能的测试覆盖

修复内容:
1. 在 setUp() 中显式设置 ldapProperties.isEnabled() 返回 false
   - 确保所有原有测试不受 LDAP 功能影响
   - 提高测试的可读性和维护性

2. 添加三个新的测试用例:
   - login_withUnknownUsername_fallsBackToLdap_whenEnabled
     验证 LDAP 启用时,本地用户不存在会回退到 LDAP 认证
   - login_withUnknownUsername_fails_whenLdapAuthenticationFails
     验证 LDAP 认证失败时正确抛出异常
   - login_withUnknownUsername_fails_whenLdapDisabled
     验证 LDAP 禁用时不会调用 LDAP 服务

这些修改确保了:
- 原有测试的稳定性和可预测性
- LDAP 回退功能的正确性
- 测试覆盖的完整性

Refs: iflytek#283
Signed-off-by: jangrui <admin@jangrui.com>
基于 PR iflytek#283 的代码审查反馈,修复了多个关键问题:

## 🔴 Critical 修复

### 1. 修复 LDAP 账号重复创建问题
**问题**: 如果 LDAP 用户的 mail 属性缺失或为空,每次登录都会创建新的 UserAccount,导致:
- 同一用户产生多个账号
- 用户数据在不同会话间丢失
- 数据库中出现大量重复账号

**修复**:
- 当 mail 属性为空时,使用 "ldap:{username}@internal" 作为唯一标识符
- 确保同一 LDAP 用户始终映射到同一个本地账号
- 保持用户数据的连续性

```java
// 修复前:email 为 null 时会创建新账号
String normalizedEmail = email != null ? email.toLowerCase() : null;

// 修复后:使用稳定的唯一标识符
String normalizedEmail = email != null ? email.toLowerCase() : "ldap:" + username + "@internal";
```

## 🟠 High 优先级修复

### 2. 修复测试代码中的 PlatformPrincipal 构造函数错误
**问题**: 测试中使用了错误的构造函数参数,导致编译错误

**修复**: 更正为正确的参数顺序:
```java
// 修复前
new PlatformPrincipal(userId, displayName, email, Set.of(), Set.of(), Set.of())

// 修复后
new PlatformPrincipal(userId, displayName, email, null, "ldap", Set.of("USER"))
```

### 3. 修复资源泄露问题
**问题**: `NamingEnumeration<SearchResult>` 没有正确关闭,导致 LDAP 连接泄露

**修复**: 在 finally 块中显式关闭 NamingEnumeration
```java
finally {
    if (results != null) {
        try {
            results.close();
        } catch (Exception e) {
            log.warn("Failed to close LDAP search results", e);
        }
    }
    closeContext(ctx);
}
```

## 📝 测试覆盖

- 添加了 3 个新测试用例验证 LDAP 回退功能
- 所有测试用例使用正确的构造函数
- 确保原有测试不受 LDAP 功能影响

## 🔗 相关链接

- 原始 PR: iflytek#283
- 修复 PR: iflytek#437
- Issue: iflytek#260

这些修复确保了 LDAP 认证功能的稳定性和正确性。

Signed-off-by: jangrui <admin@jangrui.com>
- application.yml: 合并重复的 management.health 节点,解决 DuplicateKeyException
- LocalAuthServiceTest.java: 补充 PlatformPrincipal/Set import 和类闭合括号

Signed-off-by: jangrui <admin@jangrui.com>
将 ldapProperties.isEnabled() 的默认 stub 从 setUp() 移到
login_withUnknownUsername_stillPerformsDummyPasswordCheck 中,
消除 7 个测试方法的 UnnecessaryStubbingException。

Signed-off-by: jangrui <admin@jangrui.com>
- Prevent spring-boot-starter-data-ldap AutoConfiguration side effects
  by setting spring.ldap.urls="" to avoid connection attempts when disabled
- Add conditional LdapAutoConfiguration that only creates LdapTemplate
  when skillhub.ldap.enabled=true
- Fix resource leaks in LdapAuthService (DirContext, NamingEnumeration)
- Add safeLogHost() to prevent credential exposure in logs
- Add connection timeouts (5s connect, 10s read) to prevent hangs
- Add LDAP injection prevention via isValidUsername() validation

Signed-off-by: jangrui <admin@jangrui.com>

@XiaoSeS XiaoSeS left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

This is not safe to merge yet. The current CI and my Java 21 merge-tree build prove that the code compiles, but they do not cover the LDAP identity and provisioning behavior introduced by this PR.

  1. Blocking security issue: an LDAP login can take over an existing account by email collision. In LdapAuthService.findOrCreateLdapUser, a successful LDAP bind followed by a matching mail value directly reuses any existing UserAccount. There is no LDAP identity binding or explicit account-link/merge check. An LDAP entry with the same email as a local/OAuth account therefore receives that account and its roles. LDAP users must be keyed by a stable provider subject such as entryUUID, objectGUID, or another configured immutable identifier, using the existing identity-binding/account-merge model. Email alone must not silently link accounts.

  2. The requested provisioning/sync contract is incomplete. Issue #260 asks for configurable synchronized attributes. This implementation hard-codes mail, displayName, and cn, and does not update attributes on later logins. The no-mail fallback ldap:{username}@internal is also not a stable directory identity: usernames can change and the same username can exist in different directory branches.

  3. There is no functional LDAP or LDAPS verification. The added tests mock LdapAuthService; LdapAuthService itself has no unit or integration coverage. Before merge, please verify at least OpenLDAP login, LDAPS trust/certificate handling, first-login provisioning, repeat login and attribute synchronization, duplicate-email behavior, invalid credentials, directory unavailable, and LDAP-disabled startup.

  4. Directory failures are hidden and user-facing message keys are missing. Search, bind, attribute lookup, and attribute-read exceptions are broadly converted to null/false, so bad configuration or an unavailable LDAP server is reported like “user not found” or “invalid credentials”. Also, the error.auth.ldap.* keys used here do not exist in the message bundles. Please preserve safe diagnostic logging/exception classification and add localized messages.

  5. LdapTemplate is configured and injected but never used; all operations duplicate connection setup through raw JNDI. Please either use the configured client consistently or remove the dead dependency/configuration and test the chosen lifecycle.

Validation performed against current main plus PR head 9e178711:

  • Java 21 full reactor compile/package: passed.
  • LDAP-disabled Spring context via LocalAuthControllerTest: 13/13 passed.
  • Existing LocalAuthServiceTest LDAP fallback cases: passed, but they only exercise a mocked LDAP service.

Because this is a large authentication feature rather than a simple test fix, I recommend updating the PR title/description and addressing the identity model plus real-directory tests before another review.

@XiaoSeS

XiaoSeS commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

维护状态说明:当前 HEAD 9e178711 已在 2026-07-28 收到可操作的 Changes Requested。按新的历史 PR 处理策略,从该次 Review 起保留 14 天作者响应窗口(至 2026-08-11)。

在此期间维护者不会改写作者提交;如作者补交修改,将基于新 HEAD 继续 Review。若届时仍无回复,维护者可通过额外的、带 DCO sign-off 的提交接管修复,并按 big-main → 测试机验证的顺序处理,不会直接进入 main

当前结论仍是阻塞,尚未进入 big-main,也未在测试机部署。

@XiaoSeS

XiaoSeS commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

补充一份可执行的改进方案,便于后续修复和重新验证:

  1. 以稳定目录标识建立身份绑定。 增加可配置的 LDAP subject 属性(OpenLDAP 可使用 entryUUID,AD 可使用 objectGUID),通过现有 IdentityBindingprovider=ldap + subject 查找账号;不要再把 email 当成身份主键。
  2. 禁止邮箱静默合并。 LDAP identity 尚未绑定但 email 已属于本地/OAuth 账号时,应返回明确冲突并进入显式绑定/合并流程,不能直接继承该账号及其角色。
  3. 修复无邮箱用户重复创建。 首次登录保存 identity binding;后续登录必须先按 subject 命中同一账号,ldap:{username}@internal 只能作为占位邮箱,不能承担身份键职责。
  4. 实现可配置的属性同步。 至少支持 username、displayName、email 的属性映射,并明确首次创建与后续登录的同步策略;不能只在首次创建时硬编码读取 mail/displayName/cn
  5. 统一 LDAP 客户端和错误分类。 使用已配置的 LdapTemplate/ContextSource,或删除未使用 Bean 后完整封装 JNDI;区分用户不存在、密码错误、目录不可用、TLS/证书错误,并补齐中英文消息键。
  6. 补齐验证。 单元测试覆盖邮箱碰撞、无邮箱重复登录、属性更新和禁用账号;集成测试覆盖 OpenLDAP、LDAPS、首次/重复登录、错误密码、目录不可用及 LDAP disabled 启动。

完成上述修改后,建议先合入 big-main,再在测试机使用临时 LDAP/LDAPS 容器跑完整链路;验证通过后才具备合并条件。当前 HEAD 不建议直接合并。

jangrui added 6 commits August 1, 2026 10:51
按 PR iflytek#437 review (CHANGES_REQUESTED + 改进方案) 系统修复 LDAP 登录安全与稳定性问题:

身份模型重构:
- 锚定稳定目录标识 (entryUUID/objectGUID) 而非输入侧 username,复用 IdentityBinding 绑定,首次登录持久化、重复登录按 subject 命中,避免重复建号
- 禁止邮箱静默合并/继承:邮箱已被任意账号占用即抛 409 (跨 provider 与同 issuer 双 subject 均拒绝);ldap:{username}@internal 降级为纯占位,不承担身份键
- 规范化 AD objectGUID 二进制属性为稳定 GUID 字符串 (混合字节序),并补回归单测

操作属性获取:
- getUserAttributes 显式请求 "*"+"+" 属性集,OpenLDAP entryUUID / AD objectGUID 等操作属性可正常返回,默认 subject 配置不再导致登录 503;目录拒绝该语法时回退默认属性集

安全与错误分类:
- 属性名白名单校验防 JNDI 注入,isValidUsername 改预编译 Pattern
- 区分 TLS/证书错误与目录不可用 (isTlsFailure 遍历 cause 链),新增 error.auth.ldap.tlsError 中英文消息键
- bind 成功后的目录错误由 500 折叠 401 改为透传 503;LocalAuthService 透传 403/503/409,不再误导为密码错误
- displayName fallback 与超时参数可配置化 (默认 cn / 5s+10s);修正 application.yml LDAP 配置段缩进

架构清理:
- LdapAuthService 改用 JNDI 直连,移除 LdapTemplate 死依赖与失效配置;LdapAuthService 条件化、LocalAuthService 改 ObjectProvider 可选注入

测试补齐 (单测):
- LdapAuthServiceTest 行为单测:邮箱碰撞拒绝、重复登录按 subject 命中不重复建号、属性更新刷新、禁用账号拒绝、subject 缺失 503、cn fallback
- LdapSubjectGuidTest:objectGUID 二进制规范化回归
- LdapAuthServiceTest isTlsFailure 分类单测;LocalAuthServiceTest 错误传播单测

Signed-off-by: jangrui <admin@jangrui.com>
- OpenLDAP(Testcontainers)全链路:默认 entryUUID 首登建号绑定、重复登录不重复建号、无邮箱占位与 cn 回退、邮箱碰撞 409、错误密码 401、属性变更同步刷新、LDAPS TLS 错误分类
- 目录不可用:真实 JNDI 连接关闭端口,断言 503 directoryUnavailable;不依赖 Docker,任意环境可运行
- LDAP disabled 启动:完整上下文启动,断言 LdapAuthService Bean 缺席且本地登录降级 401
- 统一 Testcontainers 版本:junit-jupiter 移除显式 1.20.3,由 Spring Boot BOM 统一管理为 1.19.5

Signed-off-by: jangrui <admin@jangrui.com>
- 并发首登同一 LDAP subject 时,唯一约束冲突经 REQUIRES_NEW 子事务回滚后按 subject 重查命中既有账号,双方登录均成功且不重复建号
- null/空密码在 authenticateLdap 显式归类 401,避免 Hashtable NPE 导致 500
- 已绑定用户 email 变更增加碰撞检查(排除自身账号),与首登 409 规则一致
- LDAPS 支持自定义 truststore 配置(tls-trust-store*),企业自签 CA 可用
- 属性名白名单补 displayNameFallbackAttribute;LDAP 配置启动期校验(url/base/超时)
- LDAP 登录成功与开始日志降为 debug
- 新增并发首登集成测试(真实 OpenLDAP)、空密码 401 用例、email 刷新碰撞单测、truststore env 单测、LdapProperties 校验单测

Signed-off-by: jangrui <admin@jangrui.com>
- 显式绑定端点 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>
- 403 与 5xx 分支优先展示后端本地化业务消息(账号禁用、目录不可用、TLS 配置错误),无消息时回退通用文案
- .gitignore 增加 .pnpm-store/

Signed-off-by: jangrui <admin@jangrui.com>
账号被删除时其身份绑定随之外键级联清理,配合 LdapAuthService 的孤儿绑定自愈,防止残留绑定阻塞目录身份重新建号。

Signed-off-by: jangrui <admin@jangrui.com>
LocalAuthService.login 移除方法级事务,LDAP 目录网络调用(最长 connect+read 超时)在无事务上下文中执行,本地凭据路径的数据库操作由 TransactionTemplate 显式包裹,避免目录慢速/不可达时放大连接池占用。

Signed-off-by: jangrui <admin@jangrui.com>
@jangrui jangrui changed the title fix(tests): 修复 PR #283 的测试问题 feat(auth): 企业 LDAP 登录与身份绑定 Aug 1, 2026
upstream main 已存在 V42__audit_log_created_at_timestamptz.sql,本 PR 的
identity_binding 级联删除迁移改用 V44(main 当前最高版本为 V43),消除
FlywayMigrationGuardrailTest 检测到的版本号冲突。

Signed-off-by: jangrui <admin@jangrui.com>
@FenjuFu

FenjuFu commented Aug 1, 2026

Copy link
Copy Markdown
Member

Heads-up on a collision: #672 (@XiaoSeS, opened 2026-07-31) also implements enterprise LDAP/AD login, so this PR and that one overlap and would conflict if both landed.

Both do OpenLDAP entryUUID / AD objectGUID and both return 409 on email collision instead of silently merging — same problem, two designs.

A maintainer should pick one lane before either merges. @dongjiang1989 — standalone (#437) or folded into the federation core (#672)? The explicit-bind endpoint here (POST /api/v1/auth/ldap/bind) is a genuinely useful piece for the email-conflict flow and could be carried into whichever design wins.

Flagging so these converge rather than race — not voting on the direction.

@XiaoSeS

XiaoSeS commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

维护者复核结论(保留 PR,不关闭):

#437 的 LDAP 功能与已在 #672 采用的统一身份核心存在架构重叠。#437 直接在 LocalAuthService fallback 中按 LDAP 结果建号/绑定,#672 则把 LDAP/AD 放进统一的 provider adapter,由稳定 subject(OpenLDAP entryUUID / AD objectGUID)和 identity binding 负责首次登录、重复登录、显式 Link、Merge 与 profile policy。两条实现不应同时进入 main,否则会出现两套 LDAP 登录和账号绑定语义。

因此当前不建议把 #437 直接合入 mainbig-main#672 已在 big-main 完成香港隔离验证,覆盖 provider 目录、首次/重复登录、logout、错误密码/未知用户脱敏和真实 uid 改名后的稳定 userId;验证记录见 https://github.com/iflytek/skillhub/pull/672#issuecomment-5161586723。

#437 中仍有可移植的设计/测试价值,建议保留为参考:

  • 显式 LDAP bind/link 入口,可作为统一 identity core 的 email collision / explicit link UX 设计输入;
  • 目录不可用、TLS/证书和 LDAP disabled 启动的测试场景;
  • 对多 OU 同名账号、属性同步和错误分类的边界覆盖。

如果继续维护本 PR,建议不要再增加第二套账号创建逻辑,而是把上述场景改写为统一 provider adapter / identity-link contract 的测试或设计补充。当前维持 Changes Requested,等待后续统一身份方案收敛。

@FenjuFu

FenjuFu commented Aug 3, 2026

Copy link
Copy Markdown
Member

Thanks @XiaoSeS for the clear call — this resolves the collision I flagged. Agreed on the direction: LDAP folded into the unified identity core (#672 on big-main) rather than two parallel account-creation paths.

@jangrui — the useful parts of this PR aren't wasted. Per XiaoSeS's note, the explicit LDAP bind/link endpoint and the edge-case tests here (directory-unavailable, TLS/cert, LDAP-disabled startup, multi-OU same-name accounts, attribute sync, error classification) are exactly the kind of coverage the unified adapter will want. The most valuable next step would be to re-target those scenarios as tests / design input against the #672 identity-link contract, rather than maintaining a second LocalAuthService-fallback build path. Keeping this open as reference (Changes Requested) makes sense until the unified approach lands.

@jangrui

jangrui commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

感谢两位的反馈和复核。同意方向:LDAP 并入统一身份核心(#672/#630),不再维护第二条账号创建路径。

本 PR 的资产可供统一 adapter 参考:

  • 显式 bind/link 端点 POST /api/v1/auth/ldap/bind
  • 测试覆盖:目录不可用、LDAP disabled 启动、并发首登、稳定 subject 标识、多 OU 同名账号、TLS/证书、错误分类

后续如有推进,将以对 #672 identity-link contract 的测试/设计补充形式跟进。再次感谢 @cw1427 的原始贡献。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants