将登录认证与会话管理交还 Spring Security

This commit is contained in:
2026-09-27 17:34:07 +00:00
parent a4b99d54f3
commit bccb331d8b
19 changed files with 347 additions and 400 deletions
+3 -2
View File
@@ -4,11 +4,12 @@
- 默认中文维护项目文档、commit、issue 和 PR;代码与上游 API 名称保留英文。 - 默认中文维护项目文档、commit、issue 和 PR;代码与上游 API 名称保留英文。
- 项目使用 Java 与 Spring;Native 是交付约束,不是可选优化,不引入 Kotlin。 - 项目使用 Java 与 Spring;Native 是交付约束,不是可选优化,不引入 Kotlin。
- 日常变更先通过适用的 JVM 测试,不要求每轮执行耗时的 Native 编译与测试;Native 仍为交付目标,在阶段性验收或兼容性风险变化时集中验证。未执行的验证明确报告。 - 日常变更先通过适用的 JVM 测试,不要求每轮执行耗时的 Native 编译与测试;Native 仍为交付目标,在阶段性验收或兼容性风险变化时集中验证。未执行的验证明确报告。
- 按 DDD 组织 authentication 上下文:领域拥有 User/UserRepository 与认证事务规则,应用层编排,基础设施实现 AD 仓储,Web 仅处理传输与会话;领域和应用层不依赖 Spring/Servlet/LDAP。 - 按 DDD 组织 authentication 上下文:领域拥有 User/UserRepository,应用层编排目录认证与读取,基础设施实现 AD 仓储与 Security Provider,Web 仅渲染页面;领域和应用层不依赖 Spring/Servlet/LDAP。
- 用户仓储复用本次用户 bind 的连接,不新增只读服务账号;连接限于当前用例,不保留密码或连接在 HTTP session 中。 - 用户仓储复用本次用户 bind 的连接,不新增只读服务账号;连接限于当前用例,不保留密码或连接在 HTTP session 中。
- 选型演示在真实接入后删除,不将演示入口、状态机、验证码或开关保留为应用功能。 - 选型演示在真实接入后删除,不将演示入口、状态机、验证码或开关保留为应用功能。
- 只实现当前任务范围。Hydra 负责签发,首轮 AD 负责身份和组,本服务独立于 Ayatori。 - 只实现当前任务范围。Hydra 负责签发,首轮 AD 负责身份和组,本服务独立于 Ayatori。
- 不把 LDAP 密码成功当成完整 MFA 成功;所有因素绑定同一主体与认证事务。 - React 只替换 Spring Security 默认登录 UI;表单认证、SecurityContext、因素状态、会话轮换与退出交给框架,不自建登录状态机。
- LDAP 密码成功可以建立仅含密码因素的 SecurityContext,但不等于完整 MFA 或 Hydra 授权;受保护操作必须检查所需因素。
- 不提交凭据、MFA secret、生产配置秘密或包含上述内容的测试输出。 - 不提交凭据、MFA secret、生产配置秘密或包含上述内容的测试输出。
- 生产部署配置属于 homelab-infra;本仓库初始化不授权切换现役登录入口。 - 生产部署配置属于 homelab-infra;本仓库初始化不授权切换现役登录入口。
- 文档区分计划、实现、Native 实测和人类验收,禁止将代码存在当成部署证据。 - 文档区分计划、实现、Native 实测和人类验收,禁止将代码存在当成部署证据。
+11 -8
View File
@@ -75,27 +75,30 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与
当前限界上下文为 `authentication`,使用 DDD 分层,依赖向领域内部收敛: 当前限界上下文为 `authentication`,使用 DDD 分层,依赖向领域内部收敛:
```text ```text
interfaces/web → application → domain interfaces/web → infrastructure/security(principal)+ domain
infrastructure/security → application → domain
infrastructure/ad → application/port + domain infrastructure/ad → application/port + domain
configuration → 装配上述实现 configuration → 装配上述实现
``` ```
- `authentication/domain`:`User`、`UserRepository`、`LoginTransaction`;稳定主体、组成员关系、 - `authentication/domain`:`User`、`UserRepository`;稳定主体与组成员关系属于领域模型,不依赖 Spring、Servlet、LDAP 或持久化注解。
登录步骤与期限属于领域模型,不依赖 Spring、Servlet、LDAP 或持久化注解。 - `authentication/application`:`VerifyPassword` 用例,编排密码认证与同连接用户仓储查询;
- `authentication/application`:登录用例,编排密码认证、用户仓储查询与事务推进;
`port` 描述密码认证及其用户仓储会话,不暴露 `DirContext`。 `port` 描述密码认证及其用户仓储会话,不暴露 `DirContext`。
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。 - `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。 使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
- `authentication/interfaces/web`:HTTP、CSRF、会话存取和页面数据转换;不定义认证状态规则。 - `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。 - `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。 - `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
测试分别覆盖领域规则、应用用例、AD 仓储和 Web 行为,LDAP 夹具集中在测试 `support` 包。 测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。
界面采用 React + Vite,Spring 在 HTML 中内联当前步骤上下文,浏览器原生表单 POST, 界面采用 React + Vite,Spring 在 HTML 中内联当前步骤上下文,浏览器原生表单 POST,
由服务端决定下一页。不增加前端路由器、模板引擎或 Node 运行服务。 表单由 Spring Security `formLogin` 处理,框架维护因素、SecurityContext、会话轮换和退出。
React 只替换默认登录 UI,不增加前端路由器、模板引擎或 Node 运行服务。
## AD 第一因素接入 ## AD 第一因素接入
真实入口为 `/signin`,默认关闭且要求 HTTPS。密码验证通过后显示 AD 身份与直接所属组, 真实入口为 `/signin`,默认关闭且要求 HTTPS。密码验证通过后显示 AD 身份与直接所属组,
停在等待 MFA 状态,不建立完整登录身份或接受 Hydra challenge。 保存仅含 `FACTOR_PASSWORD` 的认证结果,停在等待 MFA 状态。待 MFA 页要求十分钟内的密码因素;
其他应用入口暂时全部拒绝,不能凭密码因素接受 Hydra challenge。监控 Basic 认证使用独立无状态安全链。
配置、组语义、HTTPS 与验收边界见 [AD 接入](docs/ad-login.md)。 配置、组语义、HTTPS 与验收边界见 [AD 接入](docs/ad-login.md)。
+23 -15
View File
@@ -4,14 +4,15 @@
随后由 Spring Data LDAP 仓储复用这条已认证连接查询用户与组。 随后由 Spring Data LDAP 仓储复用这条已认证连接查询用户与组。
不使用额外目录服务账号,不写入 AD,不复制 Authelia 的绑定密码。 不使用额外目录服务账号,不写入 AD,不复制 Authelia 的绑定密码。
领域仓储接口为 `UserRepository`,`User` 与 `LoginTransaction` 为领域模型。应用层以 领域仓储接口为 `UserRepository`,`User` 为领域模型。`VerifyPassword` 应用用例以
try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRepository` 通过 try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRepository` 通过
`SimpleLdapRepository<AdUserEntry>`、`LdapTemplate`、ODM 实现读取与转换,关闭后不可继续查询。 `SimpleLdapRepository<AdUserEntry>`、`LdapTemplate`、ODM 实现读取与转换,关闭后不可继续查询。
`AdUserEntry` 的 LDAP 注解不会进入领域对象。 `AdUserEntry` 的 LDAP 注解不会进入领域对象。
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。 成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
**这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** 不创建 Spring Security **这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** Spring Security 保存
认证上下文,不调用 Hydra,不替换现役 Go/Authelia/Gitea 登录链路。 仅含 `FACTOR_PASSWORD` 的认证结果;其余应用请求使用 `denyAll`,不调用 Hydra,
不替换现役 Go/Authelia/Gitea 登录链路。
## 目录和组语义 ## 目录和组语义
@@ -82,13 +83,20 @@ AD 根范围查询可能返回 DomainDnsZones/ForestDnsZones 等分区 referral
## 状态与操作 ## 状态与操作
原生表单 POST 带 Spring CSRF token;服务端决定下一页。密码成功后轮换 session ID, React 只渲染登录页面与待 MFA 页面,原生表单 POST 由 Spring Security `formLogin`
只保存目录身份快照和十分钟期限,不保存密码;重启或“退出并重新验证”清除事务。 接收。`DirectoryAuthenticationProvider` 调用目录用例并返回不含密码的 principal 和
页面禁止缓存,内联 JSON 转义 HTML 结束标记。失败页面不披露目录内部异常。 带签发时间的 `FACTOR_PASSWORD`;目录组只保留在身份快照中,不映射为本服务权限。
框架负责 CSRF、成功/失败跳转、SecurityContext 持久化、session ID 轮换以及 POST logout。
`/signin/restart` 是框架 logout 地址;不再维护 LoginTransaction 或另一份浏览器认证状态。
当前每个 session 只保留一份事务,多标签页会共享状态。两秒提交间隔仅用于同事务的 待 MFA 页通过框架的 `validDuration` 要求密码因素在十分钟内完成,过期后需要重新认证。
重复提交,不是账号/IP 限流;此 PoC 仅供受控 LAN/Tailscale 验收,生产发布前仍需完善 这不是完整登录会话的过期策略。MFA 与 Hydra 尚未实现,其余应用入口当前拒绝所有访问。
入口限流、审计、MFA、Hydra 事务和恢复策略。 `/actuator/**` 使用独立无状态 Basic 安全链,人类密码因素不能用于读取监控端点,
监控账号也不能借 Basic 进入人类登录流程。
移除原来事务内的两秒提交间隔;它不是有效的账号/IP 限流。此 PoC 仍仅供受控
LAN/Tailscale 验收,生产发布前需完善入口限流、审计、MFA、Hydra challenge 和恢复策略。
页面禁止缓存,内联 JSON 转义 HTML 结束标记。错误页面只显示统一消息。
基础存活检查使用 `/actuator/health/liveness`。Boot 自动配置的 LDAP 健康项并未连接这里 基础存活检查使用 `/actuator/health/liveness`。Boot 自动配置的 LDAP 健康项并未连接这里
按用户 bind 创建的仓储连接,不能把该项当作此认证路径的可用性验证。 按用户 bind 创建的仓储连接,不能把该项当作此认证路径的可用性验证。
@@ -96,18 +104,18 @@ AD 根范围查询可能返回 DomainDnsZones/ForestDnsZones 等分区 referral
## 本轮验证边界 ## 本轮验证边界
隔离测试使用真实 TLS、LDAP bind 和搜索,校验正确/错误密码、未知账号、AD 账号状态 隔离测试使用真实 TLS、LDAP bind 和搜索,校验正确/错误密码、未知账号、AD 账号状态
子码、GUID 字节序、组名、错误 TLS 主机名、CSRF、HTTPS、会话轮换、超时和密码成功后 子码、GUID 字节序、组名、错误 TLS 主机名、CSRF、HTTPS、会话轮换、因素过期和密码成功后
仍未完整认证。UnboundID 的 UPN bind 与 AD 子码由测试拦截器模拟,不能替代 Samba AD。 仍不能访问受保护应用入口。UnboundID 的 UPN bind 与 AD 子码由测试拦截器模拟,不能替代 Samba AD。
测试证书、私钥与账号全为虚构夹具,不用于实际部署。 测试证书、私钥与账号全为虚构夹具,不用于实际部署。
2026-09-25:DDD/Spring Data LDAP 版本通过 20 项 JVM 测试和 `bootJar` 构建, 2026-09-27:Spring Security 重构通过 18 项 JVM 测试和 `bootJar` 构建,覆盖
包含同一次 bind 连接完成仓储查询、用例结束关闭连接、领域规则及监控集成。 密码因素的保存与有效期、会话轮换、退出、未完成 MFA 的访问限制,以及监控安全链隔离。
开发 HTTPS 实例已更新为该版本。此前 JVM 使用受信 CA 完成 Samba AD RootDSE 查询。 此前 JVM 使用受信 CA 完成 Samba AD RootDSE 查询。
浏览器已检查登录表单渲染、真实 CSRF 原生 POST 和失败后清空密码;只使用在访问 AD 前 浏览器已检查登录表单渲染、真实 CSRF 原生 POST 和失败后清空密码;只使用在访问 AD 前
即拒绝的合成外域用户名,不尝试猜测人类密码。浏览器回归共 1 项通过,包含移动端布局。 即拒绝的合成外域用户名,不尝试猜测人类密码。浏览器回归共 1 项通过,包含移动端布局。
复现:`IAM_AD_URL=https://验收域名:端口 npm --prefix frontend run test:browser -- ad-login.spec.ts`。 复现:`IAM_AD_URL=https://验收域名:端口 npm --prefix frontend run test:browser -- ad-login.spec.ts`。
重构前,维护者已在 HTTPS 页面完成真实密码验证,成功到达待 MFA 页面,并反馈目录标识、邮箱与 重构前,维护者已在 HTTPS 页面完成真实密码验证,成功到达待 MFA 页面,并反馈目录标识、邮箱与
六个直接所属组的查询结果。该验收覆盖 Samba AD 第一因素与属性读取,不表示 MFA、 六个直接所属组的查询结果。该验收覆盖 Samba AD 第一因素与属性读取,不表示 MFA、
嵌套组/主组等价性或 Hydra 登录已完成。Spring Data LDAP 重构后的真实人类复验仍待反馈。 嵌套组/主组等价性或 Hydra 登录已完成。Spring Security 重构后的真实人类复验仍待反馈。
不在聊天、命令行或日志中传递人类密码。 不在聊天、命令行或日志中传递人类密码。
新增 AD 路径尚未进行 Native 测试,不能复用旧 UI 原型的 Native 结论。 新增 AD 路径尚未进行 Native 测试,不能复用旧 UI 原型的 Native 结论。
@@ -1,50 +0,0 @@
package top.ddupan.iam.login.authentication.application;
import java.time.Clock;
import java.util.UUID;
import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator;
import top.ddupan.iam.login.authentication.domain.UserRepository;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.domain.LoginTransaction;
/** Coordinates the first-factor use case. HTTP/session/LDAP details stay in adapters. */
public final class SignInService {
public enum PasswordResult { ACCEPTED, REJECTED, EXPIRED, WRONG_STEP, RETRY_LATER }
private final PasswordAuthenticator authenticator;
private final Clock clock;
private final boolean enabled;
public SignInService(PasswordAuthenticator authenticator, Clock clock, boolean enabled) {
this.authenticator = authenticator;
this.clock = clock;
this.enabled = enabled;
}
public boolean enabled() { return enabled; }
public LoginTransaction start() { return LoginTransaction.start(UUID.randomUUID(), clock.instant()); }
public boolean expired(LoginTransaction transaction) { return transaction.expiredAt(clock.instant()); }
public PasswordResult submitPassword(LoginTransaction transaction, String username, String password) {
if (!enabled) throw new IllegalStateException("Human sign-in is disabled");
var attempt = transaction.beginPasswordAttempt(clock.instant());
if (attempt != LoginTransaction.Attempt.ALLOWED) {
return switch (attempt) {
case EXPIRED -> PasswordResult.EXPIRED;
case WRONG_STEP -> PasswordResult.WRONG_STEP;
case RETRY_LATER -> PasswordResult.RETRY_LATER;
case ALLOWED -> throw new IllegalStateException("Unexpected attempt result");
};
}
try (var session = authenticator.authenticate(username, password)) {
var identity = session.users().findByLoginName(session.loginName());
if (identity.isEmpty()) return PasswordResult.REJECTED;
// A slow directory response must not revive an expired transaction.
var verifiedAt = clock.instant();
if (transaction.expiredAt(verifiedAt)) return PasswordResult.EXPIRED;
transaction.passwordVerified(identity.orElseThrow(), verifiedAt);
return PasswordResult.ACCEPTED;
} catch (PasswordVerificationException | UserRepository.AccessFailure ex) {
return PasswordResult.REJECTED;
}
}
}
@@ -0,0 +1,22 @@
package top.ddupan.iam.login.authentication.application;
import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException.Reason;
import top.ddupan.iam.login.authentication.domain.User;
/** Resolves the user through the same authenticated directory connection. */
public final class VerifyPassword {
private final PasswordAuthenticator authenticator;
public VerifyPassword(PasswordAuthenticator authenticator) {
this.authenticator = authenticator;
}
public User verify(String username, String password) {
try (var session = authenticator.authenticate(username, password)) {
return session.users().findByLoginName(session.loginName())
.orElseThrow(() -> new PasswordVerificationException(Reason.REJECTED));
}
}
}
@@ -1,56 +0,0 @@
package top.ddupan.iam.login.authentication.domain;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Owns first-factor ordering, lifetime and the identity to which further factors must bind. */
public final class LoginTransaction {
private static final Duration LIFETIME = Duration.ofMinutes(10);
private static final Duration ATTEMPT_INTERVAL = Duration.ofSeconds(2);
public enum Step { PASSWORD_REQUIRED, MFA_REQUIRED }
public enum Attempt { ALLOWED, EXPIRED, WRONG_STEP, RETRY_LATER }
private final UUID id;
private Step step = Step.PASSWORD_REQUIRED;
private Instant expiresAt;
private Instant retryAfter = Instant.MIN;
private User identity;
private LoginTransaction(UUID id, Instant now) {
this.id = Objects.requireNonNull(id);
this.expiresAt = now.plus(LIFETIME);
}
public static LoginTransaction start(UUID id, Instant now) {
return new LoginTransaction(id, now);
}
public Attempt beginPasswordAttempt(Instant now) {
if (expiredAt(now)) return Attempt.EXPIRED;
if (step != Step.PASSWORD_REQUIRED) return Attempt.WRONG_STEP;
if (now.isBefore(retryAfter)) return Attempt.RETRY_LATER;
retryAfter = now.plus(ATTEMPT_INTERVAL);
return Attempt.ALLOWED;
}
public void passwordVerified(User identity, Instant now) {
if (expiredAt(now) || step != Step.PASSWORD_REQUIRED) {
throw new IllegalStateException("Password verification is not allowed in this transaction state");
}
this.identity = Objects.requireNonNull(identity);
this.step = Step.MFA_REQUIRED;
this.expiresAt = now.plus(LIFETIME);
}
public UUID id() { return id; }
public Step step() { return step; }
public boolean expiredAt(Instant now) { return !now.isBefore(expiresAt); }
public User identity() {
if (identity == null) throw new IllegalStateException("No verified identity yet");
return identity;
}
}
@@ -0,0 +1,44 @@
package top.ddupan.iam.login.authentication.infrastructure.security;
import java.util.List;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import top.ddupan.iam.login.authentication.application.VerifyPassword;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.domain.UserRepository;
/** Bridges directory authentication into Spring Security's form-login lifecycle. */
public final class DirectoryAuthenticationProvider implements AuthenticationProvider {
private final VerifyPassword passwords;
public DirectoryAuthenticationProvider(VerifyPassword passwords) {
this.passwords = passwords;
}
@Override
public Authentication authenticate(Authentication request) {
try {
var user = passwords.verify(request.getName(),
request.getCredentials() instanceof String password ? password : null);
// Directory groups remain profile data, not local application authorities.
return UsernamePasswordAuthenticationToken.authenticated(new DirectoryPrincipal(user), null,
List.of(FactorGrantedAuthority.fromAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)));
} catch (PasswordVerificationException ex) {
if (ex.reason() == PasswordVerificationException.Reason.UNAVAILABLE) {
throw new InternalAuthenticationServiceException("Directory unavailable");
}
throw new BadCredentialsException("Unable to verify credentials");
} catch (UserRepository.AccessFailure ex) {
throw new InternalAuthenticationServiceException("Directory unavailable");
}
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.equals(authentication);
}
}
@@ -0,0 +1,12 @@
package top.ddupan.iam.login.authentication.infrastructure.security;
import org.springframework.security.core.AuthenticatedPrincipal;
import top.ddupan.iam.login.authentication.domain.User;
/** An immutable directory snapshot; never contains credentials or connections. */
public record DirectoryPrincipal(User user) implements AuthenticatedPrincipal {
@Override
public String getName() {
return user.id().authority() + ":" + user.id().value();
}
}
@@ -1,12 +0,0 @@
package top.ddupan.iam.login.authentication.interfaces.web;
import top.ddupan.iam.login.authentication.domain.LoginTransaction;
/** HTTP-session storage plus presentation feedback. Authentication rules live in the aggregate. */
final class BrowserSignInState {
final LoginTransaction transaction;
String username = "";
String error = "";
BrowserSignInState(LoginTransaction transaction) { this.transaction = transaction; }
}
@@ -27,6 +27,6 @@ public class PageRenderer {
.replace(">", "\\u003e").replace("&", "\\u0026") .replace(">", "\\u003e").replace("&", "\\u0026")
.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029"); .replace("\u2028", "\\u2028").replace("\u2029", "\\u2029");
return ResponseEntity.ok().header("Cache-Control", "no-store") return ResponseEntity.ok().header("Cache-Control", "no-store")
.contentType(MediaType.TEXT_HTML).body(shell.replace(SLOT, safe)); .contentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8)).body(shell.replace(SLOT, safe));
} }
} }
@@ -0,0 +1,35 @@
package top.ddupan.iam.login.authentication.interfaces.web;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
/** Rejects disabled or plaintext sign-in before any credentials reach authentication. */
public final class SignInAvailabilityFilter extends OncePerRequestFilter {
private final boolean enabled;
public SignInAvailabilityFilter(boolean enabled) {
this.enabled = enabled;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
if (path.isEmpty()) path = request.getRequestURI().substring(request.getContextPath().length());
return !path.equals("/signin") && !path.startsWith("/signin/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (!enabled || !request.isSecure()) {
response.setHeader("Cache-Control", "no-store");
response.setStatus(enabled ? 426 : 404);
return;
}
chain.doFilter(request, response);
}
}
@@ -1,118 +1,44 @@
package top.ddupan.iam.login.authentication.interfaces.web; package top.ddupan.iam.login.authentication.interfaces.web;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.util.Map; import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import top.ddupan.iam.login.authentication.application.SignInService;
import top.ddupan.iam.login.authentication.domain.LoginTransaction.Step;
import top.ddupan.iam.login.authentication.domain.User.GroupMembership; import top.ddupan.iam.login.authentication.domain.User.GroupMembership;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
/** Translates browser requests and use-case outcomes; no directory or authentication policy here. */ /** Renders Spring Security's login pages; form processing belongs to the security filters. */
@RestController @RestController
public class SignInController { public class SignInController {
static final String STATE = SignInController.class.getName() + ".state";
private final SignInService signIn;
private final PageRenderer renderer; private final PageRenderer renderer;
public SignInController(SignInService signIn, PageRenderer renderer) { public SignInController(PageRenderer renderer) {
this.signIn = signIn;
this.renderer = renderer; this.renderer = renderer;
} }
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE) @GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> page(HttpServletRequest request, CsrfToken csrf) { ResponseEntity<String> page(@RequestParam(required = false) String error, CsrfToken csrf) {
requireAvailable(request); return renderer.render(Map.of("step", "password", "name", "",
var session = request.getSession(); "error", error == null ? "" : "无法验证账号,请检查凭据与账号状态,或稍后重试。",
synchronized (session) { "action", "/signin/password", "csrf", csrf(csrf)));
var state = state(session);
if (state.transaction.step() == Step.MFA_REQUIRED) return redirect("/signin/mfa");
return renderer.render(Map.of("step", "password", "name", state.username,
"error", state.error, "action", "/signin/password", "csrf", csrf(csrf)));
}
}
@PostMapping("/signin/password")
ResponseEntity<String> password(HttpServletRequest request,
@RequestParam(defaultValue = "") String username,
@RequestParam(defaultValue = "") String password) {
requireAvailable(request);
var session = request.getSession(false);
if (session == null) throw new ResponseStatusException(HttpStatus.CONFLICT);
synchronized (session) {
var state = (BrowserSignInState) session.getAttribute(STATE);
if (state == null) return redirect("/signin");
state.username = username.length() <= 256 ? username : "";
return switch (signIn.submitPassword(state.transaction, username, password)) {
case ACCEPTED -> {
request.changeSessionId();
state.error = "";
yield redirect("/signin/mfa");
}
case REJECTED -> {
state.error = "无法验证账号,请检查凭据与账号状态,或稍后重试。";
yield redirect("/signin");
}
case EXPIRED, WRONG_STEP -> redirect("/signin");
case RETRY_LATER -> throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS);
};
}
} }
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE) @GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> pending(HttpServletRequest request, CsrfToken csrf) { ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
requireAvailable(request); var user = principal.user();
var session = request.getSession(false); return renderer.render(Map.of("step", "mfa-pending", "name", user.displayName(),
if (session == null) return redirect("/signin"); "error", "", "action", "/signin/restart", "csrf", csrf(csrf),
synchronized (session) { "identity", Map.of("username", user.username(), "subjectId", user.id().value(),
var state = state(session); "email", user.email(),
if (state.transaction.step() != Step.MFA_REQUIRED) return redirect("/signin"); "groups", user.memberships().stream().map(GroupMembership::name).toList(),
var user = state.transaction.identity(); "groupDns", user.memberships().stream().map(GroupMembership::externalId).toList())));
return renderer.render(Map.of("step", "mfa-pending", "name", user.displayName(),
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
"email", user.email(),
"groups", user.memberships().stream().map(GroupMembership::name).toList(),
"groupDns", user.memberships().stream().map(GroupMembership::externalId).toList())));
}
}
@PostMapping("/signin/restart")
ResponseEntity<String> restart(HttpServletRequest request) {
requireAvailable(request);
var session = request.getSession(false);
if (session != null) session.invalidate();
return redirect("/signin");
}
private void requireAvailable(HttpServletRequest request) {
if (!signIn.enabled()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
if (!request.isSecure()) throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED, "HTTPS required");
}
private BrowserSignInState state(HttpSession session) {
var state = (BrowserSignInState) session.getAttribute(STATE);
if (state == null || signIn.expired(state.transaction)) {
state = new BrowserSignInState(signIn.start());
session.setAttribute(STATE, state);
}
return state;
} }
private static Map<String, String> csrf(CsrfToken token) { private static Map<String, String> csrf(CsrfToken token) {
return Map.of("name", token.getParameterName(), "value", token.getToken()); return Map.of("name", token.getParameterName(), "value", token.getToken());
} }
private static ResponseEntity<String> redirect(String location) {
return ResponseEntity.status(HttpStatus.SEE_OTHER).header("Location", location)
.header("Cache-Control", "no-store").build();
}
} }
@@ -1,6 +1,5 @@
package top.ddupan.iam.login.configuration; package top.ddupan.iam.login.configuration;
import java.time.Clock;
import javax.naming.directory.DirContext; import javax.naming.directory.DirContext;
import javax.naming.ldap.LdapContext; import javax.naming.ldap.LdapContext;
import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHints;
@@ -11,9 +10,8 @@ import org.springframework.ldap.core.DirContextProxy;
import top.ddupan.iam.login.authentication.infrastructure.ad.AdUserEntry; import top.ddupan.iam.login.authentication.infrastructure.ad.AdUserEntry;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import top.ddupan.iam.login.authentication.application.SignInService; import top.ddupan.iam.login.authentication.application.VerifyPassword;
import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator; import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator;
import top.ddupan.iam.login.authentication.infrastructure.ad.AdProperties;
/** Composition root: dependencies point inward, framework wiring stays outside the model. */ /** Composition root: dependencies point inward, framework wiring stays outside the model. */
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@@ -21,8 +19,8 @@ import top.ddupan.iam.login.authentication.infrastructure.ad.AdProperties;
@ImportRuntimeHints(AuthenticationConfiguration.DirectoryHints.class) @ImportRuntimeHints(AuthenticationConfiguration.DirectoryHints.class)
class AuthenticationConfiguration { class AuthenticationConfiguration {
@Bean @Bean
SignInService signInService(PasswordAuthenticator authenticator, AdProperties properties) { VerifyPassword verifyPassword(PasswordAuthenticator authenticator) {
return new SignInService(authenticator, Clock.systemUTC(), properties.enabled()); return new VerifyPassword(authenticator);
} }
static class DirectoryHints implements RuntimeHintsRegistrar { static class DirectoryHints implements RuntimeHintsRegistrar {
@Override @Override
@@ -1,19 +1,68 @@
package top.ddupan.iam.login.configuration; package top.ddupan.iam.login.configuration;
import java.time.Duration;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authorization.AuthorizationManagerFactories;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import top.ddupan.iam.login.authentication.application.VerifyPassword;
import top.ddupan.iam.login.authentication.infrastructure.ad.AdProperties;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryAuthenticationProvider;
import top.ddupan.iam.login.authentication.interfaces.web.SignInAvailabilityFilter;
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@EnableMultiFactorAuthentication(authorities = {})
class SecurityConfiguration { class SecurityConfiguration {
// Operational Basic authentication is isolated from human first-factor authentication.
@Bean @Bean
SecurityFilterChain security(HttpSecurity http) throws Exception { @Order(1)
return http.authorizeHttpRequests(auth -> auth SecurityFilterChain management(HttpSecurity http) throws Exception {
.requestMatchers("/error", "/signin", "/signin/**", "/assets/**", "/actuator/health/**").permitAll() return http.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())
.securityContext(context -> context.securityContextRepository(
new org.springframework.security.web.context.NullSecurityContextRepository()))
.sessionManagement(session -> session.sessionCreationPolicy(
org.springframework.security.config.http.SessionCreationPolicy.STATELESS))
.httpBasic(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults())
.build();
}
@Bean
@Order(2)
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords, AdProperties ad) throws Exception {
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
.build();
return http
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
.addFilterBefore(new SignInAvailabilityFilter(ad.enabled()), CsrfFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
// No complete MFA or Hydra acceptance exists yet. Fail closed until those are implemented.
.anyRequest().denyAll())
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
PathPatternRequestMatcher.withDefaults().matcher("/signin/**"))
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE))
.requestCache(cache -> cache.disable())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives( .headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"))) + "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")))
@@ -47,6 +47,9 @@ class IamLoginApplicationTests {
@Test @Test
void signInIsDisabledUntilDirectoryIsConfigured() throws Exception { void signInIsDisabledUntilDirectoryIsConfigured() throws Exception {
mvc.perform(get("/signin").secure(true)).andExpect(status().isNotFound()); mvc.perform(get("/signin").secure(true)).andExpect(status().isNotFound());
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/signin/password")
.secure(true).with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(status().isNotFound());
} }
@Test @Test
@@ -1,107 +0,0 @@
package top.ddupan.iam.login.authentication.application;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import top.ddupan.iam.login.authentication.application.port.AuthenticatedUserSession;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.domain.LoginTransaction;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authentication.domain.UserRepository;
import static org.assertj.core.api.Assertions.*;
class SignInServiceTests {
static final User ALICE = new User(new User.UserId("directory", "alice-id"), "alice", "Alice", "", List.of());
@Test
void verifiesPasswordThenReadsTheBoundUserAndClosesTheRepositoryScope() {
var scope = new Scope(name -> {
assertThat(name).isEqualTo("alice@directory");
return Optional.of(ALICE);
});
var service = new SignInService((username, password) -> scope, new TestClock(), true);
var transaction = service.start();
assertThat(service.submitPassword(transaction, "alice", "secret")).isEqualTo(SignInService.PasswordResult.ACCEPTED);
assertThat(scope.closed).isTrue();
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.MFA_REQUIRED);
assertThat(transaction.identity()).isEqualTo(ALICE);
}
@Test
void failedAuthenticationDoesNotAdvanceTheTransaction() {
var service = new SignInService((username, password) -> {
throw new PasswordVerificationException(PasswordVerificationException.Reason.REJECTED);
}, new TestClock(), true);
var transaction = service.start();
assertThat(service.submitPassword(transaction, "alice", "wrong")).isEqualTo(SignInService.PasswordResult.REJECTED);
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.PASSWORD_REQUIRED);
}
@Test
void missingUserDoesNotAuthenticateAndStillClosesTheScope() {
var scope = new Scope(name -> Optional.empty());
var service = new SignInService((username, password) -> scope, new TestClock(), true);
var transaction = service.start();
assertThat(service.submitPassword(transaction, "alice", "secret")).isEqualTo(SignInService.PasswordResult.REJECTED);
assertThat(scope.closed).isTrue();
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.PASSWORD_REQUIRED);
}
@Test
void expiredAndRepeatedAttemptsDoNotCallTheDirectory() {
var calls = new AtomicInteger();
var clock = new TestClock();
var service = new SignInService((username, password) -> {
calls.incrementAndGet();
throw new PasswordVerificationException(PasswordVerificationException.Reason.REJECTED);
}, clock, true);
var transaction = service.start();
service.submitPassword(transaction, "alice", "wrong");
assertThat(service.submitPassword(transaction, "alice", "wrong")).isEqualTo(SignInService.PasswordResult.RETRY_LATER);
clock.now = clock.now.plusSeconds(600);
assertThat(service.submitPassword(transaction, "alice", "wrong")).isEqualTo(SignInService.PasswordResult.EXPIRED);
assertThat(calls.get()).isEqualTo(1);
}
@Test
void aSlowRepositoryCannotReviveAnExpiredTransaction() {
var clock = new TestClock();
var scope = new Scope(name -> { clock.now = clock.now.plusSeconds(600); return Optional.of(ALICE); });
var service = new SignInService((username, password) -> scope, clock, true);
var transaction = service.start();
assertThat(service.submitPassword(transaction, "alice", "secret")).isEqualTo(SignInService.PasswordResult.EXPIRED);
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.PASSWORD_REQUIRED);
assertThat(scope.closed).isTrue();
}
@Test
void repositoryFailureClosesTheScopeWithoutPromotingIdentity() {
var scope = new Scope(name -> { throw new UserRepository.AccessFailure(); });
var service = new SignInService((username, password) -> scope, new TestClock(), true);
var transaction = service.start();
assertThat(service.submitPassword(transaction, "alice", "secret")).isEqualTo(SignInService.PasswordResult.REJECTED);
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.PASSWORD_REQUIRED);
assertThat(scope.closed).isTrue();
}
private static final class Scope implements AuthenticatedUserSession {
private final UserRepository users;
boolean closed;
Scope(UserRepository users) { this.users = users; }
@Override public String loginName() { return "alice@directory"; }
@Override public UserRepository users() { return users; }
@Override public void close() { closed = true; }
}
private static final class TestClock extends Clock {
Instant now = Instant.parse("2026-01-01T00:00:00Z");
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return Clock.fixed(now, zone); }
}
}
@@ -0,0 +1,49 @@
package top.ddupan.iam.login.authentication.application;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import top.ddupan.iam.login.authentication.application.port.AuthenticatedUserSession;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authentication.domain.UserRepository;
import static org.assertj.core.api.Assertions.*;
class VerifyPasswordTests {
static final User ALICE = new User(new User.UserId("directory", "alice-id"), "alice", "Alice", "", List.of());
@Test
void readsTheBoundUserAndClosesTheConnection() {
var scope = new Scope(name -> {
assertThat(name).isEqualTo("alice@directory");
return Optional.of(ALICE);
});
assertThat(new VerifyPassword((username, password) -> scope).verify("alice", "secret")).isEqualTo(ALICE);
assertThat(scope.closed).isTrue();
}
@Test
void missingUserClosesTheConnectionAndRejectsAuthentication() {
var scope = new Scope(name -> Optional.empty());
assertThatThrownBy(() -> new VerifyPassword((u, p) -> scope).verify("alice", "secret"))
.isInstanceOf(PasswordVerificationException.class);
assertThat(scope.closed).isTrue();
}
@Test
void repositoryFailureStillClosesTheConnection() {
var scope = new Scope(name -> { throw new UserRepository.AccessFailure(); });
assertThatThrownBy(() -> new VerifyPassword((u, p) -> scope).verify("alice", "secret"))
.isInstanceOf(UserRepository.AccessFailure.class);
assertThat(scope.closed).isTrue();
}
private static final class Scope implements AuthenticatedUserSession {
private final UserRepository users;
boolean closed;
Scope(UserRepository users) { this.users = users; }
@Override public String loginName() { return "alice@directory"; }
@Override public UserRepository users() { return users; }
@Override public void close() { closed = true; }
}
}
@@ -1,44 +0,0 @@
package top.ddupan.iam.login.authentication.domain;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class LoginTransactionTests {
static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z");
static final User ALICE = new User(new User.UserId("directory", "alice-id"), "alice", "Alice", "", List.of());
@Test
void passwordVerificationBindsIdentityAndOnlyAdvancesToMfa() {
var transaction = LoginTransaction.start(UUID.randomUUID(), NOW);
assertThat(transaction.beginPasswordAttempt(NOW)).isEqualTo(LoginTransaction.Attempt.ALLOWED);
transaction.passwordVerified(ALICE, NOW);
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.MFA_REQUIRED);
assertThat(transaction.identity()).isEqualTo(ALICE);
assertThat(transaction.beginPasswordAttempt(NOW.plusSeconds(3))).isEqualTo(LoginTransaction.Attempt.WRONG_STEP);
var bob = new User(new User.UserId("directory", "bob-id"), "bob", "Bob", "", List.of());
assertThatThrownBy(() -> transaction.passwordVerified(bob, NOW.plusSeconds(3)))
.isInstanceOf(IllegalStateException.class);
assertThat(transaction.identity()).isEqualTo(ALICE);
}
@Test
void expiredTransactionsCannotAcceptAPasswordResult() {
var transaction = LoginTransaction.start(UUID.randomUUID(), NOW);
assertThat(transaction.beginPasswordAttempt(NOW.plusSeconds(600))).isEqualTo(LoginTransaction.Attempt.EXPIRED);
assertThatThrownBy(() -> transaction.passwordVerified(ALICE, NOW.plusSeconds(600)))
.isInstanceOf(IllegalStateException.class);
}
@Test
void attemptsAreSeparatedWithoutAdvancingAuthentication() {
var transaction = LoginTransaction.start(UUID.randomUUID(), NOW);
assertThat(transaction.beginPasswordAttempt(NOW)).isEqualTo(LoginTransaction.Attempt.ALLOWED);
assertThat(transaction.beginPasswordAttempt(NOW.plusSeconds(1))).isEqualTo(LoginTransaction.Attempt.RETRY_LATER);
assertThat(transaction.beginPasswordAttempt(NOW.plusSeconds(2))).isEqualTo(LoginTransaction.Attempt.ALLOWED);
assertThat(transaction.step()).isEqualTo(LoginTransaction.Step.PASSWORD_REQUIRED);
assertThatThrownBy(transaction::identity).isInstanceOf(IllegalStateException.class);
}
}
@@ -10,6 +10,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import java.time.Instant;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.mock.web.MockHttpSession; import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
@@ -20,8 +25,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** Real LDAPS sockets and Spring Data LDAP; AD bind/subcode semantics are simulated. */ /** Real LDAPS sockets and Spring Data LDAP; AD bind/subcode semantics are simulated. */
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test"}) @SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
"management.otlp.metrics.export.enabled=false", "spring.security.user.name=fixture-monitor", "spring.security.user.password=fixture-monitor-password"})
@AutoConfigureMockMvc @AutoConfigureMockMvc
@org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics
@ImportRuntimeHints(SignInIntegrationTests.FixtureHints.class) @ImportRuntimeHints(SignInIntegrationTests.FixtureHints.class)
class SignInIntegrationTests { class SignInIntegrationTests {
static class FixtureHints implements RuntimeHintsRegistrar { static class FixtureHints implements RuntimeHintsRegistrar {
@@ -67,9 +74,12 @@ class SignInIntegrationTests {
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
assertThat(html).contains("mfa-pending", "gitea-admins", "\\u003c/script\\u003e") assertThat(html).contains("mfa-pending", "gitea-admins", "\\u003c/script\\u003e")
.doesNotContain("fixture-password", "</script><script>attack()"); .doesNotContain("fixture-password", "</script><script>attack()");
assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull(); var authentication = ((SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT")).getAuthentication();
assertThat(authentication.isAuthenticated()).isTrue();
assertThat(authentication.getCredentials()).isNull();
assertThat(authentication.getAuthorities()).extracting("authority").containsExactly("FACTOR_PASSWORD");
mvc.perform(get("/").secure(true).session(session).accept(MediaType.APPLICATION_JSON)) mvc.perform(get("/").secure(true).session(session).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isForbidden());
} }
@@ -79,12 +89,68 @@ class SignInIntegrationTests {
mvc.perform(get("/signin").secure(true).session(session)); mvc.perform(get("/signin").secure(true).session(session));
mvc.perform(post("/signin/password").secure(true).session(session).with(csrf()) mvc.perform(post("/signin/password").secure(true).session(session).with(csrf())
.param("username", "alice").param("password", "wrong")) .param("username", "alice").param("password", "wrong"))
.andExpect(redirectedUrl("/signin")); .andExpect(redirectedUrl("/signin?error"));
var state = (BrowserSignInState) session.getAttribute(SignInController.STATE); assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
assertThat(state.transaction.step()).isEqualTo(top.ddupan.iam.login.authentication.domain.LoginTransaction.Step.PASSWORD_REQUIRED); mvc.perform(get("/signin").secure(true).param("error", "").session(session))
assertThat(state.error).isNotBlank().doesNotContain("LDAP", "wrong"); .andExpect(content().string(org.hamcrest.Matchers.containsString("无法验证账号")));
mvc.perform(post("/signin/restart").secure(true).session(session).with(csrf())) mvc.perform(post("/signin/restart").secure(true).session(session).with(csrf()))
.andExpect(redirectedUrl("/signin")); .andExpect(redirectedUrl("/signin"));
assertThat(session.isInvalid()).isTrue(); assertThat(session.isInvalid()).isTrue();
} }
@Test
void plaintextPasswordPostCannotReachDirectory() throws Exception {
int before = Directory.INSTANCE.binds.get();
mvc.perform(post("/signin/password").with(csrf()).param("username", "alice")
.param("password", "fixture-password")).andExpect(status().isUpgradeRequired());
assertThat(Directory.INSTANCE.binds.get()).isEqualTo(before);
}
@Test
void expiredPasswordFactorCannotOpenPendingPage() throws Exception {
var session = login();
var context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
var previous = context.getAuthentication();
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(previous.getPrincipal(), null,
List.of(FactorGrantedAuthority.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
.issuedAt(Instant.now().minusSeconds(601)).build())));
mvc.perform(get("/signin/mfa").secure(true).session(session))
.andExpect(status().is3xxRedirection());
}
@Test
void directorySessionCannotScrapeManagementAndLogoutClearsAuthentication() throws Exception {
var session = login();
mvc.perform(get("/actuator/prometheus").secure(true).session(session))
.andExpect(status().isUnauthorized());
mvc.perform(post("/signin/restart").session(session).with(csrf()))
.andExpect(status().isUpgradeRequired());
assertThat(session.isInvalid()).isFalse();
mvc.perform(post("/signin/restart").secure(true).session(session))
.andExpect(status().isForbidden());
mvc.perform(post("/signin/restart").secure(true).session(session).with(csrf()))
.andExpect(redirectedUrl("/signin"));
assertThat(session.isInvalid()).isTrue();
mvc.perform(get("/signin/mfa").secure(true)).andExpect(status().is3xxRedirection());
}
@Test
void managementBasicCredentialsCannotAuthenticateBrowserLogin() throws Exception {
mvc.perform(get("/actuator/prometheus").secure(true).with(
org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.httpBasic("fixture-monitor", "fixture-monitor-password")))
.andExpect(status().isOk());
mvc.perform(get("/signin/mfa").secure(true).with(
org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.httpBasic("fixture-monitor", "fixture-monitor-password")))
.andExpect(status().is3xxRedirection());
}
private MockHttpSession login() throws Exception {
var session = new MockHttpSession();
mvc.perform(post("/signin/password").secure(true).session(session).with(csrf())
.param("username", "alice").param("password", "fixture-password"))
.andExpect(redirectedUrl("/signin/mfa"));
return session;
}
} }