# AD 第一因素接入 `/signin` 接收 AD 用户名(sAMAccountName)或本域 UPN,通过 Spring LDAP `ContextSource.getContext` 以用户身份执行 LDAPS bind, 随后由 Spring Data LDAP 仓储复用这条已认证连接查询用户与组。 不使用额外目录服务账号,不写入 AD,不复制 Authelia 的绑定密码。 领域仓储接口为 `UserRepository`,`User` 为领域模型。`VerifyPassword` 应用用例以 try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRepository` 通过 `SimpleLdapRepository`、`LdapTemplate`、ODM 实现读取与转换,关闭后不可继续查询。 `AdUserEntry` 的 LDAP 注解不会进入领域对象。 成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。 **密码成功本身不是完整登录成功。** Spring Security 先保存仅含 `FACTOR_PASSWORD` 的认证结果;启用 [WebAuthn](webauthn.md) 后在此继续第二因素,否则停留在等待页面。 未实现的应用请求使用 `denyAll`,不调用 Hydra, 不替换现役 Go/Authelia/Gitea 登录链路。 ## 目录和组语义 - AD 是身份和组权威。objectGUID 按 AD 混合字节序解析为标准 UUID,作为目录稳定键; 不是已确定的 Hydra `sub`。切换前仍须处理现役 issuer/sub 哈希的主体连续性。 - 从 `memberOf` 读取直接成员关系,保留原始 DN;CN 保留大小写,不添加 `ROLE_`, 不把 Spring 的 `FACTOR_PASSWORD` 当作组。结果排序,不做应用专用组改写。 - 此轮不展开嵌套组、不推导 primaryGroupID,也不宣称与 Authelia 的有效组集合完全一致。 遇到 ranged memberOf 或不同 DN 同名 CN 拒绝映射,不静默丢组或合并不同主体。 - AD bind 执行密码及账号状态检查;基础设施层将禁用、锁定、密码过期等 AD 子码转换为 应用层认证失败原因,不向领域层泄露 LDAP/Spring 异常。 搜索排除 computer 对象,只接受唯一用户条目和合法 objectGUID/sAMAccountName。 - 邮箱作为目录属性展示,不声称 `email_verified=true`。 ## 启用 `iam.ad.enabled=true` 时才装配登录 Controller 与浏览器安全链(含 formLogin)。 未启用时入口由兜底安全链拒绝,匿名 GET 返回 401;不注册密码处理端点。 正常 JVM 构建: ```sh IAM_DOCKER_USE_SUDO=1 scripts/gradle-in-docker test bootJar ``` Gradle 与 npm 缓存默认持久挂载,可用 `IAM_GRADLE_CACHE`、`IAM_NPM_CACHE` 指定目录。 日常迭代不必每轮跑 Native;新增 AD/JNDI/TLS 路径的原生验收留到阶段性验证。 运行时配置(非秘密): ```yaml iam: ad: enabled: true url: ldaps://dc1.ad.ddupan.top:636 domain: ad.ddupan.top base-dn: DC=ad,DC=ddupan,DC=top server: port: 18082 ssl: enabled: true certificate: file:/run/iam/browser.crt certificate-private-key: file:/run/iam/browser.key servlet: session: cookie: secure: true same-site: lax ``` 证书与私钥使用外部受管文件,不加入仓库。开发验收可使用本机的 Tailscale HTTPS 域名 及相应证书;用匹配证书的主机名打开,不能用 LAN IP 替代域名后跳过警告。 部署与证书自动续期归 homelab-infra,此轮本地运行不是生产部署。 LDAPS 使用默认 JSSE 信任库并执行服务端域名校验。Samba 当前证书由 OpenBao 内部 CA 签发,需把**公开 CA** 加入运行用 truststore,并通过 JVM 参数指定: ```sh java -Djavax.net.ssl.trustStore=/run/iam/truststore \ -Djavax.net.ssl.trustStorePassword=changeit -jar iam-login.jar ``` 这里 truststore 仅含公开信任锚,口令不是目录密码。保留所需公共根证书;不得禁用 LDAP endpoint identification 或用信任所有证书的 socket factory。连接/读取超时为 3/5 秒。 AD 根范围查询可能返回 DomainDnsZones/ForestDnsZones 等分区 referral;配置为 ignore, 由仓储的 LdapTemplate 忽略 partial result,不使用 throw 打断用户查询,也不 follow 转发用户凭据。 浏览器安全链通过 Spring Security `redirectToHttps` 将明文请求重定向至 HTTPS, 重定向前不执行密码验证或退出。默认不信任转发头。若以后由代理终结 TLS, 必须配合仅受信代理可达的后端网络和转发头配置,不能公开一个信任任意 forwarded header 的 HTTP 端口。当前开发验收由应用直接终结 TLS。 ## 状态与操作 React 只渲染登录页面与待 MFA 页面,原生表单 POST 由 Spring Security `formLogin` 接收。`DirectoryAuthenticationProvider` 调用目录用例并返回不含密码的 principal 和 带签发时间的 `FACTOR_PASSWORD`;目录组只保留在身份快照中,不映射为本服务权限。 框架负责 CSRF、成功/失败跳转、SecurityContext 持久化、session ID 轮换以及 POST logout。 `/signin/restart` 是框架 logout 地址;不再维护 LoginTransaction 或另一份浏览器认证状态。 待 MFA 页通过框架的 `validDuration` 要求密码因素在十分钟内完成,过期后需要重新认证。 这不是完整登录会话的过期策略。MFA 与 Hydra 尚未实现,其余应用入口当前拒绝所有访问。 `/actuator/**` 使用独立无状态 Basic 安全链,人类密码因素不能用于读取监控端点, 监控账号也不能借 Basic 进入人类登录流程。 移除原来事务内的两秒提交间隔;它不是有效的账号/IP 限流。此 PoC 仍仅供受控 LAN/Tailscale 验收,生产发布前需完善入口限流、审计、MFA、Hydra challenge 和恢复策略。 页面禁止缓存,内联 JSON 转义 HTML 结束标记。错误页面只显示统一消息。 基础存活检查使用 `/actuator/health/liveness`。Boot 自动配置的 LDAP 健康项并未连接这里 按用户 bind 创建的仓储连接,不能把该项当作此认证路径的可用性验证。 ## 本轮验证边界 隔离测试使用真实 TLS、LDAP bind 和搜索,校验正确/错误密码、未知账号、AD 账号状态 子码、GUID 字节序、组名、错误 TLS 主机名、CSRF、HTTPS、会话轮换、因素过期和密码成功后 仍不能访问受保护应用入口。UnboundID 的 UPN bind 与 AD 子码由测试拦截器模拟,不能替代 Samba AD。 测试证书、私钥与账号全为虚构夹具,不用于实际部署。 2026-09-27:Spring Security 重构通过 18 项 JVM 测试和 `bootJar` 构建,覆盖 密码因素的保存与有效期、会话轮换、退出、未完成 MFA 的访问限制,以及监控安全链隔离。 此前 JVM 使用受信 CA 完成 Samba AD RootDSE 查询。 浏览器已检查登录表单渲染、真实 CSRF 原生 POST 和失败后清空密码;只使用在访问 AD 前 即拒绝的合成外域用户名,不尝试猜测人类密码。浏览器回归共 1 项通过,包含移动端布局。 复现:`IAM_AD_URL=https://验收域名:端口 npm --prefix frontend run test:browser -- ad-login.spec.ts`。 重构前,维护者已在 HTTPS 页面完成真实密码验证,成功到达待 MFA 页面,并反馈目录标识、邮箱与 六个直接所属组的查询结果。该验收覆盖 Samba AD 第一因素与属性读取,不表示 MFA、 嵌套组/主组等价性或 Hydra 登录已完成。Spring Security 重构后的真实人类复验仍待反馈。 不在聊天、命令行或日志中传递人类密码。 新增 AD 路径尚未进行 Native 测试,不能复用旧 UI 原型的 Native 结论。