接入 WebAuthn 第二因素与 PostgreSQL 凭据仓储
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# iam-login
|
# iam-login
|
||||||
|
|
||||||
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为
|
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为
|
||||||
Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及等待 MFA 的浏览器页面;MFA 与 Hydra 登录链路仍待实现。
|
Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;Hydra 登录链路仍待实现。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与
|
|||||||
[本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
|
[本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
|
||||||
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
|
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
|
||||||
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP
|
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP
|
||||||
版本已通过 JVM 和浏览器回归,真实人类复验待反馈。MFA 与 Hydra 链路仍待实现。
|
版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 链路仍待实现。
|
||||||
|
|
||||||
## 领域与代码组织
|
## 领域与代码组织
|
||||||
|
|
||||||
@@ -87,6 +87,7 @@ configuration → 装配上述实现
|
|||||||
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
|
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
|
||||||
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
|
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
|
||||||
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
|
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
|
||||||
|
- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
|
||||||
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
|
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
|
||||||
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
|
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
|
||||||
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
|
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
||||||
|
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
|
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
|
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
@@ -42,6 +46,7 @@ dependencies {
|
|||||||
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||||
testImplementation 'com.unboundid:unboundid-ldapsdk'
|
testImplementation 'com.unboundid:unboundid-ldapsdk'
|
||||||
testImplementation 'org.testcontainers:testcontainers-grafana'
|
testImplementation 'org.testcontainers:testcontainers-grafana'
|
||||||
|
testImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||||
testCompileOnly 'org.projectlombok:lombok'
|
testCompileOnly 'org.projectlombok:lombok'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
@@ -83,3 +88,10 @@ tasks.named('processResources') {
|
|||||||
}
|
}
|
||||||
from('frontend/dist') { into 'ui' }
|
from('frontend/dist') { into 'ui' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit test-only entry point for browser WebAuthn ceremonies; no fixture endpoints in production.
|
||||||
|
tasks.register('webauthnBrowserFixture', JavaExec) {
|
||||||
|
dependsOn tasks.named('testClasses')
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Only the development credential database. Supply IAM_DEV_DB_PASSWORD outside Git.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: iam_login
|
||||||
|
POSTGRES_USER: iam_login
|
||||||
|
POSTGRES_PASSWORD: ${IAM_DEV_DB_PASSWORD:?Set IAM_DEV_DB_PASSWORD outside Git}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${IAM_DEV_DB_PORT:-15432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U iam_login -d iam_login"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
cpus: 1
|
||||||
|
mem_limit: 512m
|
||||||
|
volumes:
|
||||||
|
postgres:
|
||||||
+3
-2
@@ -10,8 +10,9 @@ try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRe
|
|||||||
`AdUserEntry` 的 LDAP 注解不会进入领域对象。
|
`AdUserEntry` 的 LDAP 注解不会进入领域对象。
|
||||||
|
|
||||||
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
|
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
|
||||||
**这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** Spring Security 保存
|
**密码成功本身不是完整登录成功。** Spring Security 先保存仅含 `FACTOR_PASSWORD`
|
||||||
仅含 `FACTOR_PASSWORD` 的认证结果;其余应用请求使用 `denyAll`,不调用 Hydra,
|
的认证结果;启用 [WebAuthn](webauthn.md) 后在此继续第二因素,否则停留在等待页面。
|
||||||
|
未实现的应用请求使用 `denyAll`,不调用 Hydra,
|
||||||
不替换现役 Go/Authelia/Gitea 登录链路。
|
不替换现役 Go/Authelia/Gitea 登录链路。
|
||||||
|
|
||||||
## 目录和组语义
|
## 目录和组语义
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
|
|||||||
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
|
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
|
||||||
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
|
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
|
||||||
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
|
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
|
||||||
|
- WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证;
|
||||||
|
Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。
|
||||||
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
|
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
|
||||||
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
|
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
|
||||||
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
|
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# WebAuthn 第二因素
|
||||||
|
|
||||||
|
WebAuthn 使用 Spring Security 官方过滤器、WebAuthn4J 校验和 JDBC 仓储。
|
||||||
|
PostgreSQL 保存 user handle、凭据公钥与签名计数等记录,不保存用户密码或认证器私钥。
|
||||||
|
AD 仍为用户与组权威;凭据按目录 authority + objectGUID 关联,用户名改名不会换主体。
|
||||||
|
|
||||||
|
## 登录与注册
|
||||||
|
|
||||||
|
1. `/signin` 使用原生表单 POST 验证 AD 密码,建立 `FACTOR_PASSWORD`。
|
||||||
|
2. `/signin/mfa`:没有凭据时注册 passkey;已有凭据时验证 passkey。
|
||||||
|
3. 注册只保存凭据,必须再次实际验证,才取得 `FACTOR_WEBAUTHN`。
|
||||||
|
4. `/signin/complete` 要求两种因素均在 10 分钟内有效;目前仅显示验证结果,尚不接受 Hydra challenge。
|
||||||
|
|
||||||
|
首次注册信任近期 AD 密码验证。已有凭据后的新增注册同时要求密码与 WebAuthn 因素;
|
||||||
|
本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。遗失所有 passkey
|
||||||
|
尚无自助登录途径;生产上线前需要另行确定恢复和初始注册政策,不能把数据库清空作为日常恢复方式。
|
||||||
|
注册和验证均要求认证器 user verification(例如 PIN 或生物识别)。
|
||||||
|
|
||||||
|
Spring Security 负责因素合并、会话轮换、退出和 CSRF。应用仅补目录主体与凭据所有权
|
||||||
|
限制、首次注册并发检查,以及 challenge 的 5 分钟服务端有效期和单次消费。
|
||||||
|
没有应用自建登录状态机或认证 Filter。上游 options Filter 位于授权 Filter 前,因而
|
||||||
|
这些检查在 RelyingPartyOperations 扩展点执行,不能仅靠 URL 授权规则。
|
||||||
|
|
||||||
|
## 本地数据库
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 密码从 shell 或外部权限为 0600 的 env 文件提供;不要写入版本库。
|
||||||
|
docker compose -p iam-login-dev -f compose.dev.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
必须设置 `IAM_DEV_DB_PASSWORD`。PostgreSQL 仅发布在 `127.0.0.1:15432`,可用
|
||||||
|
`IAM_DEV_DB_PORT` 调整端口;数据保存在 Compose named volume 中。
|
||||||
|
普通 `down` 不删数据,**不要使用 `down -v`**,否则会删除已注册凭据。
|
||||||
|
|
||||||
|
应用额外配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
iam:
|
||||||
|
webauthn:
|
||||||
|
enabled: true
|
||||||
|
rp-id: laptop.tail7e769.ts.net
|
||||||
|
origin: https://laptop.tail7e769.ts.net:18082
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://127.0.0.1:15432/iam_login
|
||||||
|
username: iam_login
|
||||||
|
password: ${IAM_DEV_DB_PASSWORD}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时启用并配置 [AD](ad-login.md)。RP ID 不含协议与端口,origin 必须与浏览器实际
|
||||||
|
HTTPS 入口完全一致;改变 RP 域名后旧凭据不能直接在新域名使用。
|
||||||
|
凭据 schema 由 Flyway 管理,采用 Spring Security 7.1.1 官方 PostgreSQL 表结构,
|
||||||
|
增加主体名称唯一约束和凭据所有者索引。未启用 WebAuthn 时不创建 DataSource,AD-only
|
||||||
|
路径不需要数据库。数据库不可用时 MFA 失败,不降级为仅密码通过。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
JVM 回归使用 Testcontainers PostgreSQL 与模拟 AD;不会向真实 AD 提交测试密码。
|
||||||
|
浏览器用 Chromium 虚拟认证器产生真实注册/断言签名,再交给后端校验:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/gradle-in-docker test
|
||||||
|
scripts/gradle-in-docker webauthnBrowserFixture
|
||||||
|
# 另一终端;测试夹具固定监听 localhost:18083,使用测试证书。
|
||||||
|
IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。
|
||||||
|
真实用户的 passkey 注册、认证器兼容性和 Native 路径仍需独立验收;JVM/虚拟认证器通过
|
||||||
|
不能代替真实人类或 Native 验收。生产共享 PostgreSQL 的接入留在部署阶段。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { CsrfToken } from "../page-context";
|
||||||
|
|
||||||
|
export function Passkey({ csrf, initial }: { csrf: CsrfToken; initial: "register" | "authenticate" }) {
|
||||||
|
const [step, setStep] = useState(initial);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [registered, setRegistered] = useState(false);
|
||||||
|
|
||||||
|
async function post(path: string, body?: unknown) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "POST", credentials: "same-origin", redirect: "error",
|
||||||
|
headers: { "Content-Type": "application/json", [csrf.headerName]: csrf.value },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) {
|
||||||
|
throw new Error("验证未完成,请重试;若登录已过期,请退出并重新验证密码。");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function perform() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (!PublicKeyCredential.parseCreationOptionsFromJSON || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||||
|
throw new Error("请使用支持 passkey 的新版浏览器。");
|
||||||
|
}
|
||||||
|
if (step === "register") {
|
||||||
|
const options = await post("/webauthn/register/options");
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
|
||||||
|
}) as PublicKeyCredential | null;
|
||||||
|
if (!credential) throw new Error("注册已取消。");
|
||||||
|
await post("/webauthn/register", { publicKey: { credential: credential.toJSON(), label: "Passkey" } });
|
||||||
|
setRegistered(true);
|
||||||
|
setStep("authenticate");
|
||||||
|
} else {
|
||||||
|
const options = await post("/webauthn/authenticate/options");
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
|
||||||
|
}) as PublicKeyCredential | null;
|
||||||
|
if (!credential) throw new Error("验证已取消。");
|
||||||
|
await post("/login/webauthn", credential.toJSON());
|
||||||
|
window.location.assign("/signin/complete");
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof DOMException ? "操作已取消或认证器不可用,可以重试。"
|
||||||
|
: cause instanceof Error ? cause.message : "验证未完成,请重试。");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="passkey">
|
||||||
|
{registered && <p role="status">Passkey 已保存,请验证一次以完成第二因素。</p>}
|
||||||
|
{step === "register" && <p>首次使用,请注册 passkey。后续登录仍需 AD 密码和 passkey。</p>}
|
||||||
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
|
<button type="button" disabled={busy} onClick={perform}>
|
||||||
|
{busy ? "等待认证器…" : step === "register" ? "注册 Passkey" : "验证 Passkey"}
|
||||||
|
</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type CsrfToken = { name: string; value: string };
|
export type CsrfToken = { name: string; value: string; headerName: string };
|
||||||
|
|
||||||
type PageBase = {
|
type PageBase = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,7 +10,8 @@ type PageBase = {
|
|||||||
export type SignInContext = PageBase & (
|
export type SignInContext = PageBase & (
|
||||||
| { step: "password" }
|
| { step: "password" }
|
||||||
| {
|
| {
|
||||||
step: "mfa-pending";
|
step: "mfa-pending" | "mfa-complete";
|
||||||
|
passkey: "unavailable" | "register" | "authenticate";
|
||||||
identity: {
|
identity: {
|
||||||
username: string;
|
username: string;
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
@@ -25,7 +26,7 @@ export function readPageContext(): SignInContext {
|
|||||||
const data = document.getElementById("login-context")?.textContent;
|
const data = document.getElementById("login-context")?.textContent;
|
||||||
if (!data) throw new Error("Missing login page context");
|
if (!data) throw new Error("Missing login page context");
|
||||||
const context: SignInContext = JSON.parse(data);
|
const context: SignInContext = JSON.parse(data);
|
||||||
if (context.step !== "password" && context.step !== "mfa-pending") {
|
if (context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") {
|
||||||
throw new Error("Unknown login step");
|
throw new Error("Unknown login step");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Passkey } from "../components/Passkey";
|
||||||
import { SubmitForm } from "../components/SubmitForm";
|
import { SubmitForm } from "../components/SubmitForm";
|
||||||
import type { SignInContext } from "../page-context";
|
import type { SignInContext } from "../page-context";
|
||||||
|
|
||||||
@@ -8,15 +9,19 @@ export function SignInPage({ context }: { context: SignInContext }) {
|
|||||||
<section className="card" aria-labelledby="title">
|
<section className="card" aria-labelledby="title">
|
||||||
<div className="eyebrow">AD 登录验证</div>
|
<div className="eyebrow">AD 登录验证</div>
|
||||||
<h1 id="title">
|
<h1 id="title">
|
||||||
{context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"}
|
{context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="intro">
|
<p className="intro">
|
||||||
{context.step === "password"
|
{context.step === "password"
|
||||||
? "使用 AD 用户名或完整 UPN 登录。"
|
? "使用 AD 用户名或完整 UPN 登录。"
|
||||||
: `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`}
|
: context.step === "mfa-complete"
|
||||||
|
? `${context.name},密码与 passkey 已验证。尚未向应用授权。`
|
||||||
|
: context.passkey === "unavailable"
|
||||||
|
? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`
|
||||||
|
: `${context.name},请使用 passkey 完成第二因素验证。`}
|
||||||
</p>
|
</p>
|
||||||
{context.error && <p className="error" role="alert">{context.error}</p>}
|
{context.error && <p className="error" role="alert">{context.error}</p>}
|
||||||
{context.step === "mfa-pending" && (
|
{context.step !== "password" && (
|
||||||
<div className="directory-result">
|
<div className="directory-result">
|
||||||
<dl>
|
<dl>
|
||||||
<dt>账号</dt><dd>{context.identity.username}</dd>
|
<dt>账号</dt><dd>{context.identity.username}</dd>
|
||||||
@@ -34,6 +39,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
|
|||||||
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
|
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{context.step === "mfa-pending" && context.passkey !== "unavailable" &&
|
||||||
|
<Passkey csrf={context.csrf} initial={context.passkey} />}
|
||||||
<SubmitForm action={context.action} csrf={context.csrf}
|
<SubmitForm action={context.action} csrf={context.csrf}
|
||||||
label={context.step === "password" ? "继续" : "退出并重新验证"}>
|
label={context.step === "password" ? "继续" : "退出并重新验证"}>
|
||||||
{context.step === "password" && <>
|
{context.step === "password" && <>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ ignoreHTTPSErrors: true });
|
||||||
|
|
||||||
|
// Dedicated localhost fixture only. Never registers a synthetic credential against real AD.
|
||||||
|
test("AD + PostgreSQL + real WebAuthn ceremony, factor gating and persisted re-login", async ({ page, context }) => {
|
||||||
|
test.skip(process.env.IAM_WEBAUTHN_FIXTURE !== "1", "Start the test-only webauthnBrowserFixture first");
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send("WebAuthn.enable");
|
||||||
|
await cdp.send("WebAuthn.addVirtualAuthenticator", { options: {
|
||||||
|
protocol: "ctap2", transport: "internal", hasResidentKey: true,
|
||||||
|
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true,
|
||||||
|
} });
|
||||||
|
async function login(username = "alice") {
|
||||||
|
await page.goto("https://localhost:18083/signin");
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill(username);
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("fixture-password");
|
||||||
|
await page.getByRole("button", { name: "继续", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "密码已验证,等待 MFA" })).toBeVisible();
|
||||||
|
}
|
||||||
|
await login();
|
||||||
|
await page.getByRole("button", { name: "注册 Passkey", exact: true }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("Passkey 已保存");
|
||||||
|
await page.goto("https://localhost:18083/signin/complete");
|
||||||
|
await expect(page).toHaveURL(/^https:\/\/localhost:18083\/signin\/mfa(?:\?.*)?$/);
|
||||||
|
const enrollmentToken = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf);
|
||||||
|
const enrollAgain = await context.request.post("https://localhost:18083/webauthn/register/options", {
|
||||||
|
headers: { [enrollmentToken.headerName]: enrollmentToken.value }, maxRedirects: 0,
|
||||||
|
});
|
||||||
|
expect(enrollAgain.status()).toBe(302);
|
||||||
|
expect(enrollAgain.headers().location).toContain("factor.type=webauthn");
|
||||||
|
const beforeMfa = (await context.cookies()).find(c => c.name === "JSESSIONID")!.value;
|
||||||
|
await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("gitea-admins", { exact: true })).toBeVisible();
|
||||||
|
expect((await context.cookies()).find(c => c.name === "JSESSIONID")!.value).not.toBe(beforeMfa);
|
||||||
|
await page.getByRole("button", { name: "退出并重新验证", exact: true }).click();
|
||||||
|
await login();
|
||||||
|
await expect(page.getByRole("button", { name: "注册 Passkey", exact: true })).toHaveCount(0);
|
||||||
|
const assertionRequest = page.waitForRequest(request => new URL(request.url()).pathname === "/login/webauthn");
|
||||||
|
await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
|
||||||
|
const assertion = (await assertionRequest).postDataJSON();
|
||||||
|
await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible();
|
||||||
|
const token = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf);
|
||||||
|
const replay = await context.request.post("https://localhost:18083/login/webauthn", {
|
||||||
|
headers: { [token.headerName]: token.value }, data: assertion,
|
||||||
|
});
|
||||||
|
expect(replay.status()).toBe(401);
|
||||||
|
await page.getByRole("button", { name: "退出并重新验证", exact: true }).click();
|
||||||
|
await login("bob");
|
||||||
|
// Bob has no credential. A discoverable Alice credential must not become Bob's second factor.
|
||||||
|
const foreignStatus = await page.evaluate(async () => {
|
||||||
|
const csrf = JSON.parse(document.getElementById("login-context")!.textContent!).csrf;
|
||||||
|
const headers = { "Content-Type": "application/json", [csrf.headerName]: csrf.value };
|
||||||
|
const options = await fetch("/webauthn/authenticate/options", { method: "POST", headers }).then(r => r.json());
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
return fetch("/login/webauthn", { method: "POST", headers, body: JSON.stringify(credential.toJSON()) })
|
||||||
|
.then(r => r.status);
|
||||||
|
});
|
||||||
|
expect(foreignStatus).toBe(401);
|
||||||
|
});
|
||||||
+7
-1
@@ -1,10 +1,16 @@
|
|||||||
package top.ddupan.iam.login.authentication.infrastructure.security;
|
package top.ddupan.iam.login.authentication.infrastructure.security;
|
||||||
|
|
||||||
import org.springframework.security.core.AuthenticatedPrincipal;
|
import org.springframework.security.core.AuthenticatedPrincipal;
|
||||||
|
import org.springframework.security.web.webauthn.api.Bytes;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
|
||||||
import top.ddupan.iam.login.authentication.domain.User;
|
import top.ddupan.iam.login.authentication.domain.User;
|
||||||
|
|
||||||
/** An immutable directory snapshot; never contains credentials or connections. */
|
/** An immutable directory snapshot; never contains credentials or connections. */
|
||||||
public record DirectoryPrincipal(User user) implements AuthenticatedPrincipal {
|
public record DirectoryPrincipal(User user, Bytes credentialUserId)
|
||||||
|
implements AuthenticatedPrincipal, PublicKeyCredentialUserEntity {
|
||||||
|
public DirectoryPrincipal(User user) { this(user, null); }
|
||||||
|
@Override public Bytes getId() { return credentialUserId; }
|
||||||
|
@Override public String getDisplayName() { return user.displayName(); }
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return user.id().authority() + ":" + user.id().value();
|
return user.id().authority() + ":" + user.id().value();
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcOperations;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.api.*;
|
||||||
|
import org.springframework.security.web.webauthn.management.*;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||||
|
|
||||||
|
/** Adds directory ownership/enrollment policy; verification and storage remain upstream implementations. */
|
||||||
|
public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPartyOperations {
|
||||||
|
private final WebAuthnRelyingPartyOperations delegate;
|
||||||
|
private final MfaPolicy policy;
|
||||||
|
private final PublicKeyCredentialUserEntityRepository users;
|
||||||
|
private final UserCredentialRepository credentials;
|
||||||
|
private final JdbcOperations jdbc;
|
||||||
|
private final TransactionTemplate transactions;
|
||||||
|
|
||||||
|
public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy,
|
||||||
|
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
|
||||||
|
JdbcOperations jdbc, TransactionTemplate transactions) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.policy = policy;
|
||||||
|
this.users = users;
|
||||||
|
this.credentials = credentials;
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.transactions = transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions(
|
||||||
|
PublicKeyCredentialCreationOptionsRequest request) {
|
||||||
|
policy.current();
|
||||||
|
policy.requireEnrollment(request.getAuthentication());
|
||||||
|
return delegate.createPublicKeyCredentialCreationOptions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CredentialRecord registerCredential(RelyingPartyRegistrationRequest request) {
|
||||||
|
var principal = policy.current();
|
||||||
|
var owner = request.getCreationOptions().getUser();
|
||||||
|
if (!principal.getName().equals(owner.getName())) throw new AccessDeniedException("Credential owner mismatch");
|
||||||
|
return transactions.execute(status -> {
|
||||||
|
// Serialize first enrollment across sessions/processes, then recheck existing factors.
|
||||||
|
jdbc.queryForObject("select id from user_entities where id = ? for update", String.class,
|
||||||
|
owner.getId().toBase64UrlString());
|
||||||
|
policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
return delegate.registerCredential(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKeyCredentialRequestOptions createCredentialRequestOptions(PublicKeyCredentialRequestOptionsRequest request) {
|
||||||
|
policy.current();
|
||||||
|
return delegate.createCredentialRequestOptions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKeyCredentialUserEntity authenticate(RelyingPartyAuthenticationRequest request) {
|
||||||
|
var principal = policy.current();
|
||||||
|
var owner = users.findByUsername(principal.getName());
|
||||||
|
var credential = credentials.findByCredentialId(request.getPublicKey().getRawId());
|
||||||
|
if (owner == null || credential == null || !owner.getId().equals(credential.getUserEntityUserId())) {
|
||||||
|
throw new AccessDeniedException("Credential owner mismatch");
|
||||||
|
}
|
||||||
|
var verified = delegate.authenticate(request);
|
||||||
|
if (!verified.getName().equals(principal.getName())) throw new AccessDeniedException("Credential owner mismatch");
|
||||||
|
return new DirectoryPrincipal(principal.user(), verified.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authorization.AuthorizationManager;
|
||||||
|
import org.springframework.security.authorization.AuthorizationManagerFactories;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||||
|
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||||
|
|
||||||
|
/** Enrollment policy; factor completion and freshness are evaluated by Spring Security. */
|
||||||
|
public final class MfaPolicy {
|
||||||
|
private final PublicKeyCredentialUserEntityRepository users;
|
||||||
|
private final UserCredentialRepository credentials;
|
||||||
|
public final AuthorizationManager<Object> password = AuthorizationManagerFactories.<Object>multiFactor()
|
||||||
|
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
|
||||||
|
public final AuthorizationManager<Object> complete = AuthorizationManagerFactories.<Object>multiFactor()
|
||||||
|
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
||||||
|
.requireFactor(f -> f.webauthnAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
|
||||||
|
|
||||||
|
public MfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
|
||||||
|
this.users = users;
|
||||||
|
this.credentials = credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DirectoryPrincipal current() {
|
||||||
|
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
password.verify(() -> authentication, null);
|
||||||
|
if (!(authentication.getPrincipal() instanceof DirectoryPrincipal principal)) {
|
||||||
|
throw new AccessDeniedException("Directory identity required");
|
||||||
|
}
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enrolled(String name) {
|
||||||
|
var user = users.findByUsername(name);
|
||||||
|
return user != null && !credentials.findByUserId(user.getId()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireEnrollment(Authentication authentication) {
|
||||||
|
password.verify(() -> authentication, null);
|
||||||
|
if (enrolled(authentication.getName())) complete.verify(() -> authentication, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
|
||||||
|
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository;
|
||||||
|
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsRepository;
|
||||||
|
|
||||||
|
/** Framework repository extension: server-side TTL, owner binding and atomic single consumption. */
|
||||||
|
public final class SessionChallenges {
|
||||||
|
private final Clock clock;
|
||||||
|
private final MfaPolicy policy;
|
||||||
|
private static final Duration LIFETIME = Duration.ofMinutes(5);
|
||||||
|
private record Challenge(Object options, String owner, Instant expiresAt) { }
|
||||||
|
|
||||||
|
public SessionChallenges(Clock clock, MfaPolicy policy) {
|
||||||
|
this.clock = clock;
|
||||||
|
this.policy = policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(HttpServletRequest request, String key, Object options) {
|
||||||
|
// Upstream clears after load; load already consumes atomically. Do not clear a newer challenge.
|
||||||
|
if (options == null) return;
|
||||||
|
var owner = policy.current().getName();
|
||||||
|
var session = request.getSession();
|
||||||
|
synchronized (session) {
|
||||||
|
session.setAttribute(key, new Challenge(options, owner, clock.instant().plus(LIFETIME)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object consume(HttpServletRequest request, String key) {
|
||||||
|
var session = request.getSession(false);
|
||||||
|
if (session == null) return null;
|
||||||
|
synchronized (session) {
|
||||||
|
var challenge = (Challenge) session.getAttribute(key);
|
||||||
|
session.removeAttribute(key);
|
||||||
|
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (challenge == null || !clock.instant().isBefore(challenge.expiresAt())
|
||||||
|
|| authentication == null || !challenge.owner().equals(authentication.getName())) return null;
|
||||||
|
var result = policy.password.authorize(() -> authentication, null);
|
||||||
|
if (result == null || !result.isGranted()) return null;
|
||||||
|
return challenge.options();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicKeyCredentialCreationOptionsRepository registration() {
|
||||||
|
return new PublicKeyCredentialCreationOptionsRepository() {
|
||||||
|
private static final String KEY = "iam.webauthn.registration";
|
||||||
|
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialCreationOptions o) {
|
||||||
|
SessionChallenges.this.save(r, KEY, o);
|
||||||
|
}
|
||||||
|
@Override public PublicKeyCredentialCreationOptions load(HttpServletRequest r) {
|
||||||
|
return (PublicKeyCredentialCreationOptions) consume(r, KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicKeyCredentialRequestOptionsRepository authentication() {
|
||||||
|
return new PublicKeyCredentialRequestOptionsRepository() {
|
||||||
|
private static final String KEY = "iam.webauthn.authentication";
|
||||||
|
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialRequestOptions o) {
|
||||||
|
SessionChallenges.this.save(r, KEY, o);
|
||||||
|
}
|
||||||
|
@Override public PublicKeyCredentialRequestOptions load(HttpServletRequest r) {
|
||||||
|
return (PublicKeyCredentialRequestOptions) consume(r, KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties("iam.webauthn")
|
||||||
|
public record WebAuthnProperties(boolean enabled, String rpId, String origin) {
|
||||||
|
public WebAuthnProperties {
|
||||||
|
if (enabled) {
|
||||||
|
if (origin == null) throw new IllegalArgumentException("WebAuthn HTTPS origin is required");
|
||||||
|
var uri = URI.create(origin);
|
||||||
|
if (rpId == null || rpId.isBlank() || !"https".equals(uri.getScheme())
|
||||||
|
|| !rpId.equals(uri.getHost()) || uri.getUserInfo() != null
|
||||||
|
|| uri.getQuery() != null || uri.getFragment() != null
|
||||||
|
|| (uri.getPath() != null && !uri.getPath().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("WebAuthn requires an exact HTTPS origin and matching RP host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -1,6 +1,8 @@
|
|||||||
package top.ddupan.iam.login.authentication.interfaces.web;
|
package top.ddupan.iam.login.authentication.interfaces.web;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -17,9 +19,11 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin
|
|||||||
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
||||||
public class SignInController {
|
public class SignInController {
|
||||||
private final PageRenderer renderer;
|
private final PageRenderer renderer;
|
||||||
|
private final ObjectProvider<MfaPolicy> policies;
|
||||||
|
|
||||||
public SignInController(PageRenderer renderer) {
|
public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies) {
|
||||||
this.renderer = renderer;
|
this.renderer = renderer;
|
||||||
|
this.policies = policies;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
@@ -31,8 +35,19 @@ public class SignInController {
|
|||||||
|
|
||||||
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
|
ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
|
||||||
|
var policy = policies.getIfAvailable();
|
||||||
|
return identity(principal, csrf, "mfa-pending", policy == null ? "unavailable"
|
||||||
|
: policy.enrolled(principal.getName()) ? "authenticate" : "register");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/signin/complete", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
ResponseEntity<String> complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
|
||||||
|
return identity(principal, csrf, "mfa-complete", "authenticate");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> identity(DirectoryPrincipal principal, CsrfToken csrf, String step, String passkey) {
|
||||||
var user = principal.user();
|
var user = principal.user();
|
||||||
return renderer.render(Map.of("step", "mfa-pending", "name", user.displayName(),
|
return renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(),
|
||||||
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
|
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
|
||||||
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
|
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
|
||||||
"email", user.email(),
|
"email", user.email(),
|
||||||
@@ -41,6 +56,6 @@ public class SignInController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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(), "headerName", token.getHeaderName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package top.ddupan.iam.login.configuration;
|
package top.ddupan.iam.login.configuration;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -41,18 +42,26 @@ class SecurityConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
@Order(2)
|
@Order(2)
|
||||||
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
||||||
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords) throws Exception {
|
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords,
|
||||||
|
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn) throws Exception {
|
||||||
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
|
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
|
||||||
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
||||||
.build();
|
.build();
|
||||||
return http.securityMatcher("/signin", "/signin/**", "/assets/**")
|
var mfa = webAuthn.getIfAvailable();
|
||||||
|
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn")
|
||||||
.redirectToHttps(Customizer.withDefaults())
|
.redirectToHttps(Customizer.withDefaults())
|
||||||
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
|
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> {
|
||||||
.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
|
if (mfa != null) {
|
||||||
|
auth.requestMatchers("/signin/complete").access(mfa.policy.complete);
|
||||||
|
auth.requestMatchers(org.springframework.http.HttpMethod.POST, "/webauthn/register")
|
||||||
|
.access(mfa.policy.password);
|
||||||
|
}
|
||||||
|
auth.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
|
||||||
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
|
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
|
||||||
// No complete MFA or Hydra acceptance exists yet. Fail closed until those are implemented.
|
// Credential deletion and all unimplemented routes remain closed.
|
||||||
.anyRequest().denyAll())
|
.anyRequest().denyAll();
|
||||||
|
})
|
||||||
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
|
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
|
||||||
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
|
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
|
||||||
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
|
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
|
||||||
@@ -64,8 +73,10 @@ class SecurityConfiguration {
|
|||||||
.requestCache(cache -> cache.disable())
|
.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'")));
|
||||||
.build();
|
if (mfa != null) mfa.configure(http);
|
||||||
|
var chain = http.build();
|
||||||
|
return mfa == null ? chain : mfa.finish(http, chain);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package top.ddupan.iam.login.configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||||
|
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
|
||||||
|
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
|
||||||
|
|
||||||
|
/** Wires official filters through their public extension points; no custom authentication filter. */
|
||||||
|
final class WebAuthnBrowserConfigurer {
|
||||||
|
private final WebAuthnProperties properties;
|
||||||
|
final MfaPolicy policy;
|
||||||
|
private final SessionChallenges challenges;
|
||||||
|
|
||||||
|
WebAuthnBrowserConfigurer(WebAuthnProperties properties, MfaPolicy policy, SessionChallenges challenges) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.policy = policy;
|
||||||
|
this.challenges = challenges;
|
||||||
|
}
|
||||||
|
|
||||||
|
void configure(HttpSecurity http) throws Exception {
|
||||||
|
http.setSharedObject(UserDetailsService.class, name -> {
|
||||||
|
if (!policy.current().getName().equals(name)) throw new UsernameNotFoundException("Directory identity mismatch");
|
||||||
|
// Spring Security merges the existing password factor, retaining its original issue time.
|
||||||
|
return new User(name, "", List.of());
|
||||||
|
});
|
||||||
|
http.webAuthn(web -> web.rpId(properties.rpId()).rpName("IAM Login")
|
||||||
|
.allowedOrigins(properties.origin()).disableDefaultRegistrationPage(true)
|
||||||
|
.creationOptionsRepository(challenges.registration()));
|
||||||
|
http.exceptionHandling(exceptions -> exceptions.defaultDeniedHandlerForMissingAuthority(
|
||||||
|
new LoginUrlAuthenticationEntryPoint("/signin/mfa"), FactorGrantedAuthority.WEBAUTHN_AUTHORITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
SecurityFilterChain finish(HttpSecurity http, SecurityFilterChain chain) {
|
||||||
|
var repository = challenges.authentication();
|
||||||
|
// Security 7.1 exposes these setters but does not expose the assertion repository in its DSL.
|
||||||
|
for (var filter : chain.getFilters()) {
|
||||||
|
if (filter instanceof PublicKeyCredentialRequestOptionsFilter options) {
|
||||||
|
options.setRequestOptionsRepository(repository);
|
||||||
|
}
|
||||||
|
if (filter instanceof WebAuthnAuthenticationFilter authentication) {
|
||||||
|
authentication.setRequestOptionsRepository(repository);
|
||||||
|
authentication.setSessionAuthenticationStrategy(http.getSharedObject(
|
||||||
|
org.springframework.security.web.authentication.session.SessionAuthenticationStrategy.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package top.ddupan.iam.login.configuration;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcOperations;
|
||||||
|
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
|
||||||
|
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
|
||||||
|
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
|
||||||
|
import org.springframework.security.web.webauthn.management.*;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@ConditionalOnProperty(prefix = "iam.webauthn", name = "enabled", havingValue = "true")
|
||||||
|
@EnableConfigurationProperties({WebAuthnProperties.class, DataSourceProperties.class})
|
||||||
|
class WebAuthnConfiguration {
|
||||||
|
@Bean
|
||||||
|
DataSource webAuthnDataSource(DataSourceProperties properties) {
|
||||||
|
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PublicKeyCredentialUserEntityRepository credentialUsers(JdbcOperations jdbc) {
|
||||||
|
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
UserCredentialRepository credentials(JdbcOperations jdbc) {
|
||||||
|
return new JdbcUserCredentialRepository(jdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
MfaPolicy mfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
|
||||||
|
return new MfaPolicy(users, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SessionChallenges challenges(MfaPolicy policy) {
|
||||||
|
return new SessionChallenges(Clock.systemUTC(), policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
WebAuthnRelyingPartyOperations relyingParty(WebAuthnProperties properties, MfaPolicy policy,
|
||||||
|
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
|
||||||
|
JdbcOperations jdbc, PlatformTransactionManager transactions) {
|
||||||
|
var delegate = new Webauthn4JRelyingPartyOperations(users, credentials,
|
||||||
|
PublicKeyCredentialRpEntity.builder().id(properties.rpId()).name("IAM Login").build(),
|
||||||
|
Set.of(properties.origin()));
|
||||||
|
delegate.setCustomizeCreationOptions(options -> options.timeout(Duration.ofMinutes(5))
|
||||||
|
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
|
||||||
|
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||||
|
.userVerification(UserVerificationRequirement.REQUIRED).build()));
|
||||||
|
delegate.setCustomizeRequestOptions(options -> options.timeout(Duration.ofMinutes(5))
|
||||||
|
.userVerification(UserVerificationRequirement.REQUIRED));
|
||||||
|
return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials, jdbc,
|
||||||
|
new TransactionTemplate(transactions));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
WebAuthnBrowserConfigurer webAuthnBrowser(WebAuthnProperties properties, MfaPolicy policy,
|
||||||
|
SessionChallenges challenges) {
|
||||||
|
return new WebAuthnBrowserConfigurer(properties, policy, challenges);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
spring:
|
spring:
|
||||||
|
autoconfigure:
|
||||||
|
exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
|
||||||
application:
|
application:
|
||||||
name: iam-login
|
name: iam-login
|
||||||
management:
|
management:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Based on Spring Security 7.1.1 WebAuthn JDBC schemas (Apache-2.0).
|
||||||
|
create table user_entities
|
||||||
|
(
|
||||||
|
id varchar(1000) not null,
|
||||||
|
name varchar(100) not null,
|
||||||
|
display_name varchar(200),
|
||||||
|
primary key (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table user_credentials
|
||||||
|
(
|
||||||
|
credential_id varchar(1000) not null,
|
||||||
|
user_entity_user_id varchar(1000) not null,
|
||||||
|
public_key bytea not null,
|
||||||
|
signature_count bigint,
|
||||||
|
uv_initialized boolean,
|
||||||
|
backup_eligible boolean not null,
|
||||||
|
authenticator_transports varchar(1000),
|
||||||
|
public_key_credential_type varchar(100),
|
||||||
|
backup_state boolean not null,
|
||||||
|
attestation_object bytea,
|
||||||
|
attestation_client_data_json bytea,
|
||||||
|
created timestamp,
|
||||||
|
last_used timestamp,
|
||||||
|
label varchar(1000) not null,
|
||||||
|
primary key (credential_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index user_entities_name on user_entities(name);
|
||||||
|
create index user_credentials_owner on user_credentials(user_entity_user_id);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package top.ddupan.iam.login;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
import top.ddupan.iam.login.support.AdDirectoryFixture;
|
||||||
|
|
||||||
|
/** Test-only HTTPS application with simulated AD and disposable PostgreSQL. Never connects to real AD. */
|
||||||
|
public final class WebAuthnBrowserFixture {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
var directory = new AdDirectoryFixture();
|
||||||
|
var database = new PostgreSQLContainer(DockerImageName.parse(
|
||||||
|
"postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873")
|
||||||
|
.asCompatibleSubstituteFor("postgres"));
|
||||||
|
database.start();
|
||||||
|
var application = new SpringApplication(IamLoginApplication.class);
|
||||||
|
application.setDefaultProperties(Map.ofEntries(
|
||||||
|
Map.entry("server.address", "127.0.0.1"), Map.entry("server.port", "18083"),
|
||||||
|
Map.entry("server.ssl.enabled", "true"), Map.entry("server.ssl.key-store", "classpath:ldap/fixture.p12"),
|
||||||
|
Map.entry("server.ssl.key-store-password", "fixture-only"),
|
||||||
|
Map.entry("iam.ad.enabled", "true"), Map.entry("iam.ad.domain", "example.test"),
|
||||||
|
Map.entry("iam.ad.base-dn", "dc=example,dc=test"), Map.entry("iam.ad.url", directory.url()),
|
||||||
|
Map.entry("iam.webauthn.enabled", "true"), Map.entry("iam.webauthn.rp-id", "localhost"),
|
||||||
|
Map.entry("iam.webauthn.origin", "https://localhost:18083"),
|
||||||
|
Map.entry("spring.datasource.url", database.getJdbcUrl()),
|
||||||
|
Map.entry("spring.datasource.username", database.getUsername()),
|
||||||
|
Map.entry("spring.datasource.password", database.getPassword()),
|
||||||
|
Map.entry("spring.security.user.password", "fixture-monitor-password"),
|
||||||
|
Map.entry("management.otlp.metrics.export.enabled", "false")));
|
||||||
|
var context = application.run(args);
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||||
|
context.close(); directory.close(); database.stop();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.api.Bytes;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
|
||||||
|
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
|
||||||
|
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
|
||||||
|
import top.ddupan.iam.login.authentication.domain.User;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class SessionChallengesTests {
|
||||||
|
private final MfaPolicy policy = new MfaPolicy(new MapPublicKeyCredentialUserEntityRepository(), new MapUserCredentialRepository());
|
||||||
|
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengesExpireAndAreBoundToCurrentIdentity() {
|
||||||
|
var issued = Instant.now();
|
||||||
|
var request = new MockHttpServletRequest();
|
||||||
|
var response = new MockHttpServletResponse();
|
||||||
|
identify("alice", issued);
|
||||||
|
var repository = new SessionChallenges(Clock.fixed(issued, ZoneOffset.UTC), policy).authentication();
|
||||||
|
var options = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
|
||||||
|
repository.save(request, response, options);
|
||||||
|
var later = new SessionChallenges(Clock.fixed(issued.plusSeconds(301), ZoneOffset.UTC), policy).authentication();
|
||||||
|
assertThat(later.load(request)).isNull();
|
||||||
|
repository.save(request, response, options);
|
||||||
|
identify("bob", issued);
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
identify("alice", issued);
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengeIsConsumedOnceAndUpstreamCleanupCannotEraseNewChallenge() {
|
||||||
|
var issued = Instant.now();
|
||||||
|
identify("alice", issued);
|
||||||
|
var request = new MockHttpServletRequest();
|
||||||
|
var response = new MockHttpServletResponse();
|
||||||
|
var repository = new SessionChallenges(Clock.systemUTC(), policy).authentication();
|
||||||
|
var first = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
|
||||||
|
var second = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
|
||||||
|
repository.save(request, response, first);
|
||||||
|
assertThat(repository.load(request)).isSameAs(first);
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
repository.save(request, response, second);
|
||||||
|
repository.save(request, response, null);
|
||||||
|
assertThat(repository.load(request)).isSameAs(second);
|
||||||
|
repository.save(request, response, first);
|
||||||
|
identify("alice", issued.minusSeconds(601));
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void identify(String id, Instant issued) {
|
||||||
|
var user = new User(new User.UserId("example.test", id), id, id, "", List.of());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
new DirectoryPrincipal(user), null, List.of(FactorGrantedAuthority.withAuthority("FACTOR_PASSWORD")
|
||||||
|
.issuedAt(issued).build())));
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.mock.web.MockHttpSession;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||||
|
import org.springframework.jdbc.core.JdbcOperations;
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
import top.ddupan.iam.login.support.AdDirectoryFixture;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
||||||
|
"iam.webauthn.enabled=true", "iam.webauthn.rp-id=localhost", "iam.webauthn.origin=https://localhost",
|
||||||
|
"management.otlp.metrics.export.enabled=false", "spring.security.user.password=fixture-monitor-password"})
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class WebAuthnIntegrationTests {
|
||||||
|
static class Fixtures {
|
||||||
|
static final AdDirectoryFixture DIRECTORY = new AdDirectoryFixture();
|
||||||
|
static final PostgreSQLContainer DATABASE = new PostgreSQLContainer(DockerImageName.parse(
|
||||||
|
"postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873")
|
||||||
|
.asCompatibleSubstituteFor("postgres"));
|
||||||
|
static { DATABASE.start(); }
|
||||||
|
}
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void properties(DynamicPropertyRegistry r) {
|
||||||
|
r.add("iam.ad.url", () -> Fixtures.DIRECTORY.url());
|
||||||
|
r.add("spring.datasource.url", () -> Fixtures.DATABASE.getJdbcUrl());
|
||||||
|
r.add("spring.datasource.username", () -> Fixtures.DATABASE.getUsername());
|
||||||
|
r.add("spring.datasource.password", () -> Fixtures.DATABASE.getPassword());
|
||||||
|
}
|
||||||
|
@AfterAll static void close() { Fixtures.DIRECTORY.close(); Fixtures.DATABASE.stop(); }
|
||||||
|
@Autowired MockMvc mvc;
|
||||||
|
@Autowired JdbcOperations jdbc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordOnlyCanEnrollButCannotCompleteOrDelete() throws Exception {
|
||||||
|
var session = login();
|
||||||
|
mvc.perform(post("/webauthn/register/options").session(session).with(https()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
mvc.perform(post("/webauthn/register/options").session(session).with(https()).with(csrf()))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("rp.id").value("localhost"))
|
||||||
|
.andExpect(jsonPath("authenticatorSelection.userVerification").value("required"));
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from user_entities", Integer.class)).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from user_credentials", Integer.class)).isZero();
|
||||||
|
mvc.perform(get("/signin/complete").session(session).with(https()))
|
||||||
|
.andExpect(redirectedUrlPattern("/signin/mfa?*"));
|
||||||
|
mvc.perform(delete("/webauthn/register/unused").session(session).with(https()).with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionsRequireFreshPasswordEvenThoughFrameworkProcessesThemBeforeAuthorization() throws Exception {
|
||||||
|
mvc.perform(post("/webauthn/authenticate/options").with(https()).with(csrf()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
var session = login();
|
||||||
|
var context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
|
||||||
|
var auth = context.getAuthentication();
|
||||||
|
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(auth.getPrincipal(), null,
|
||||||
|
List.of(FactorGrantedAuthority.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
|
||||||
|
.issuedAt(Instant.now().minusSeconds(601)).build())));
|
||||||
|
mvc.perform(post("/webauthn/register/options").session(session).with(https()).with(csrf()))
|
||||||
|
.andExpect(status().is3xxRedirection());
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpSession login() throws Exception {
|
||||||
|
var session = new MockHttpSession();
|
||||||
|
mvc.perform(post("/signin/password").session(session).with(https()).with(csrf())
|
||||||
|
.param("username", "alice").param("password", "fixture-password"))
|
||||||
|
.andExpect(redirectedUrl("/signin/mfa"));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
private static RequestPostProcessor https() {
|
||||||
|
return request -> { request.setScheme("https"); request.setSecure(true); request.setServerPort(443); return request; };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,9 @@ public final class AdDirectoryFixture implements AutoCloseable {
|
|||||||
"80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1");
|
"80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1");
|
||||||
if (name.equalsIgnoreCase("[email protected]")) {
|
if (name.equalsIgnoreCase("[email protected]")) {
|
||||||
request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue()));
|
request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue()));
|
||||||
} else if (!name.equals(USER_DN)) {
|
} else if (name.equalsIgnoreCase("[email protected]")) {
|
||||||
|
request.setRequest(new SimpleBindRequest("cn=Bob," + BASE, request.getRequest().getPassword().getValue()));
|
||||||
|
} else if (!name.equals(USER_DN) && !name.equals("cn=Bob," + BASE)) {
|
||||||
throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials");
|
throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,14 @@ public final class AdDirectoryFixture implements AutoCloseable {
|
|||||||
new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"),
|
new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"),
|
||||||
new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID),
|
new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID),
|
||||||
new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE)));
|
new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE)));
|
||||||
|
ldap.add(new Entry("cn=Bob," + BASE,
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("objectClass", "user"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("cn", "Bob"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("sAMAccountName", "bob"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("userPrincipalName", "[email protected]"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("userPassword", "fixture-password"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("displayName", "Bob"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("objectGUID", new byte[16])));
|
||||||
} catch (Exception ex) { throw new ExceptionInInitializerError(ex); }
|
} catch (Exception ex) { throw new ExceptionInInitializerError(ex); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user