feat: implement TASK-WP1-04 admin security

This commit is contained in:
suyx
2026-07-28 18:32:50 +08:00
parent 66fe3b763a
commit 03f1509de7
29 changed files with 2344 additions and 40 deletions
+157
View File
@@ -0,0 +1,157 @@
:root {
color: #121212;
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.admin-auth-page {
min-height: 100vh;
background: #ffffff;
display: grid;
grid-template-rows: 8px 72px 1fr;
}
.admin-auth-accent {
background: #eaff00;
}
.admin-auth-header {
align-items: center;
border-bottom: 1px solid #dedede;
display: flex;
padding: 0 32px;
}
.admin-auth-wordmark {
color: #111111;
font-family: Arial, sans-serif;
font-size: 20px;
font-weight: 800;
letter-spacing: 0;
text-decoration: none;
}
.admin-auth-panel {
align-self: center;
justify-self: center;
margin: 48px 20px 96px;
width: min(440px, calc(100vw - 40px));
}
.admin-auth-kicker {
color: #606060;
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
margin: 0 0 10px;
}
.admin-auth-panel h1 {
font-size: 26px;
line-height: 1.35;
margin: 0 0 36px;
}
.admin-auth-panel label {
display: block;
font-size: 14px;
font-weight: 650;
margin-bottom: 9px;
}
.admin-auth-panel input {
border: 1px solid #b9b9b9;
border-radius: 4px;
font: inherit;
height: 48px;
min-width: 0;
padding: 0 13px;
width: 100%;
}
.admin-auth-panel input:focus {
border-color: #111111;
box-shadow: 0 0 0 2px #eaff00;
outline: none;
}
.admin-auth-send-row {
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) 124px;
}
.admin-auth-panel button {
border-radius: 4px;
cursor: pointer;
font: inherit;
font-weight: 700;
height: 48px;
}
.admin-auth-panel button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.admin-auth-send {
background: #ffffff;
border: 1px solid #111111;
width: 124px;
}
.admin-auth-code-field {
margin-top: 24px;
}
.admin-auth-error {
border-left: 3px solid #c93333;
color: #8d1717;
font-size: 14px;
line-height: 1.55;
margin: 20px 0 0;
padding-left: 12px;
}
.admin-auth-submit {
background: #151515;
border: 1px solid #151515;
color: #ffffff;
margin-top: 28px;
width: 100%;
}
.admin-auth-return {
color: #363636;
display: inline-block;
font-size: 14px;
margin-top: 24px;
text-underline-offset: 4px;
}
@media (max-width: 480px) {
.admin-auth-header {
padding: 0 20px;
}
.admin-auth-panel {
align-self: start;
margin-top: 72px;
}
.admin-auth-send-row {
grid-template-columns: minmax(0, 1fr) 112px;
}
.admin-auth-send {
width: 112px;
}
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useId, useState, type FormEvent } from "react";
import "./admin-auth.css";
interface ErrorEnvelopeBody {
error?: {
details?: { field_errors?: Array<{ message_key?: string }> };
};
}
function adminErrorMessage(body: ErrorEnvelopeBody) {
const key = body.error?.details?.field_errors?.[0]?.message_key;
if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。";
if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。";
if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。";
if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。";
return "管理员登录暂时无法完成,请稍后重试。";
}
export function AdminAuthPage() {
const emailId = useId();
const codeId = useId();
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [registrationId, setRegistrationId] = useState<string>();
const [countdown, setCountdown] = useState(0);
const [sending, setSending] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string>();
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
useEffect(() => {
if (countdown <= 0) return;
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
return () => window.clearInterval(timer);
}, [countdown]);
async function sendCode() {
if (!emailValid || sending || countdown > 0) return;
setSending(true);
setError(undefined);
try {
const response = await fetch("/api/v1/admin-auth/login/send", {
body: JSON.stringify({ email }),
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
method: "POST",
});
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
if (!response.ok || !body.registration_id) {
setError(adminErrorMessage(body));
return;
}
setRegistrationId(body.registration_id);
setCountdown(60);
window.requestAnimationFrame(() => document.getElementById(codeId)?.focus());
} catch {
setError("管理员登录暂时无法完成,请稍后重试。");
} finally {
setSending(false);
}
}
async function completeLogin(event: FormEvent) {
event.preventDefault();
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
setSubmitting(true);
setError(undefined);
try {
const response = await fetch("/api/v1/admin-auth/login/complete", {
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
},
method: "POST",
});
const body = await response.json() as ErrorEnvelopeBody;
if (!response.ok) {
setError(adminErrorMessage(body));
return;
}
window.location.assign("/admin");
} catch {
setError("管理员登录暂时无法完成,请稍后重试。");
} finally {
setSubmitting(false);
}
}
return (
<main className="admin-auth-page">
<div className="admin-auth-accent" aria-hidden="true" />
<header className="admin-auth-header">
<a className="admin-auth-wordmark" href="/" aria-label="Dada 普通用户登录">DADA</a>
</header>
<section className="admin-auth-panel">
<p className="admin-auth-kicker">ADMIN</p>
<h1 id="admin-auth-heading"></h1>
<form onSubmit={completeLogin} noValidate>
<label htmlFor={emailId}></label>
<div className="admin-auth-send-row">
<input
id={emailId}
autoComplete="email"
inputMode="email"
onChange={(event) => {
setEmail(event.target.value);
setRegistrationId(undefined);
setCode("");
setCountdown(0);
setError(undefined);
}}
type="email"
value={email}
/>
<button
aria-label="获取验证码"
className="admin-auth-send"
disabled={!emailValid || sending || countdown > 0}
onClick={sendCode}
type="button"
>
{sending ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</div>
{registrationId ? (
<div className="admin-auth-code-field">
<label htmlFor={codeId}></label>
<input
id={codeId}
autoComplete="one-time-code"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
value={code}
/>
</div>
) : null}
{error ? <p className="admin-auth-error" role="alert">{error}</p> : null}
<button className="admin-auth-submit" disabled={!registrationId || code.length !== 6 || submitting} type="submit">
{submitting ? "登录中" : "登录后台"}
</button>
</form>
<a className="admin-auth-return" href="/"></a>
</section>
</main>
);
}
+26 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: {
}>;
}
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminLoginCompleteResponse>;
}
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -63,6 +72,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
return response.json() as Promise<RegistrationCompleteResponse>;
}
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminSessionResponse>;
}
export async function getBootstrap(options: ClientOptions = {}): Promise<{
"app_version": string;
"dependencies": Array<{
@@ -120,6 +136,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<RegistrationSendResponse>;
}
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+33
View File
@@ -1,5 +1,38 @@
// Generated from openapi/openapi.json. Do not edit by hand.
export type AdminAuthenticatedUser = {
"role": "super_admin";
"status": "active";
"user_id": string;
};
export type AdminLoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
};
export type AdminLoginCompleteResponse = {
"admin": AdminAuthenticatedUser;
"audience": "admin";
"session_expires_at": string;
"status": "authenticated";
};
export type AdminLoginSendRequest = {
"email": string;
};
export type AdminSessionResponse = {
"acknowledged_private_content_notice_version": string | null;
"admin": AdminAuthenticatedUser;
"audience": "admin";
"authenticated": true;
"csrf_token": string;
"current_private_content_notice_version": string | null;
"expires_at": string;
"notice_acknowledged": boolean;
};
export type AuthenticatedUser = {
"creator_name": string;
"role": "user";
+5 -1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
import { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js";
const root = document.getElementById("root");
@@ -18,9 +19,12 @@ let authRevision = 0;
function renderAuthenticationEntry() {
authRevision += 1;
const authenticationPage = window.location.pathname.startsWith("/admin")
? <AdminAuthPage key={authRevision} />
: <UserAuthPage key={authRevision} />;
appRoot.render(
<StrictMode>
<UserAuthPage key={authRevision} />
{authenticationPage}
</StrictMode>,
);
}
+1 -1
View File
@@ -269,7 +269,7 @@ export function UserAuthPage() {
</section>
<section className="auth-content">
<a className="auth-admin-link" href="/admin"></a>
<a className="auth-admin-link" href="/admin/login"></a>
<div className="auth-panel">
<div className="auth-tabs" role="tablist" aria-label="认证方式">
<button