From 6457c836faf3a6c519e59ad69c7c3836c0f31a5b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 6 Aug 2026 19:48:40 +0800 Subject: [PATCH] feat(admin): add RBAC identity and MFA controls --- .../migrations/20260806070000_admin_mfa.sql | 22 + .../src/app/api/admin/administrators/route.ts | 119 ++++++ frontend/src/app/api/admin/mfa/route.ts | 306 +++++++++++++ frontend/src/app/api/admin/reauth/route.ts | 125 ++++++ frontend/src/app/api/admin/roles/route.ts | 45 ++ frontend/src/app/api/admin/session/route.ts | 8 +- frontend/src/app/login/page.tsx | 10 + frontend/src/components/email-otp-login.tsx | 127 +++++- frontend/src/lib/admin/auth-policy.ts | 267 +++++++++++- frontend/src/lib/admin/auth.ts | 94 +++- frontend/src/lib/admin/database.ts | 7 +- frontend/src/lib/admin/http.ts | 108 ++++- .../src/lib/db/local-postgres-client-core.ts | 340 ++++++++++----- .../src/lib/supabase/admin-client-core.ts | 10 +- frontend/src/lib/supabase/admin.ts | 9 +- frontend/src/modules/identity/auth-factory.ts | 15 +- frontend/src/modules/identity/auth.ts | 50 +++ frontend/src/modules/identity/client.ts | 46 +- frontend/src/modules/identity/config.ts | 6 + frontend/src/modules/identity/contracts.ts | 1 + .../identity/email/resend-email-otp-sender.ts | 2 +- frontend/src/modules/identity/host.ts | 8 +- frontend/src/modules/identity/model.ts | 17 + frontend/src/modules/identity/session.ts | 32 +- .../migrations/20260806010000_admin_rbac.sql | 402 ++++++++++++++++++ 25 files changed, 2002 insertions(+), 174 deletions(-) create mode 100644 frontend/db/migrations/20260806070000_admin_mfa.sql create mode 100644 frontend/src/app/api/admin/administrators/route.ts create mode 100644 frontend/src/app/api/admin/mfa/route.ts create mode 100644 frontend/src/app/api/admin/reauth/route.ts create mode 100644 frontend/src/app/api/admin/roles/route.ts create mode 100644 frontend/supabase/migrations/20260806010000_admin_rbac.sql diff --git a/frontend/db/migrations/20260806070000_admin_mfa.sql b/frontend/db/migrations/20260806070000_admin_mfa.sql new file mode 100644 index 00000000..488d69ef --- /dev/null +++ b/frontend/db/migrations/20260806070000_admin_mfa.sql @@ -0,0 +1,22 @@ +alter table identity.users + add column if not exists two_factor_enabled boolean not null default false; + +create table if not exists identity.two_factors ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null unique references identity.users(id) on delete cascade, + secret text not null, + backup_codes text not null, + verified boolean not null default false, + failed_verification_count integer not null default 0 check (failed_verification_count >= 0), + locked_until timestamptz +); + +create index if not exists identity_two_factors_locked_until_idx + on identity.two_factors (locked_until) + where locked_until is not null; + +revoke all on table identity.two_factors +from public, app_runtime, admin_runtime, backup_reader, migration_runner; + +grant select, insert, update, delete on table identity.two_factors +to identity_runtime; diff --git a/frontend/src/app/api/admin/administrators/route.ts b/frontend/src/app/api/admin/administrators/route.ts new file mode 100644 index 00000000..7314acd9 --- /dev/null +++ b/frontend/src/app/api/admin/administrators/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requirePermission } from "@/lib/admin/auth"; +import { isPostgresError, pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readAdminMfaStatus, + requireHighRiskAdminMutation, + requestId, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type AdministratorRow = { + id: string; + email: string; + name: string; + created_at: Date; + roles: string[]; + total_count: string; +}; + +const mutationSchema = z.object({ + email: z.string().trim().email().optional(), + userId: z.string().uuid().optional(), + roleCode: z.enum(["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]), + reason: z.string().trim().min(1).max(500), +}).refine((value) => Boolean(value.email || value.userId), { + message: "email_or_user_id_required", +}); + +export async function GET(request: Request) { + try { + const session = await requirePermission("admin.users.read", request.headers); + const mfa = readAdminMfaStatus(request, session); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, q } = parsed.data; + const values: unknown[] = []; + const conditions = ["au.revoked_at is null"]; + if (q) { + values.push(`%${q}%`); + conditions.push(`(u.email ilike $${values.length} or u.name ilike $${values.length})`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const rows = await queryAdminRows(` + select u.id, u.email, u.name, au.created_at, + array_agg(r.code order by r.code) as roles, + count(*) over()::text as total_count + from public.admin_users au + join identity.users u on u.id = au.user_id + join public.admin_user_roles ur on ur.admin_user_id = au.user_id + join public.admin_roles r on r.id = ur.role_id + where ${conditions.join(" and ")} + group by u.id, u.email, u.name, au.created_at + order by au.created_at desc, u.id + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + email: row.email, + name: row.name, + createdAt: row.created_at.toISOString(), + roles: row.roles, + })), + total: Number(rows[0]?.total_count ?? 0), + highRiskWritesEnabled: mfa.highRiskWritesEnabled, + mfa, + }); + } catch (error) { + return adminErrorResponse(error); + } +} + +async function mutate(request: Request, assign: boolean) { + try { + const session = await requireHighRiskAdminMutation(request, "admin.users.manage_roles"); + const parsed = mutationSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const targetRows = parsed.data.userId + ? [{ id: parsed.data.userId }] + : await queryAdminRows<{ id: string }>( + "select id from identity.users where lower(btrim(email)) = lower(btrim($1)) limit 1", + [parsed.data.email], + ); + const targetUserId = targetRows[0]?.id; + if (!targetUserId) return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + const rows = await queryAdminRows<{ user_id: string; role_code: string; assigned: boolean }>( + "select * from public.admin_manage_role($1, $2, $3, $4, $5, $6)", + [session.user.id, targetUserId, parsed.data.roleCode, assign, parsed.data.reason, requestId(request)], + ); + return NextResponse.json({ data: rows[0] }); + } catch (error) { + if ( + isPostgresError(error) + && error.code === "23514" + && error instanceof Error + && error.message.includes("last_owner_protected") + ) { + return NextResponse.json( + { error: "不能撤销最后一位 Owner,请先分配另一位 Owner" }, + { status: 409 }, + ); + } + return adminErrorResponse(error); + } +} + +export function POST(request: Request) { + return mutate(request, true); +} + +export function DELETE(request: Request) { + return mutate(request, false); +} diff --git a/frontend/src/app/api/admin/mfa/route.ts b/frontend/src/app/api/admin/mfa/route.ts new file mode 100644 index 00000000..58471699 --- /dev/null +++ b/frontend/src/app/api/admin/mfa/route.ts @@ -0,0 +1,306 @@ +import { APIError } from "better-auth"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + getIdentityTwoFactorApi, + type IdentityTwoFactorApi, +} from "@/modules/identity/auth"; +import { AdminAuthorizationError, requirePermission, type AdminSession } from "@/lib/admin/auth"; +import { + ADMIN_MFA_PROOF_COOKIE, + ADMIN_MFA_PROOF_TTL_MS, + HIGH_RISK_ADMIN_CHALLENGE_COOKIE, + HIGH_RISK_ADMIN_PROOF_COOKIE, + issueAdminMfaProof, +} from "@/lib/admin/auth-policy"; +import { + adminProofSigningSecret, + adminErrorResponse, + invalidQueryResponse, + readAdminMfaStatus, + requireAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +const password = z.string().min(1).max(128); +const mfaSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("enroll"), password }), + z.object({ action: z.literal("verify"), code: z.string().regex(/^\d{6}$/) }), + z.object({ action: z.literal("recover"), code: z.string().trim().min(4).max(128) }), + z.object({ action: z.literal("regenerate"), password }), + z.object({ action: z.literal("disable"), password }), +]); + +class MfaOperationError extends Error { + constructor( + message: string, + readonly status: 400 | 409 | 429 | 500, + ) { + super(message); + this.name = "MfaOperationError"; + } +} + +type NativeResponseHeaders = Headers & { getSetCookie?: () => string[] }; + +function nativeRequestHeaders(request: Request): Headers { + const headers = new Headers(); + const cookie = request.headers.get("cookie"); + const userAgent = request.headers.get("user-agent"); + if (cookie) headers.set("cookie", cookie); + if (userAgent) headers.set("user-agent", userAgent); + return headers; +} + +function nativeSetCookies(response: Response): string[] { + const headers = response.headers as NativeResponseHeaders; + const values = headers.getSetCookie?.(); + if (values?.length) return values; + const combined = headers.get("set-cookie"); + return combined + ? combined.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g).map((value) => value.trim()) + : []; +} + +function headersAfterNativeResponse(request: Request, response: Response): Headers { + const cookies = new Map(); + for (const item of request.headers.get("cookie")?.split(";") ?? []) { + const separator = item.indexOf("="); + if (separator > 0) cookies.set(item.slice(0, separator).trim(), item.slice(separator + 1).trim()); + } + for (const setCookie of nativeSetCookies(response)) { + const pair = setCookie.split(";", 1)[0]; + const separator = pair.indexOf("="); + if (separator <= 0) continue; + const name = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + if (value) cookies.set(name, value); + else cookies.delete(name); + } + const headers = nativeRequestHeaders(request); + if (cookies.size) { + headers.set("cookie", [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")); + } else { + headers.delete("cookie"); + } + return headers; +} + +function copyNativeCookies(nativeResponse: Response, response: NextResponse): void { + for (const setCookie of nativeSetCookies(nativeResponse)) { + response.headers.append("set-cookie", setCookie); + } +} + +async function readNativeJson(response: Response, message: string): Promise { + if (!response.ok) { + throw new MfaOperationError( + response.status === 429 ? "MFA 尝试过于频繁,请稍后再试" : message, + response.status === 429 ? 429 : response.status >= 500 ? 500 : 400, + ); + } + return await response.json() as T; +} + +function mfaProofResponse( + request: Request, + session: AdminSession, + nativeResponse: Response, +): NextResponse { + const response = NextResponse.json({ + data: { + required: session.requiresMfa, + enrolled: true, + verified: true, + highRiskWritesEnabled: true, + expiresIn: ADMIN_MFA_PROOF_TTL_MS / 1_000, + }, + }); + copyNativeCookies(nativeResponse, response); + response.cookies.set( + ADMIN_MFA_PROOF_COOKIE, + issueAdminMfaProof( + { + userId: session.user.id, + sessionId: session.identitySession.id, + origin: new URL(request.url).origin, + }, + adminProofSigningSecret(), + session.identitySession.token, + ), + { + httpOnly: true, + sameSite: "strict", + secure: true, + path: "/api/admin", + maxAge: ADMIN_MFA_PROOF_TTL_MS / 1_000, + }, + ); + return response; +} + +async function refreshedAdminSession( + request: Request, + nativeResponse: Response, + expectedUserId: string, +): Promise { + const session = await requirePermission( + "admin.access", + headersAfterNativeResponse(request, nativeResponse), + ); + if (session.user.id !== expectedUserId) { + throw new MfaOperationError("MFA 会话状态无效", 500); + } + return session; +} + +function requireVerifiedMfa(request: Request, session: AdminSession): void { + const status = readAdminMfaStatus(request, session); + if (!status.enrolled) throw new AdminAuthorizationError("尚未启用 MFA", 403); + if (!status.verified) throw new AdminAuthorizationError("请先完成当前会话的 MFA 验证", 403); +} + +function nativeErrorResponse(error: unknown): NextResponse | null { + if (error instanceof MfaOperationError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + if (error instanceof APIError) { + const status = error.statusCode === 429 ? 429 : error.statusCode >= 500 ? 500 : 400; + const message = status === 429 + ? "MFA 尝试过于频繁,请稍后再试" + : status === 500 + ? "MFA 服务暂时不可用" + : "MFA 凭据无效或已过期"; + return NextResponse.json({ error: message }, { status }); + } + return null; +} + +export async function GET(request: Request) { + try { + const session = await requirePermission("admin.access", request.headers); + return NextResponse.json({ data: readAdminMfaStatus(request, session) }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function POST(request: Request) { + try { + const parsed = mfaSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + + const session = await requireAdminMutation(request, "admin.access"); + const auth: IdentityTwoFactorApi = getIdentityTwoFactorApi(); + const headers = nativeRequestHeaders(request); + + if (parsed.data.action === "enroll") { + if (session.user.twoFactorEnabled) { + return NextResponse.json({ error: "MFA 已启用" }, { status: 409 }); + } + const nativeResponse = await auth.enableTwoFactor({ + body: { password: parsed.data.password }, + headers, + asResponse: true, + }); + const data = await readNativeJson<{ totpURI?: unknown; backupCodes?: unknown }>( + nativeResponse, + "密码错误或无法启用 MFA", + ); + if ( + typeof data.totpURI !== "string" + || !data.totpURI.startsWith("otpauth://") + || !Array.isArray(data.backupCodes) + || data.backupCodes.some((code) => typeof code !== "string") + ) { + throw new MfaOperationError("MFA 服务返回无效数据", 500); + } + return NextResponse.json({ + data: { + totpUri: data.totpURI, + backupCodes: data.backupCodes, + verificationRequired: true, + }, + }); + } + + if (parsed.data.action === "verify") { + const nativeResponse = await auth.verifyTOTP({ + body: { code: parsed.data.code, trustDevice: false }, + headers, + asResponse: true, + }); + await readNativeJson(nativeResponse, "动态验证码错误或已过期"); + const refreshed = await refreshedAdminSession(request, nativeResponse, session.user.id); + if (!refreshed.user.twoFactorEnabled) { + throw new MfaOperationError("MFA enrollment 尚未完成", 500); + } + return mfaProofResponse(request, refreshed, nativeResponse); + } + + if (parsed.data.action === "recover") { + if (!session.user.twoFactorEnabled) { + throw new AdminAuthorizationError("尚未完成 MFA enrollment", 403); + } + const nativeResponse = await auth.verifyBackupCode({ + body: { code: parsed.data.code, disableSession: false }, + headers, + asResponse: true, + }); + await readNativeJson(nativeResponse, "恢复码错误或已使用"); + const refreshed = await refreshedAdminSession(request, nativeResponse, session.user.id); + return mfaProofResponse(request, refreshed, nativeResponse); + } + + requireVerifiedMfa(request, session); + if (parsed.data.action === "regenerate") { + const nativeResponse = await auth.generateBackupCodes({ + body: { password: parsed.data.password }, + headers, + asResponse: true, + }); + const data = await readNativeJson<{ backupCodes?: unknown }>( + nativeResponse, + "密码错误或无法生成恢复码", + ); + if (!Array.isArray(data.backupCodes) || data.backupCodes.some((code) => typeof code !== "string")) { + throw new MfaOperationError("MFA 服务返回无效数据", 500); + } + return NextResponse.json({ data: { backupCodes: data.backupCodes } }); + } + + const nativeResponse = await auth.disableTwoFactor({ + body: { password: parsed.data.password }, + headers, + asResponse: true, + }); + await readNativeJson(nativeResponse, "密码错误或无法禁用 MFA"); + const response = NextResponse.json({ + data: { + required: session.requiresMfa, + enrolled: false, + verified: false, + highRiskWritesEnabled: !session.requiresMfa, + }, + }); + copyNativeCookies(nativeResponse, response); + for (const name of [ + ADMIN_MFA_PROOF_COOKIE, + HIGH_RISK_ADMIN_CHALLENGE_COOKIE, + HIGH_RISK_ADMIN_PROOF_COOKIE, + ]) { + response.cookies.set(name, "", { + httpOnly: true, + sameSite: "strict", + secure: true, + path: "/api/admin", + maxAge: 0, + }); + } + return response; + } catch (error) { + return nativeErrorResponse(error) ?? adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/reauth/route.ts b/frontend/src/app/api/admin/reauth/route.ts new file mode 100644 index 00000000..759a974a --- /dev/null +++ b/frontend/src/app/api/admin/reauth/route.ts @@ -0,0 +1,125 @@ +import { APIError } from "better-auth"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { getIdentityEmailOtpApi } from "@/modules/identity/auth"; +import { AdminAuthorizationError } from "@/lib/admin/auth"; +import { + adminPermissions, + HIGH_RISK_ADMIN_CHALLENGE_COOKIE, + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS, + HIGH_RISK_ADMIN_PROOF_COOKIE, + HIGH_RISK_ADMIN_PROOF_TTL_MS, + issueHighRiskAdminChallenge, + issueHighRiskAdminProof, + verifyHighRiskAdminChallenge, +} from "@/lib/admin/auth-policy"; +import { + adminProofSigningSecret, + adminErrorResponse, + invalidQueryResponse, + requestCookie, + requireAdminMfaIfRequired, + requireAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +const reauthSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("request"), + permission: z.enum(adminPermissions), + }), + z.object({ + action: z.literal("verify"), + permission: z.enum(adminPermissions), + otp: z.string().regex(/^\d{6}$/), + }), +]); + +export async function POST(request: Request) { + try { + const parsed = reauthSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + + const session = await requireAdminMutation(request, parsed.data.permission); + requireAdminMfaIfRequired(request, session); + const auth = getIdentityEmailOtpApi(); + const proofContext = { + userId: session.user.id, + sessionId: session.identitySession.id, + permission: parsed.data.permission, + origin: new URL(request.url).origin, + }; + const proofSecret = adminProofSigningSecret(); + if (parsed.data.action === "request") { + await auth.sendVerificationOTP({ + body: { email: session.user.email, type: "email-verification" }, + }); + const response = NextResponse.json({ data: { sent: true, expiresIn: 300 } }); + response.cookies.set( + HIGH_RISK_ADMIN_CHALLENGE_COOKIE, + issueHighRiskAdminChallenge( + proofContext, + proofSecret, + session.identitySession.token, + ), + { + httpOnly: true, + sameSite: "strict", + secure: true, + path: "/api/admin", + maxAge: HIGH_RISK_ADMIN_CHALLENGE_TTL_MS / 1_000, + }, + ); + return response; + } + + if (!verifyHighRiskAdminChallenge( + requestCookie(request, HIGH_RISK_ADMIN_CHALLENGE_COOKIE), + proofContext, + proofSecret, + session.identitySession.token, + )) { + throw new AdminAuthorizationError("请先请求当前权限的邮箱验证码", 403); + } + + try { + await auth.verifyEmailOTP({ + body: { email: session.user.email, otp: parsed.data.otp }, + }); + } catch (error) { + if (error instanceof APIError && error.statusCode >= 400 && error.statusCode < 500) { + return NextResponse.json({ error: "验证码错误或已过期" }, { status: 400 }); + } + throw error; + } + + const response = NextResponse.json({ data: { verified: true } }); + response.cookies.set( + HIGH_RISK_ADMIN_PROOF_COOKIE, + issueHighRiskAdminProof( + proofContext, + proofSecret, + session.identitySession.token, + ), + { + httpOnly: true, + sameSite: "strict", + secure: true, + path: "/api/admin", + maxAge: HIGH_RISK_ADMIN_PROOF_TTL_MS / 1_000, + }, + ); + response.cookies.set(HIGH_RISK_ADMIN_CHALLENGE_COOKIE, "", { + httpOnly: true, + sameSite: "strict", + secure: true, + path: "/api/admin", + maxAge: 0, + }); + return response; + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/roles/route.ts b/frontend/src/app/api/admin/roles/route.ts new file mode 100644 index 00000000..6e6afc39 --- /dev/null +++ b/frontend/src/app/api/admin/roles/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/lib/admin/auth"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse } from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type RoleRow = { + id: string; + code: string; + name: string; + description: string; + requires_mfa: boolean; + permissions: string[]; +}; + +export async function GET() { + try { + await requirePermission("admin.users.read"); + const rows = await queryAdminRows(` + select r.id, r.code, r.name, r.description, r.requires_mfa, + coalesce(array_agg(p.permission_key order by p.permission_key) + filter (where p.permission_key is not null), '{}') as permissions + from public.admin_roles r + left join public.admin_role_permissions rp on rp.role_id = r.id + left join public.admin_permissions p on p.id = rp.permission_id + group by r.id + order by r.name + `); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + requiresMfa: row.requires_mfa, + permissions: row.permissions, + })), + total: rows.length, + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/session/route.ts b/frontend/src/app/api/admin/session/route.ts index 036a6061..f4d27302 100644 --- a/frontend/src/app/api/admin/session/route.ts +++ b/frontend/src/app/api/admin/session/route.ts @@ -1,19 +1,21 @@ import { NextResponse } from "next/server"; -import { requireAdminSession } from "@/lib/admin/auth"; +import { requirePermission } from "@/lib/admin/auth"; import { adminErrorResponse } from "@/lib/admin/http"; export const runtime = "nodejs"; export async function GET() { try { - const { user, role } = await requireAdminSession(); + const { user, roles, permissions, requiresMfa } = await requirePermission("admin.access"); return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, - role, + roles, + permissions, + requiresMfa, }, }); } catch (error) { diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 63196fc2..0570f487 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,14 +1,24 @@ import { EmailOtpLogin } from "@/components/email-otp-login"; import { readIdentityConfig } from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; export const dynamic = "force-dynamic"; export default async function LoginPage() { const config = readIdentityConfig(process.env); + let successPath: "/" | "/admin" = "/"; + if (config.provider === "self-hosted") { + const surface = resolveIdentitySurface((await headers()).get("host"), config); + if (!surface) notFound(); + successPath = surface === "admin" ? "/admin" : "/"; + } return ( ); } diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index 9e849d7b..66ccc900 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -8,7 +8,8 @@ import { selfHostedAuthActions } from "@/modules/identity/client"; type AuthProvider = "supabase" | "self-hosted"; type AuthMode = "otp" | "password" | "register" | "forgot"; -type AuthStep = "email" | "otp" | "set-password" | "existing"; +type AuthStep = "email" | "otp" | "two-factor" | "set-password" | "existing"; +type MfaMethod = "totp" | "backup-code"; function authMessage(caught: unknown) { const message = caught instanceof Error ? caught.message : "暂时无法登录"; @@ -37,10 +38,12 @@ export function EmailOtpLogin({ provider, passwordEnabled = false, passwordOnly = false, + successPath = "/", }: { provider: AuthProvider; passwordEnabled?: boolean; passwordOnly?: boolean; + successPath?: "/" | "/admin"; }) { const [mode, setMode] = useState(passwordOnly ? "password" : "otp"); const [step, setStep] = useState("email"); @@ -48,15 +51,22 @@ export function EmailOtpLogin({ const [token, setToken] = useState(""); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); + const [mfaMethod, setMfaMethod] = useState("totp"); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const canUsePassword = provider === "self-hosted" && passwordEnabled; const showLoginNavigation = - canUsePassword && !passwordOnly && (mode === "otp" || mode === "password"); + step === "email" + && canUsePassword + && !passwordOnly + && (mode === "otp" || mode === "password"); const showBackToLogin = - canUsePassword && !passwordOnly && (mode === "register" || mode === "forgot"); + step !== "two-factor" + && canUsePassword + && !passwordOnly + && (mode === "register" || mode === "forgot"); function chooseMode(nextMode: AuthMode, nextNotice = "") { setMode(nextMode); @@ -64,6 +74,7 @@ export function EmailOtpLogin({ setToken(""); setPassword(""); setConfirmation(""); + setMfaMethod("totp"); setError(""); setNotice(nextNotice); } @@ -111,7 +122,13 @@ export function EmailOtpLogin({ setError(""); try { if (provider === "self-hosted") { - await selfHostedAuthActions.verify(email, token); + const result = await selfHostedAuthActions.verify(email, token); + if (result.twoFactorRequired) { + setStep("two-factor"); + setToken(""); + setNotice("请输入验证器动态码,或使用一枚未使用的恢复码"); + return; + } const hasPassword = await selfHostedAuthActions.hasPassword(); if (!hasPassword) { setStep("set-password"); @@ -132,7 +149,7 @@ export function EmailOtpLogin({ }); if (otpError) throw otpError; } - window.location.assign("/"); + window.location.assign(successPath); } catch (caught) { if (!(caught instanceof Error)) throw caught; setError(authMessage(caught)); @@ -148,8 +165,30 @@ export function EmailOtpLogin({ setError(""); setNotice(""); try { - await selfHostedAuthActions.signInWithPassword(email, password); - window.location.assign("/"); + const result = await selfHostedAuthActions.signInWithPassword(email, password); + if (result.twoFactorRequired) { + setStep("two-factor"); + setToken(""); + setPassword(""); + setNotice("请输入验证器动态码,或使用一枚未使用的恢复码"); + return; + } + window.location.assign(successPath); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + setBusy(false); + } + } + + async function verifyTwoFactor(event: FormEvent) { + event.preventDefault(); + if (!token || busy) return; + setBusy(true); + setError(""); + try { + await selfHostedAuthActions.verifyTwoFactor(token, mfaMethod); + window.location.assign(successPath); } catch (caught) { if (!(caught instanceof Error)) throw caught; setError(authMessage(caught)); @@ -169,7 +208,7 @@ export function EmailOtpLogin({ setError(""); try { await selfHostedAuthActions.setPassword(password); - window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/"); + window.location.assign(successPath); } catch (caught) { if (!(caught instanceof Error)) throw caught; const message = authMessage(caught); @@ -209,18 +248,23 @@ export function EmailOtpLogin({ setToken(""); setPassword(""); setConfirmation(""); + setMfaMethod("totp"); setError(""); setNotice(""); } const title = - mode === "register" + step === "two-factor" + ? "二步验证" + : mode === "register" ? "注册账号" : mode === "forgot" ? "忘记密码" : "欢迎回来"; const intro = - mode === "password" + step === "two-factor" + ? "此账号已启用 MFA。完成真实第二因素后才会建立登录会话。" + : mode === "password" ? "使用邮箱和密码登录。" : mode === "register" ? "验证邮箱后设置密码,并自动登录。" @@ -369,6 +413,67 @@ export function EmailOtpLogin({ {busy ? "发送中" : "发送验证码"} + ) : step === "two-factor" ? ( +
+
+ + +
+ + { + const value = mfaMethod === "totp" + ? event.target.value.replace(/\D/g, "").slice(0, 6) + : event.target.value.trimStart().slice(0, 128); + setToken(value); + setError(""); + }} + /> + +
) : step === "otp" && mode === "forgot" ? (
window.location.assign("/")} + onClick={() => window.location.assign(successPath)} > 进入首页 diff --git a/frontend/src/lib/admin/auth-policy.ts b/frontend/src/lib/admin/auth-policy.ts index a9bda407..99edfdfc 100644 --- a/frontend/src/lib/admin/auth-policy.ts +++ b/frontend/src/lib/admin/auth-policy.ts @@ -1,18 +1,273 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + import type { IdentityUser } from "@/modules/identity/contracts"; -export type AdminRole = "admin"; +export type AdminRole = + | "owner" + | "model_admin" + | "billing_admin" + | "operations" + | "support" + | "auditor"; + +export const adminPermissions = [ + "admin.access", + "admin.customers.read", + "admin.customers.birth_data.read", + "admin.users.read", + "admin.users.manage_roles", + "billing.products.read", + "billing.products.write", + "billing.products.publish", + "billing.orders.read", + "billing.adjustments.write", + "models.read", + "models.write", + "models.test", + "models.publish", + "models.rollback", + "ops.flags.write", + "audit.read", +] as const; + +export type AdminPermission = (typeof adminPermissions)[number]; export type AdminAccessResult = - | { allowed: true; role: AdminRole } + | { allowed: true } | { allowed: false; status: 401 | 403 }; +export const ADMIN_MFA_PROOF_COOKIE = "jyotisha-admin.mfa"; +export const ADMIN_MFA_PROOF_TTL_MS = 10 * 60 * 1_000; +export const HIGH_RISK_ADMIN_CHALLENGE_COOKIE = "jyotisha-admin.reauth-challenge"; +export const HIGH_RISK_ADMIN_CHALLENGE_TTL_MS = 5 * 60 * 1_000; +export const HIGH_RISK_ADMIN_PROOF_COOKIE = "jyotisha-admin.reauth"; +export const HIGH_RISK_ADMIN_PROOF_TTL_MS = 5 * 60 * 1_000; + +type AdminMfaProofContext = { + userId: string; + sessionId: string; + origin: string; +}; + +type AdminMfaProofClaims = AdminMfaProofContext & { + version: 1; + issuedAt: number; + expiresAt: number; +}; + +type HighRiskAdminProofContext = AdminMfaProofContext & { + permission: AdminPermission; +}; + +type HighRiskAdminProofClaims = HighRiskAdminProofContext & { + version: 1; + issuedAt: number; + expiresAt: number; +}; + +export type AdminMfaStatus = { + required: boolean; + enrolled: boolean; + verified: boolean; + highRiskWritesEnabled: boolean; +}; + export function authorizeAdminAccess( user: IdentityUser | null, - access: "read" | "write", + permissions: readonly string[], + required: AdminPermission, ): AdminAccessResult { if (!user) return { allowed: false, status: 401 }; - if (!user.role.includes("admin")) { - return { allowed: false, status: 403 }; + return permissions.includes(required) + ? { allowed: true } + : { allowed: false, status: 403 }; +} + +export function isSameOriginAdminMutation( + origin: string | null, + requestUrl: string, +): boolean { + if (!origin) return false; + try { + return origin === new URL(requestUrl).origin; + } catch { + return false; } - return { allowed: true, role: "admin" }; +} + +export function resolveAdminMfaStatus( + required: boolean, + enrolled: boolean, + verified: boolean, +): AdminMfaStatus { + const currentSessionVerified = enrolled && verified; + return { + required, + enrolled, + verified: currentSessionVerified, + highRiskWritesEnabled: !required || currentSessionVerified, + }; +} + +function signProof( + purpose: string, + payload: string, + proofSecret: string, + sessionToken: string, +): Buffer { + return createHmac("sha256", proofSecret) + .update(purpose) + .update("\0") + .update(sessionToken) + .update("\0") + .update(payload) + .digest(); +} + +function encodeProof( + purpose: string, + claims: AdminMfaProofClaims | HighRiskAdminProofClaims, + proofSecret: string, + sessionToken: string, +): string { + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `${payload}.${signProof(purpose, payload, proofSecret, sessionToken).toString("base64url")}`; +} + +function decodeProof( + proof: string | undefined, + purpose: string, + proofSecret: string, + sessionToken: string, +): Partial | null { + if (!proof || proof.length > 2_048) return null; + const parts = proof.split("."); + if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null; + + const [payload, encodedSignature] = parts; + const signature = Buffer.from(encodedSignature, "base64url"); + const expected = signProof(purpose, payload, proofSecret, sessionToken); + if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null; + + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Partial; + } catch { + return null; + } +} + +export function issueAdminMfaProof( + context: AdminMfaProofContext, + proofSecret: string, + sessionToken: string, + now = Date.now(), +): string { + return encodeProof("jyotisha-admin-mfa-v1", { + version: 1, + ...context, + issuedAt: now, + expiresAt: now + ADMIN_MFA_PROOF_TTL_MS, + } satisfies AdminMfaProofClaims, proofSecret, sessionToken); +} + +export function verifyAdminMfaProof( + proof: string | undefined, + context: AdminMfaProofContext, + proofSecret: string, + sessionToken: string, + now = Date.now(), +): boolean { + const claims = decodeProof( + proof, + "jyotisha-admin-mfa-v1", + proofSecret, + sessionToken, + ); + return claims?.version === 1 + && claims.userId === context.userId + && claims.sessionId === context.sessionId + && claims.origin === context.origin + && Number.isSafeInteger(claims.issuedAt) + && Number.isSafeInteger(claims.expiresAt) + && claims.expiresAt! - claims.issuedAt! === ADMIN_MFA_PROOF_TTL_MS + && claims.issuedAt! <= now + && now < claims.expiresAt!; +} + +export function issueHighRiskAdminChallenge( + context: HighRiskAdminProofContext, + proofSecret: string, + sessionToken: string, + now = Date.now(), +): string { + return encodeProof("jyotisha-admin-reauth-challenge-v1", { + version: 1, + ...context, + issuedAt: now, + expiresAt: now + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS, + } satisfies HighRiskAdminProofClaims, proofSecret, sessionToken); +} + +export function verifyHighRiskAdminChallenge( + proof: string | undefined, + context: HighRiskAdminProofContext, + proofSecret: string, + sessionToken: string, + now = Date.now(), +): boolean { + const claims = decodeProof( + proof, + "jyotisha-admin-reauth-challenge-v1", + proofSecret, + sessionToken, + ); + return claims?.version === 1 + && claims.userId === context.userId + && claims.sessionId === context.sessionId + && claims.permission === context.permission + && claims.origin === context.origin + && Number.isSafeInteger(claims.issuedAt) + && Number.isSafeInteger(claims.expiresAt) + && claims.expiresAt! - claims.issuedAt! === HIGH_RISK_ADMIN_CHALLENGE_TTL_MS + && claims.issuedAt! <= now + && now < claims.expiresAt!; +} + +export function issueHighRiskAdminProof( + context: HighRiskAdminProofContext, + proofSecret: string, + sessionToken: string, + now = Date.now(), +): string { + return encodeProof("jyotisha-admin-reauth-v1", { + version: 1, + ...context, + issuedAt: now, + expiresAt: now + HIGH_RISK_ADMIN_PROOF_TTL_MS, + } satisfies HighRiskAdminProofClaims, proofSecret, sessionToken); +} + +export function verifyHighRiskAdminProof( + proof: string | undefined, + context: HighRiskAdminProofContext, + proofSecret: string, + sessionToken: string, + now = Date.now(), +): boolean { + const claims = decodeProof( + proof, + "jyotisha-admin-reauth-v1", + proofSecret, + sessionToken, + ); + return claims?.version === 1 + && claims.userId === context.userId + && claims.sessionId === context.sessionId + && claims.permission === context.permission + && claims.origin === context.origin + && Number.isSafeInteger(claims.issuedAt) + && Number.isSafeInteger(claims.expiresAt) + && claims.expiresAt! - claims.issuedAt! === HIGH_RISK_ADMIN_PROOF_TTL_MS + && claims.issuedAt! <= now + && now < claims.expiresAt!; } diff --git a/frontend/src/lib/admin/auth.ts b/frontend/src/lib/admin/auth.ts index 57501f19..92efc2c9 100644 --- a/frontend/src/lib/admin/auth.ts +++ b/frontend/src/lib/admin/auth.ts @@ -3,45 +3,103 @@ import "server-only"; import { headers } from "next/headers"; import { getIdentityAuthServices } from "@/modules/identity/auth"; +import { readSelfHostedIdentityConfig } from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; import { IdentityAuthorizationError, - requireIdentityUser, + requireIdentityServerSession, } from "@/modules/identity/session"; import type { IdentityUser } from "@/modules/identity/contracts"; -import { authorizeAdminAccess, type AdminRole } from "./auth-policy"; +import { + authorizeAdminAccess, + type AdminPermission, + type AdminRole, +} from "./auth-policy"; +import { queryAdminRows } from "./database"; -export type { AdminRole } from "./auth-policy"; +export type { AdminPermission, AdminRole } from "./auth-policy"; + +type PermissionRow = { + permission_key: string; + role_code: AdminRole; + requires_mfa: boolean; +}; + +export type AdminSession = { + user: IdentityUser; + roles: AdminRole[]; + permissions: AdminPermission[]; + requiresMfa: boolean; + identitySession: { + id: string; + token: string; + expiresAt: Date; + }; +}; export class AdminAuthorizationError extends Error { constructor( message: string, - readonly status: 401 | 403, + readonly status: 401 | 403 | 503, ) { super(message); this.name = "AdminAuthorizationError"; } } -export async function requireAdminSession( - access: "read" | "write" = "read", -): Promise<{ user: IdentityUser; role: AdminRole }> { - if ( - process.env.AUTH_PROVIDER?.trim() !== "self-hosted" - || process.env.APP_ENV?.trim() === "production" - ) { +async function loadAdminSession( + user: IdentityUser, + identitySession: AdminSession["identitySession"], +): Promise { + const rows = await queryAdminRows( + "select permission_key, role_code, requires_mfa from public.admin_permission_keys($1)", + [user.id], + ); + const roles = [...new Set(rows.map((row) => row.role_code))]; + const permissions = [...new Set(rows.map((row) => row.permission_key))] as AdminPermission[]; + return { + user, + roles, + permissions, + requiresMfa: rows.some((row) => row.requires_mfa), + identitySession, + }; +} + +export async function requirePermission( + permission: AdminPermission = "admin.access", + requestHeaders?: Headers, +): Promise { + if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") { throw new AdminAuthorizationError("后台身份服务未启用", 403); } + const adminHeaders = requestHeaders ?? new Headers(await headers()); + const identityConfig = readSelfHostedIdentityConfig(process.env); + if (resolveIdentitySurface(adminHeaders.get("host"), identityConfig) !== "admin") { + throw new AdminAuthorizationError("无权访问后台", 403); + } + try { - const user = await requireIdentityUser( + const identitySession = await requireIdentityServerSession( getIdentityAuthServices().user.api, - new Headers(await headers()), + adminHeaders, + ); + const session = await loadAdminSession(identitySession.user, { + id: identitySession.sessionId, + token: identitySession.sessionToken, + expiresAt: identitySession.expiresAt, + }); + const user = identitySession.user; + const authorization = authorizeAdminAccess( + user, + session.permissions, + permission, ); - const authorization = authorizeAdminAccess(user, access); if (!authorization.allowed) { throw new AdminAuthorizationError("无权执行此操作", authorization.status); } - return { user, role: authorization.role }; + return session; } catch (error) { if (error instanceof AdminAuthorizationError) throw error; if (error instanceof IdentityAuthorizationError) { @@ -53,3 +111,9 @@ export async function requireAdminSession( throw error; } } + +export function requireAdminSession( + access: "read" | "write" = "read", +): Promise { + return requirePermission(access === "read" ? "admin.access" : "admin.users.manage_roles"); +} diff --git a/frontend/src/lib/admin/database.ts b/frontend/src/lib/admin/database.ts index 3adeac35..4119af1e 100644 --- a/frontend/src/lib/admin/database.ts +++ b/frontend/src/lib/admin/database.ts @@ -9,11 +9,8 @@ const poolGlobal = globalThis as typeof globalThis & { }; export function adminDatabasePool(): Pool { - if ( - process.env.AUTH_PROVIDER?.trim() !== "self-hosted" - || process.env.APP_ENV?.trim() === "production" - ) { - throw new Error("admin database requests require the staging self-hosted identity service"); + if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") { + throw new Error("admin database requests require the self-hosted identity service"); } poolGlobal.jyotishaAdminDatabasePool ??= new Pool({ connectionString: readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"), diff --git a/frontend/src/lib/admin/http.ts b/frontend/src/lib/admin/http.ts index ac5a22a3..7939b997 100644 --- a/frontend/src/lib/admin/http.ts +++ b/frontend/src/lib/admin/http.ts @@ -1,7 +1,22 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { AdminAuthorizationError } from "./auth"; +import { + AdminAuthorizationError, + requirePermission, + type AdminPermission, + type AdminSession, +} from "./auth"; +import { + ADMIN_MFA_PROOF_COOKIE, + HIGH_RISK_ADMIN_PROOF_COOKIE, + isSameOriginAdminMutation, + resolveAdminMfaStatus, + verifyAdminMfaProof, + verifyHighRiskAdminProof, + type AdminMfaStatus, +} from "./auth-policy"; +import { isPostgresError } from "./database"; export const listQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -29,12 +44,103 @@ export function adminErrorResponse(error: unknown) { if (error instanceof AdminAuthorizationError) { return NextResponse.json({ error: error.message }, { status: error.status }); } + if (isPostgresError(error)) { + if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 }); + if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 }); + if (error.code === "22023" || error.code === "23514" || error.code === "23505") { + return NextResponse.json({ error: "提交内容不符合业务约束" }, { status: 400 }); + } + } return NextResponse.json( { error: "后台服务暂时不可用" }, { status: 500 }, ); } +export async function requireAdminMutation(request: Request, permission: AdminPermission) { + if (!isSameOriginAdminMutation(request.headers.get("origin"), request.url)) { + throw new AdminAuthorizationError("请求来源不可信", 403); + } + return requirePermission(permission, request.headers); +} + +export function requestCookie(request: Request, name: string): string | undefined { + for (const part of request.headers.get("cookie")?.split(";") ?? []) { + const separator = part.indexOf("="); + if (separator > 0 && part.slice(0, separator).trim() === name) { + return part.slice(separator + 1).trim(); + } + } +} + +export function adminProofSigningSecret( + env: NodeJS.ProcessEnv = process.env, +): string { + const secret = env.BETTER_AUTH_USER_SECRET?.trim(); + if (!secret || secret.length < 32) { + throw new AdminAuthorizationError("MFA 服务暂时不可用", 503); + } + return secret; +} + +export function readAdminMfaStatus( + request: Request, + session: AdminSession, +): AdminMfaStatus { + const enrolled = session.user.twoFactorEnabled === true; + const verified = enrolled && verifyAdminMfaProof( + requestCookie(request, ADMIN_MFA_PROOF_COOKIE), + { + userId: session.user.id, + sessionId: session.identitySession.id, + origin: new URL(request.url).origin, + }, + adminProofSigningSecret(), + session.identitySession.token, + ); + return resolveAdminMfaStatus(session.requiresMfa, enrolled, verified); +} + +export function requireAdminMfaIfRequired( + request: Request, + session: AdminSession, +): AdminMfaStatus { + const status = readAdminMfaStatus(request, session); + if (!status.required) return status; + if (!status.enrolled) { + throw new AdminAuthorizationError("此管理员角色必须先启用 MFA", 403); + } + if (!status.verified) { + throw new AdminAuthorizationError("请先完成当前会话的 MFA 验证", 403); + } + return status; +} + +export async function requireHighRiskAdminMutation( + request: Request, + permission: AdminPermission, +): Promise { + const session = await requireAdminMutation(request, permission); + requireAdminMfaIfRequired(request, session); + + const origin = new URL(request.url).origin; + const valid = verifyHighRiskAdminProof( + requestCookie(request, HIGH_RISK_ADMIN_PROOF_COOKIE), + { + userId: session.user.id, + sessionId: session.identitySession.id, + permission, + origin, + }, + adminProofSigningSecret(), + session.identitySession.token, + ); + if (!valid) { + throw new AdminAuthorizationError("请先使用邮箱验证码重新认证", 403); + } + return session; +} + export function invalidQueryResponse(details?: unknown) { return NextResponse.json( { error: "查询参数不正确", ...(details ? { details } : {}) }, diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index c166e769..c2479ebe 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -14,6 +14,8 @@ type Filter = | Readonly<{ kind: "eq"; column: string; value: unknown }> | Readonly<{ kind: "neq"; column: string; value: unknown }> | Readonly<{ kind: "gt"; column: string; value: unknown }> + | Readonly<{ kind: "lte"; column: string; value: unknown }> + | Readonly<{ kind: "like"; column: string; value: unknown }> | Readonly<{ kind: "in"; column: string; value: readonly unknown[] }> | Readonly<{ kind: "is"; column: string; value: unknown }> | Readonly<{ kind: "notContains"; column: string; value: unknown }>; @@ -21,14 +23,19 @@ type Filter = type Mutation = | Readonly<{ kind: "insert"; rows: readonly Record[] }> | Readonly<{ kind: "update"; values: Record }> - | Readonly<{ kind: "upsert"; rows: readonly Record[]; conflict: readonly string[] }> + | Readonly<{ + kind: "upsert"; + rows: readonly Record[]; + conflict: readonly string[]; + }> | Readonly<{ kind: "delete"; exactCount: boolean }>; const identifierPattern = /^[a-z_][a-z0-9_]*$/; function identifier(value: string): string { const normalized = value.trim(); - if (!identifierPattern.test(normalized)) throw new Error("unsafe database identifier"); + if (!identifierPattern.test(normalized)) + throw new Error("unsafe database identifier"); return `"${normalized}"`; } @@ -40,7 +47,9 @@ function queryError(error: unknown): QueryError { }; } -function records(value: Record | readonly Record[]) { +function records( + value: Record | readonly Record[], +) { return Array.isArray(value) ? value : [value]; } @@ -65,16 +74,23 @@ function localDataPool(connectionString: string): Pool { return pool; } +export async function closeLocalPostgresDataPools(): Promise { + const pools = poolGlobal.jyotishaLocalDataPools; + poolGlobal.jyotishaLocalDataPools = new Map(); + if (!pools) return; + await Promise.all([...pools.values()].map((pool) => pool.end())); +} + async function inBusinessTransaction( pool: Pool, identity: LocalIdentity, - role: LocalDatabaseRole, + role: LocalDatabaseRole | null, run: (client: PoolClient) => Promise, ): Promise { const client = await pool.connect(); try { await client.query("begin"); - await client.query(`set local role ${role}`); + if (role !== null) await client.query(`set local role ${role}`); await client.query( "select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)", [identity?.id ?? "", identity?.email ?? ""], @@ -116,7 +132,8 @@ class LocalPostgresQueryBuilder implements PromiseLike { private selectedColumns: string[] | null = null; private mutation: Mutation | null = null; private readonly filters: Filter[] = []; - private ordering: Readonly<{ column: string; ascending: boolean }> | null = null; + private ordering: Readonly<{ column: string; ascending: boolean }> | null = + null; private rowLimit: number | null = null; private abort: AbortSignal | null = null; private cardinality: "many" | "single" | "maybeSingle" = "many"; @@ -124,16 +141,20 @@ class LocalPostgresQueryBuilder implements PromiseLike { constructor( private readonly pool: Pool, private readonly identity: LocalIdentity, - private readonly role: LocalDatabaseRole, + private readonly role: LocalDatabaseRole | null, private readonly table: string, ) { identifier(table); } select(columns = "*") { - this.selectedColumns = columns === "*" - ? ["*"] - : columns.split(",").map((column) => column.trim()).filter(Boolean); + this.selectedColumns = + columns === "*" + ? ["*"] + : columns + .split(",") + .map((column) => column.trim()) + .filter(Boolean); for (const column of this.selectedColumns) { if (column !== "*") identifier(column); } @@ -149,7 +170,9 @@ class LocalPostgresQueryBuilder implements PromiseLike { value: Record | readonly Record[], options: { onConflict: string }, ) { - const conflict = options.onConflict.split(",").map((column) => column.trim()); + const conflict = options.onConflict + .split(",") + .map((column) => column.trim()); conflict.forEach(identifier); this.mutation = { kind: "upsert", rows: records(value), conflict }; return this; @@ -183,6 +206,18 @@ class LocalPostgresQueryBuilder implements PromiseLike { return this; } + lte(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "lte", column, value }); + return this; + } + + like(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "like", column, value }); + return this; + } + in(column: string, value: readonly unknown[]) { identifier(column); this.filters.push({ kind: "in", column, value }); @@ -209,7 +244,8 @@ class LocalPostgresQueryBuilder implements PromiseLike { } limit(value: number) { - if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid row limit"); + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("invalid row limit"); this.rowLimit = value; return this; } @@ -230,7 +266,8 @@ class LocalPostgresQueryBuilder implements PromiseLike { } then( - onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onfulfilled?: + ((value: QueryResult) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): PromiseLike { return this.execute().then(onfulfilled, onrejected); @@ -238,10 +275,13 @@ class LocalPostgresQueryBuilder implements PromiseLike { private returningClause(): string { if (!this.selectedColumns) return ""; - return ` returning ${this.selectedColumns.map((column) => column === "*" ? "*" : identifier(column)).join(", ")}`; + return ` returning ${this.selectedColumns.map((column) => (column === "*" ? "*" : identifier(column))).join(", ")}`; } - private filterClause(parameters: unknown[], types: Map): string { + private filterClause( + parameters: unknown[], + types: Map, + ): string { if (this.filters.length === 0) return ""; const parts = this.filters.map((filter) => { const column = identifier(filter.column); @@ -264,7 +304,17 @@ class LocalPostgresQueryBuilder implements PromiseLike { return `not (${column} @> $${parameters.length})`; } parameters.push(databaseValue(types.get(filter.column), filter.value)); - return `${column} ${filter.kind === "neq" ? "<>" : filter.kind === "gt" ? ">" : "="} $${parameters.length}`; + const operator = + filter.kind === "neq" + ? "<>" + : filter.kind === "gt" + ? ">" + : filter.kind === "lte" + ? "<=" + : filter.kind === "like" + ? "like" + : "="; + return `${column} ${operator} $${parameters.length}`; }); return ` where ${parts.join(" and ")}`; } @@ -274,75 +324,116 @@ class LocalPostgresQueryBuilder implements PromiseLike { return { data: null, error: { message: "AbortError" } }; } try { - return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => { - const types = await columnTypes(client, this.table); - const parameters: unknown[] = []; - let sql: string; + return await inBusinessTransaction( + this.pool, + this.identity, + this.role, + async (client) => { + const types = await columnTypes(client, this.table); + const parameters: unknown[] = []; + let sql: string; - if (!this.mutation) { - const selected = (this.selectedColumns ?? ["*"]) - .map((column) => column === "*" ? "*" : identifier(column)) - .join(", "); - sql = `select ${selected} from public.${identifier(this.table)}`; - sql += this.filterClause(parameters, types); - if (this.ordering) { - sql += ` order by ${identifier(this.ordering.column)} ${this.ordering.ascending ? "asc" : "desc"}`; + if (!this.mutation) { + const selected = (this.selectedColumns ?? ["*"]) + .map((column) => (column === "*" ? "*" : identifier(column))) + .join(", "); + sql = `select ${selected} from public.${identifier(this.table)}`; + sql += this.filterClause(parameters, types); + if (this.ordering) { + sql += ` order by ${identifier(this.ordering.column)} ${this.ordering.ascending ? "asc" : "desc"}`; + } + if (this.rowLimit !== null) sql += ` limit ${this.rowLimit}`; + } else if ( + this.mutation.kind === "insert" || + this.mutation.kind === "upsert" + ) { + const rows = this.mutation.rows; + if (rows.length === 0) + return { data: this.selectedColumns ? [] : null, error: null }; + const columns = Object.keys(rows[0] ?? {}); + if ( + columns.length === 0 || + rows.some( + (row) => Object.keys(row).join("\0") !== columns.join("\0"), + ) + ) { + throw new Error("inconsistent insert rows"); + } + columns.forEach(identifier); + const valueGroups = rows.map( + (row) => + `(${columns + .map((column) => { + parameters.push( + databaseValue(types.get(column), row[column]), + ); + return `$${parameters.length}`; + }) + .join(", ")})`, + ); + sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`; + if (this.mutation.kind === "upsert") { + const updates = columns.filter( + (column) => + !this.mutation || + this.mutation.kind !== "upsert" || + !this.mutation.conflict.includes(column), + ); + sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${ + updates.length === 0 + ? "nothing" + : `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}` + }`; + } + sql += this.returningClause(); + } else if (this.mutation.kind === "update") { + const columns = Object.keys(this.mutation.values); + if (columns.length === 0) throw new Error("empty update"); + const assignments = columns.map((column) => { + identifier(column); + parameters.push( + databaseValue( + types.get(column), + this.mutation && this.mutation.kind === "update" + ? this.mutation.values[column] + : null, + ), + ); + return `${identifier(column)} = $${parameters.length}`; + }); + sql = `update public.${identifier(this.table)} set ${assignments.join(", ")}`; + sql += this.filterClause(parameters, types); + sql += this.returningClause(); + } else { + sql = `delete from public.${identifier(this.table)}`; + sql += this.filterClause(parameters, types); + sql += this.returningClause(); } - if (this.rowLimit !== null) sql += ` limit ${this.rowLimit}`; - } else if (this.mutation.kind === "insert" || this.mutation.kind === "upsert") { - const rows = this.mutation.rows; - if (rows.length === 0) return { data: this.selectedColumns ? [] : null, error: null }; - const columns = Object.keys(rows[0] ?? {}); - if (columns.length === 0 || rows.some((row) => Object.keys(row).join("\0") !== columns.join("\0"))) { - throw new Error("inconsistent insert rows"); - } - columns.forEach(identifier); - const valueGroups = rows.map((row) => `(${columns.map((column) => { - parameters.push(databaseValue(types.get(column), row[column])); - return `$${parameters.length}`; - }).join(", ")})`); - sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`; - if (this.mutation.kind === "upsert") { - const updates = columns.filter((column) => !this.mutation || this.mutation.kind !== "upsert" || !this.mutation.conflict.includes(column)); - sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${updates.length === 0 - ? "nothing" - : `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}`}`; - } - sql += this.returningClause(); - } else if (this.mutation.kind === "update") { - const columns = Object.keys(this.mutation.values); - if (columns.length === 0) throw new Error("empty update"); - const assignments = columns.map((column) => { - identifier(column); - parameters.push(databaseValue(types.get(column), this.mutation && this.mutation.kind === "update" ? this.mutation.values[column] : null)); - return `${identifier(column)} = $${parameters.length}`; - }); - sql = `update public.${identifier(this.table)} set ${assignments.join(", ")}`; - sql += this.filterClause(parameters, types); - sql += this.returningClause(); - } else { - sql = `delete from public.${identifier(this.table)}`; - sql += this.filterClause(parameters, types); - sql += this.returningClause(); - } - const result = await client.query(sql, parameters); - const rows = result.rows; - let data: unknown = this.selectedColumns ? rows : null; - if (this.cardinality !== "many") { - if (rows.length > 1 || (this.cardinality === "single" && rows.length !== 1)) { - return { data: null, error: { code: "PGRST116", message: "unexpected row count" } }; + const result = await client.query(sql, parameters); + const rows = result.rows; + let data: unknown = this.selectedColumns ? rows : null; + if (this.cardinality !== "many") { + if ( + rows.length > 1 || + (this.cardinality === "single" && rows.length !== 1) + ) { + return { + data: null, + error: { code: "PGRST116", message: "unexpected row count" }, + }; + } + data = rows[0] ?? null; } - data = rows[0] ?? null; - } - return { - data, - error: null, - ...(this.mutation?.kind === "delete" && this.mutation.exactCount - ? { count: result.rowCount ?? 0 } - : {}), - }; - }); + return { + data, + error: null, + ...(this.mutation?.kind === "delete" && this.mutation.exactCount + ? { count: result.rowCount ?? 0 } + : {}), + }; + }, + ); } catch (error) { return { data: null, error: queryError(error), count: null }; } @@ -387,16 +478,19 @@ async function functionMetadata( } function castType(value: string): string { - if (!/^[a-z0-9_ .\[\]]+$/.test(value)) throw new Error("unsafe database type"); + if (!/^[a-z0-9_ .\[\]]+$/.test(value)) + throw new Error("unsafe database type"); return value; } export class LocalPostgresDataClient { readonly auth: Readonly<{ - getUser: () => Promise>; + getUser: () => Promise< + Readonly<{ + data: { user: LocalIdentity }; + error: null; + }> + >; }>; private readonly pool: Pool; @@ -404,9 +498,9 @@ export class LocalPostgresDataClient { constructor( connectionString: string, private readonly identity: LocalIdentity, - private readonly role: LocalDatabaseRole, + private readonly role: LocalDatabaseRole | null, ) { - if (role !== "authenticated" && role !== "service_role") { + if (role !== null && role !== "authenticated" && role !== "service_role") { throw new Error("unsupported database role"); } this.pool = localDataPool(connectionString); @@ -416,33 +510,55 @@ export class LocalPostgresDataClient { } from(table: string) { - return new LocalPostgresQueryBuilder(this.pool, this.identity, this.role, table); + return new LocalPostgresQueryBuilder( + this.pool, + this.identity, + this.role, + table, + ); } - async rpc(functionName: string, args: Readonly> = {}) { + async rpc( + functionName: string, + args: Readonly> = {}, + ) { try { identifier(functionName); - return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => { - const names = Object.keys(args); - names.forEach(identifier); - const metadata = await functionMetadata(client, functionName, names); - const typeByName = new Map( - (metadata.argument_names ?? []).map((name, index) => [name, metadata.argument_types[index]]), - ); - const parameters = names.map((name) => - databaseValue(typeByName.get(name), args[name])); - const call = names.map((name, index) => - `${identifier(name)} => $${index + 1}::${castType(typeByName.get(name) ?? "text")}`, - ).join(", "); - const sql = metadata.proretset - ? `select * from public.${identifier(functionName)}(${call})` - : `select public.${identifier(functionName)}(${call}) as value`; - const result = await client.query(sql, parameters); - return { - data: metadata.proretset ? result.rows : result.rows[0]?.value ?? null, - error: null, - }; - }); + return await inBusinessTransaction( + this.pool, + this.identity, + this.role, + async (client) => { + const names = Object.keys(args); + names.forEach(identifier); + const metadata = await functionMetadata(client, functionName, names); + const typeByName = new Map( + (metadata.argument_names ?? []).map((name, index) => [ + name, + metadata.argument_types[index], + ]), + ); + const parameters = names.map((name) => + databaseValue(typeByName.get(name), args[name]), + ); + const call = names + .map( + (name, index) => + `${identifier(name)} => $${index + 1}::${castType(typeByName.get(name) ?? "text")}`, + ) + .join(", "); + const sql = metadata.proretset + ? `select * from public.${identifier(functionName)}(${call})` + : `select public.${identifier(functionName)}(${call}) as value`; + const result = await client.query(sql, parameters); + return { + data: metadata.proretset + ? result.rows + : (result.rows[0]?.value ?? null), + error: null, + }; + }, + ); } catch (error) { return { data: null, error: queryError(error) }; } @@ -452,7 +568,7 @@ export class LocalPostgresDataClient { export function createLocalPostgresDataClient( connectionString: string, identity: LocalIdentity = null, - role: LocalDatabaseRole = "authenticated", + role: LocalDatabaseRole | null = "authenticated", ) { return new LocalPostgresDataClient(connectionString, identity, role); } diff --git a/frontend/src/lib/supabase/admin-client-core.ts b/frontend/src/lib/supabase/admin-client-core.ts index 8286bd75..2a462b90 100644 --- a/frontend/src/lib/supabase/admin-client-core.ts +++ b/frontend/src/lib/supabase/admin-client-core.ts @@ -1,6 +1,5 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core"; -import { readDatabaseUrl } from "@/lib/db/config"; import { getSupabaseUrl, SupabaseConfigurationError, @@ -8,8 +7,15 @@ import { export function createAdminSupabaseClient() { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { + const connectionString = process.env.SERVICE_DATABASE_URL?.trim(); + if (!connectionString) { + throw new SupabaseConfigurationError(["SERVICE_DATABASE_URL"]); + } + if (!connectionString.startsWith("postgresql://")) { + throw new Error("SERVICE_DATABASE_URL must be a PostgreSQL URL"); + } return createLocalPostgresDataClient( - readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"), + connectionString, null, "service_role", ) as unknown as SupabaseClient; diff --git a/frontend/src/lib/supabase/admin.ts b/frontend/src/lib/supabase/admin.ts index 7b4e1da5..95d6be16 100644 --- a/frontend/src/lib/supabase/admin.ts +++ b/frontend/src/lib/supabase/admin.ts @@ -12,14 +12,11 @@ export async function isAdminUser(user: { id?: string; email?: string | null }) if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { if (!user.id) return false; try { - const rows = await queryAdminRows<{ role: string }>( - "select role from identity.users where id = $1 limit 1", + const rows = await queryAdminRows<{ allowed: boolean }>( + "select public.admin_has_permission($1, 'admin.access') as allowed", [user.id], ); - return rows[0]?.role - .split(",") - .map((role) => role.trim()) - .some((role) => role === "admin") ?? false; + return rows[0]?.allowed ?? false; } catch { return false; } diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts index f82cd940..9c1ccc96 100644 --- a/frontend/src/modules/identity/auth-factory.ts +++ b/frontend/src/modules/identity/auth-factory.ts @@ -1,7 +1,7 @@ import { createHmac } from "node:crypto"; import type { Pool } from "pg"; import type { BetterAuthOptions } from "better-auth"; -import { admin, emailOTP, type EmailOTPOptions } from "better-auth/plugins"; +import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins"; import type { SelfHostedIdentityConfig } from "./config.ts"; import type { EmailOtpSender } from "./contracts.ts"; @@ -66,7 +66,7 @@ export function buildAuthOptions({ basePath: "/api/auth", secret: config.userSecret, database, - trustedOrigins: [config.userOrigin], + trustedOrigins: [config.userOrigin, config.adminOrigin], telemetry: { enabled: false }, user: identityModelMapping.user, session: identityModelMapping.session, @@ -97,6 +97,17 @@ export function buildAuthOptions({ }, plugins: [ emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false)), + twoFactor({ + issuer: "Jyotisha Admin", + twoFactorTable: "two_factors", + twoFactorCookieMaxAge: 300, + accountLockout: { + enabled: true, + maxFailedAttempts: 5, + durationSeconds: 900, + }, + schema: identityModelMapping.twoFactor, + }), admin({ defaultRole: "user", adminRoles: ["admin"], diff --git a/frontend/src/modules/identity/auth.ts b/frontend/src/modules/identity/auth.ts index e315c283..584f2544 100644 --- a/frontend/src/modules/identity/auth.ts +++ b/frontend/src/modules/identity/auth.ts @@ -104,6 +104,56 @@ export function createIdentityAuthServices( }; } + +export interface IdentityEmailOtpApi { + sendVerificationOTP(input: { + body: { email: string; type: "email-verification" }; + }): Promise<{ success: boolean }>; + verifyEmailOTP(input: { + body: { email: string; otp: string }; + }): Promise; +} + +export function getIdentityEmailOtpApi( + env: NodeJS.ProcessEnv = process.env, +): IdentityEmailOtpApi { + return getIdentityAuthServices(env).user.api as unknown as IdentityEmailOtpApi; +} + +export interface IdentityTwoFactorApi { + enableTwoFactor(input: { + body: { password: string }; + headers: Headers; + asResponse: true; + }): Promise; + verifyTOTP(input: { + body: { code: string; trustDevice?: boolean }; + headers: Headers; + asResponse: true; + }): Promise; + verifyBackupCode(input: { + body: { code: string; disableSession?: boolean }; + headers: Headers; + asResponse: true; + }): Promise; + generateBackupCodes(input: { + body: { password: string }; + headers: Headers; + asResponse: true; + }): Promise; + disableTwoFactor(input: { + body: { password: string }; + headers: Headers; + asResponse: true; + }): Promise; +} + +export function getIdentityTwoFactorApi( + env: NodeJS.ProcessEnv = process.env, +): IdentityTwoFactorApi { + return getIdentityAuthServices(env).user.api as unknown as IdentityTwoFactorApi; +} + const identityGlobal = globalThis as typeof globalThis & { jyotishaIdentityAuth?: IdentityAuthServices; }; diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts index 75bd492a..c0b587a5 100644 --- a/frontend/src/modules/identity/client.ts +++ b/frontend/src/modules/identity/client.ts @@ -1,5 +1,5 @@ import { createAuthClient } from "better-auth/react"; -import { emailOTPClient } from "better-auth/client/plugins"; +import { emailOTPClient, twoFactorClient } from "better-auth/client/plugins"; interface AuthClientResult { data: unknown; @@ -29,13 +29,28 @@ export interface SelfHostedAuthClient { password: string; }): Promise; }; + twoFactor?: { + verifyTotp?(input: { + code: string; + trustDevice?: boolean; + }): Promise; + verifyBackupCode?(input: { + code: string; + disableSession?: boolean; + }): Promise; + }; signOut?(): Promise; } +export type SelfHostedSignInResult = { + twoFactorRequired: boolean; +}; + export interface SelfHostedAuthActions { send(email: string): Promise; - verify(email: string, otp: string): Promise; - signInWithPassword(email: string, password: string): Promise; + verify(email: string, otp: string): Promise; + signInWithPassword(email: string, password: string): Promise; + verifyTwoFactor(code: string, method: "totp" | "backup-code"): Promise; requestPasswordReset(email: string): Promise; resetPassword(email: string, otp: string, password: string): Promise; hasPassword(): Promise; @@ -49,6 +64,17 @@ function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } +function signInResult(result: AuthClientResult): SelfHostedSignInResult { + const data = result.data; + return { + twoFactorRequired: + typeof data === "object" + && data !== null + && "twoFactorRedirect" in data + && data.twoFactorRedirect === true, + }; +} + export function createSelfHostedAuthActions( client: SelfHostedAuthClient, fetcher: Fetcher = fetch, @@ -71,6 +97,7 @@ export function createSelfHostedAuthActions( if (result.error) { throw new Error("验证码错误或已过期,请重新获取"); } + return signInResult(result); }, async signInWithPassword(email, password) { if (!client.signIn.email) throw new Error("邮箱或密码错误"); @@ -79,6 +106,15 @@ export function createSelfHostedAuthActions( password, }); if (result.error) throw new Error("邮箱或密码错误"); + return signInResult(result); + }, + async verifyTwoFactor(code, method) { + const operation = method === "backup-code" + ? client.twoFactor?.verifyBackupCode?.({ code, disableSession: false }) + : client.twoFactor?.verifyTotp?.({ code, trustDevice: false }); + if (!operation) throw new Error("二步验证暂时不可用,请稍后再试"); + const result = await operation; + if (result.error) throw new Error("动态验证码或恢复码错误或已过期"); }, async requestPasswordReset(email) { if (!client.emailOtp.requestPasswordReset) { @@ -139,7 +175,9 @@ export type SelfHostedOtpClient = SelfHostedAuthClient; export type SelfHostedOtpActions = SelfHostedAuthActions; export const createSelfHostedOtpActions = createSelfHostedAuthActions; -const authClient = createAuthClient({ plugins: [emailOTPClient()] }); +const authClient = createAuthClient({ + plugins: [emailOTPClient(), twoFactorClient()], +}); export const selfHostedAuthActions = createSelfHostedAuthActions( authClient as unknown as SelfHostedAuthClient, diff --git a/frontend/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts index a0e29009..2dbb2bb4 100644 --- a/frontend/src/modules/identity/config.ts +++ b/frontend/src/modules/identity/config.ts @@ -8,6 +8,7 @@ export interface SelfHostedIdentityConfig { provider: "self-hosted"; databaseUrl: string; userOrigin: string; + adminOrigin: string; userSecret: string; resendApiKey: string; resendFrom: string; @@ -93,12 +94,17 @@ export function readSelfHostedIdentityConfig( env: IdentityEnvironment, ): SelfHostedIdentityConfig { const userOrigin = readOrigin(env, "AUTH_USER_ORIGIN"); + const adminOrigin = readOrigin(env, "ADMIN_USER_ORIGIN"); + if (adminOrigin === userOrigin) { + throw new Error("ADMIN_USER_ORIGIN must differ from AUTH_USER_ORIGIN"); + } const userSecret = readSecret(env, "BETTER_AUTH_USER_SECRET"); return { provider: "self-hosted", databaseUrl: readPostgresUrl(env), userOrigin, + adminOrigin, userSecret, resendApiKey: required(env, "RESEND_API_KEY"), resendFrom: readSender(env), diff --git a/frontend/src/modules/identity/contracts.ts b/frontend/src/modules/identity/contracts.ts index d30920ef..337b20c3 100644 --- a/frontend/src/modules/identity/contracts.ts +++ b/frontend/src/modules/identity/contracts.ts @@ -19,6 +19,7 @@ export interface IdentityUser { id: string; email: string; emailVerified: boolean; + twoFactorEnabled: boolean; name: string; image: string | null; role: string[]; diff --git a/frontend/src/modules/identity/email/resend-email-otp-sender.ts b/frontend/src/modules/identity/email/resend-email-otp-sender.ts index 0cac51cc..9f74b95f 100644 --- a/frontend/src/modules/identity/email/resend-email-otp-sender.ts +++ b/frontend/src/modules/identity/email/resend-email-otp-sender.ts @@ -9,7 +9,7 @@ const safeDeliveryError = "OTP email delivery failed"; const subjectByType: Record = { "sign-in": "Your Jyotisha sign-in code", - "email-verification": "Verify your Jyotisha email", + "email-verification": "Your Jyotisha verification code", "forget-password": "Reset your Jyotisha password", "change-email": "Confirm your new Jyotisha email", }; diff --git a/frontend/src/modules/identity/host.ts b/frontend/src/modules/identity/host.ts index 33b563e9..d0a50968 100644 --- a/frontend/src/modules/identity/host.ts +++ b/frontend/src/modules/identity/host.ts @@ -32,11 +32,13 @@ function normalizeHost(value: string | null): string | null { export function resolveIdentitySurface( hostHeader: string | null, config: SelfHostedIdentityConfig, -): "user" | null { +): "user" | "admin" | null { const host = normalizeHost(hostHeader); if (!host) return null; - return host === new URL(config.userOrigin).host.toLowerCase() ? "user" : null; + if (host === new URL(config.userOrigin).host.toLowerCase()) return "user"; + if (host === new URL(config.adminOrigin).host.toLowerCase()) return "admin"; + return null; } function isAdminEndpoint(request: Request): boolean { @@ -59,7 +61,7 @@ export function createHostIsolatedAuthHandlers( if (!surface) { return new Response("Unrecognized identity host", { status: 421 }); } - if (isAdminEndpoint(request)) { + if (surface === "user" && isAdminEndpoint(request)) { return new Response("Not found", { status: 404 }); } return handlers.user[method](request); diff --git a/frontend/src/modules/identity/model.ts b/frontend/src/modules/identity/model.ts index 59bdbb7a..5eb4bfaa 100644 --- a/frontend/src/modules/identity/model.ts +++ b/frontend/src/modules/identity/model.ts @@ -3,6 +3,7 @@ export const identityModelMapping = { modelName: "users", fields: { emailVerified: "email_verified", + twoFactorEnabled: "two_factor_enabled", createdAt: "created_at", updatedAt: "updated_at", }, @@ -47,6 +48,22 @@ export const identityModelMapping = { lastRequest: "last_request", }, }, + twoFactor: { + user: { + fields: { + twoFactorEnabled: "two_factor_enabled", + }, + }, + twoFactor: { + modelName: "two_factors", + fields: { + userId: "user_id", + backupCodes: "backup_codes", + failedVerificationCount: "failed_verification_count", + lockedUntil: "locked_until", + }, + }, + }, admin: { user: { fields: { diff --git a/frontend/src/modules/identity/session.ts b/frontend/src/modules/identity/session.ts index 0c2fd6e1..0ba8c87f 100644 --- a/frontend/src/modules/identity/session.ts +++ b/frontend/src/modules/identity/session.ts @@ -1,17 +1,23 @@ import type { IdentitySession, IdentityUser } from "./contracts.ts"; interface RawIdentitySession { - session: { expiresAt: Date | string }; + session: { id: string; token: string; expiresAt: Date | string }; user: { id: string; email: string; emailVerified: boolean; + twoFactorEnabled?: boolean; name: string; image?: string | null; role?: string | null; }; } +export interface IdentityServerSession extends IdentitySession { + sessionId: string; + sessionToken: string; +} + export interface IdentitySessionReader { getSession(input: { headers: Headers }): Promise; } @@ -34,10 +40,10 @@ function parseRoles(role: string | null | undefined): string[] { return [...new Set(roles.length ? roles : ["user"])]; } -export async function readIdentitySession( +export async function readIdentityServerSession( reader: IdentitySessionReader, requestHeaders: Headers, -): Promise { +): Promise { const value = await reader.getSession({ headers: requestHeaders }); if (!value) return null; @@ -47,11 +53,14 @@ export async function readIdentitySession( } return { + sessionId: value.session.id, + sessionToken: value.session.token, expiresAt, user: { id: value.user.id, email: value.user.email.trim().toLowerCase(), emailVerified: value.user.emailVerified, + twoFactorEnabled: value.user.twoFactorEnabled === true, name: value.user.name, image: value.user.image ?? null, role: parseRoles(value.user.role), @@ -59,6 +68,23 @@ export async function readIdentitySession( }; } +export async function readIdentitySession( + reader: IdentitySessionReader, + requestHeaders: Headers, +): Promise { + const session = await readIdentityServerSession(reader, requestHeaders); + return session ? { user: session.user, expiresAt: session.expiresAt } : null; +} + +export async function requireIdentityServerSession( + reader: IdentitySessionReader, + requestHeaders: Headers, +): Promise { + const session = await readIdentityServerSession(reader, requestHeaders); + if (!session) throw new IdentityAuthorizationError("Authentication required", 401); + return session; +} + export async function requireIdentityUser( reader: IdentitySessionReader, requestHeaders: Headers, diff --git a/frontend/supabase/migrations/20260806010000_admin_rbac.sql b/frontend/supabase/migrations/20260806010000_admin_rbac.sql new file mode 100644 index 00000000..36c3c1b0 --- /dev/null +++ b/frontend/supabase/migrations/20260806010000_admin_rbac.sql @@ -0,0 +1,402 @@ +begin; + +alter table public.admin_users + add column if not exists updated_at timestamptz not null default now(); + +create table if not exists public.admin_roles ( + id uuid primary key default gen_random_uuid(), + code text not null unique check (code ~ '^[a-z][a-z0-9_]{1,39}$'), + name text not null check (char_length(name) between 1 and 80), + description text not null default '' check (char_length(description) <= 500), + requires_mfa boolean not null default false, + system_role boolean not null default true, + created_at timestamptz not null default now() +); + +create table if not exists public.admin_permissions ( + id uuid primary key default gen_random_uuid(), + permission_key text not null unique check (permission_key ~ '^[a-z][a-z0-9_.]{2,79}$'), + description text not null default '' check (char_length(description) <= 500) +); + +create table if not exists public.admin_user_roles ( + admin_user_id uuid not null references public.admin_users(user_id) on delete cascade, + role_id uuid not null references public.admin_roles(id) on delete restrict, + assigned_by uuid not null references auth.users(id) on delete restrict, + assigned_at timestamptz not null default now(), + primary key (admin_user_id, role_id) +); + +create table if not exists public.admin_role_permissions ( + role_id uuid not null references public.admin_roles(id) on delete cascade, + permission_id uuid not null references public.admin_permissions(id) on delete cascade, + primary key (role_id, permission_id) +); + +create table if not exists public.admin_session_revocations ( + id uuid primary key default gen_random_uuid(), + admin_user_id uuid not null references public.admin_users(user_id) on delete cascade, + revoked_by uuid not null references auth.users(id) on delete restrict, + reason text not null check (char_length(btrim(reason)) between 1 and 500), + revoked_at timestamptz not null default clock_timestamp() +); + +create index if not exists admin_session_revocations_user_idx + on public.admin_session_revocations (admin_user_id, revoked_at desc); + +insert into public.admin_permissions (permission_key, description) values + ('admin.access', '进入管理后台'), + ('admin.customers.read', '查看客户基本资料'), + ('admin.customers.birth_data.read', '查看客户出生资料'), + ('admin.users.read', '查看管理员'), + ('admin.users.manage_roles', '管理管理员角色'), + ('billing.products.read', '查看商品'), + ('billing.products.write', '编辑商品草稿'), + ('billing.products.publish', '发布商品版本'), + ('billing.orders.read', '查看订单'), + ('billing.adjustments.write', '调整订阅和补偿'), + ('models.read', '查看模型配置'), + ('models.write', '编辑模型草稿'), + ('models.test', '测试模型连接'), + ('models.publish', '发布模型配置'), + ('models.rollback', '回滚模型配置'), + ('ops.flags.write', '修改功能开关'), + ('audit.read', '查看审计日志') +on conflict (permission_key) do update set description = excluded.description; + +insert into public.admin_roles (code, name, description, requires_mfa) values + ('owner', 'Owner', '管理员、角色、模型、账务、配置和审计', true), + ('model_admin', 'Model Admin', '模型草稿、测试、发布和回滚', true), + ('billing_admin', 'Billing Admin', '商品、订单、补偿和订阅调整', true), + ('operations', 'Operations', '监控、熔断、切流和任务重试', true), + ('support', 'Support', '用户查询和有限补偿', false), + ('auditor', 'Auditor', '只读账本、配置版本和审计', false) +on conflict (code) do update set + name = excluded.name, + description = excluded.description, + requires_mfa = excluded.requires_mfa; + +with role_grants(role_code, permission_key) as (values + ('owner', 'admin.access'), ('owner', 'admin.customers.read'), ('owner', 'admin.customers.birth_data.read'), + ('owner', 'admin.users.read'), ('owner', 'admin.users.manage_roles'), + ('owner', 'billing.products.read'), ('owner', 'billing.products.write'), ('owner', 'billing.products.publish'), + ('owner', 'billing.orders.read'), ('owner', 'billing.adjustments.write'), + ('owner', 'models.read'), ('owner', 'models.write'), ('owner', 'models.test'), + ('owner', 'models.publish'), ('owner', 'models.rollback'), ('owner', 'ops.flags.write'), ('owner', 'audit.read'), + ('model_admin', 'admin.access'), ('model_admin', 'models.read'), ('model_admin', 'models.write'), + ('model_admin', 'models.test'), ('model_admin', 'models.publish'), ('model_admin', 'models.rollback'), + ('billing_admin', 'admin.access'), ('billing_admin', 'admin.customers.read'), ('billing_admin', 'billing.products.read'), + ('billing_admin', 'billing.products.write'), ('billing_admin', 'billing.products.publish'), + ('billing_admin', 'billing.orders.read'), ('billing_admin', 'billing.adjustments.write'), + ('operations', 'admin.access'), ('operations', 'admin.customers.read'), ('operations', 'billing.orders.read'), ('operations', 'models.read'), + ('operations', 'ops.flags.write'), ('operations', 'audit.read'), + ('support', 'admin.access'), ('support', 'admin.customers.read'), ('support', 'billing.orders.read'), + ('auditor', 'admin.access'), ('auditor', 'admin.customers.read'), ('auditor', 'billing.products.read'), ('auditor', 'billing.orders.read'), + ('auditor', 'models.read'), ('auditor', 'audit.read') +) +insert into public.admin_role_permissions (role_id, permission_id) +select r.id, p.id +from role_grants g +join public.admin_roles r on r.code = g.role_code +join public.admin_permissions p on p.permission_key = g.permission_key +on conflict do nothing; + +-- Existing self-hosted administrators are the one-time bootstrap source. Runtime +-- authorization below uses only public.admin_users + RBAC, never ADMIN_EMAILS. +insert into public.admin_users (user_id, created_by) +select u.id, u.id +from identity.users u +where 'admin' = any(regexp_split_to_array(u.role, '\\s*,\\s*')) + and exists (select 1 from auth.users a where a.id = u.id) +on conflict on constraint admin_users_pkey do update set revoked_at = null, revoked_by = null, updated_at = now(); + +insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) +select a.user_id, r.id, a.user_id +from public.admin_users a +cross join public.admin_roles r +where a.revoked_at is null and r.code = 'owner' + and not exists (select 1 from public.admin_user_roles existing where existing.admin_user_id = a.user_id) +on conflict do nothing; + +create or replace function public.admin_has_permission(p_user_id uuid, p_permission_key text) +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select exists ( + select 1 + from public.admin_users au + join public.admin_user_roles aur on aur.admin_user_id = au.user_id + join public.admin_role_permissions arp on arp.role_id = aur.role_id + join public.admin_permissions ap on ap.id = arp.permission_id + where au.user_id = p_user_id + and au.revoked_at is null + and ap.permission_key = p_permission_key + ); +$$; + +create or replace function public.admin_permission_keys(p_user_id uuid) +returns table (permission_key text, role_code text, requires_mfa boolean) +language sql +stable +security definer +set search_path = '' +as $$ + select distinct ap.permission_key, ar.code, ar.requires_mfa + from public.admin_users au + join public.admin_user_roles aur on aur.admin_user_id = au.user_id + join public.admin_roles ar on ar.id = aur.role_id + join public.admin_role_permissions arp on arp.role_id = ar.id + join public.admin_permissions ap on ap.id = arp.permission_id + where au.user_id = p_user_id and au.revoked_at is null + order by ar.code, ap.permission_key; +$$; + +create or replace function public.assert_active_admin_owner_exists() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_removes_active_owner boolean := false; +begin + if tg_table_name = 'admin_users' then + v_removes_active_owner := old.revoked_at is null + and (tg_op = 'DELETE' or new.revoked_at is not null) + and exists ( + select 1 + from public.admin_user_roles aur + join public.admin_roles ar on ar.id = aur.role_id + where aur.admin_user_id = old.user_id and ar.code = 'owner' + ); + elsif tg_table_name = 'admin_user_roles' then + v_removes_active_owner := exists ( + select 1 from public.admin_roles ar where ar.id = old.role_id and ar.code = 'owner' + ) and ( + tg_op = 'DELETE' + or new.admin_user_id is distinct from old.admin_user_id + or new.role_id is distinct from old.role_id + ); + elsif tg_table_name = 'admin_roles' then + v_removes_active_owner := old.code = 'owner' + and (tg_op = 'DELETE' or new.code is distinct from old.code); + end if; + + if v_removes_active_owner then + perform pg_catalog.pg_advisory_xact_lock(1096040772, 1); + if not exists ( + select 1 + from public.admin_users au + join public.admin_user_roles aur on aur.admin_user_id = au.user_id + join public.admin_roles ar on ar.id = aur.role_id + where au.revoked_at is null and ar.code = 'owner' + ) then + raise exception 'last_owner_protected' using errcode = '23514'; + end if; + end if; + + if tg_op = 'DELETE' then + return old; + end if; + return new; +end; +$$; + +revoke all on function public.assert_active_admin_owner_exists() + from public, anon, authenticated, service_role; + +drop trigger if exists admin_users_require_active_owner on public.admin_users; +create constraint trigger admin_users_require_active_owner + after update of revoked_at or delete on public.admin_users + deferrable initially immediate + for each row execute function public.assert_active_admin_owner_exists(); + +drop trigger if exists admin_user_roles_require_active_owner on public.admin_user_roles; +create constraint trigger admin_user_roles_require_active_owner + after update of admin_user_id, role_id or delete on public.admin_user_roles + deferrable initially immediate + for each row execute function public.assert_active_admin_owner_exists(); + +drop trigger if exists admin_roles_require_active_owner on public.admin_roles; +create constraint trigger admin_roles_require_active_owner + after update of code or delete on public.admin_roles + deferrable initially immediate + for each row execute function public.assert_active_admin_owner_exists(); + +alter table audit.admin_audit_logs + drop constraint if exists admin_audit_logs_action_check, + drop constraint if exists admin_audit_logs_target_type_check, + drop constraint if exists admin_audit_logs_actor_role_check; + +alter table audit.admin_audit_logs + add column if not exists permission_used text, + add column if not exists reason text; + +create or replace function public.admin_manage_role( + p_actor_user_id uuid, + p_target_user_id uuid, + p_role_code text, + p_assign boolean, + p_reason text, + p_request_id text +) +returns table (user_id uuid, role_code text, assigned boolean) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_role_id uuid; + v_actor_email text; + v_target_email text; + v_owner_count integer; +begin + if not public.admin_has_permission(p_actor_user_id, 'admin.users.manage_roles') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then + raise exception 'admin_reason_required' using errcode = '22023'; + end if; + select id into v_role_id from public.admin_roles where code = p_role_code; + if v_role_id is null then raise exception 'admin_role_not_found' using errcode = '22023'; end if; + select lower(btrim(email)) into v_actor_email from identity.users where id = p_actor_user_id; + select lower(btrim(email)) into v_target_email from identity.users where id = p_target_user_id; + if v_target_email is null then raise exception 'admin_user_not_found' using errcode = '22023'; end if; + + insert into public.admin_users (user_id, created_by) + values (p_target_user_id, p_actor_user_id) + on conflict on constraint admin_users_pkey do update set revoked_at = null, revoked_by = null, updated_at = now(); + + if p_assign then + insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) + values (p_target_user_id, v_role_id, p_actor_user_id) + on conflict do nothing; + else + if p_role_code = 'owner' then + -- Fixed transaction-level lock serializes every last-Owner decision. + perform pg_catalog.pg_advisory_xact_lock(1096040772, 1); + select count(*) into v_owner_count + from public.admin_users au + join public.admin_user_roles aur on aur.admin_user_id = au.user_id + join public.admin_roles ar on ar.id = aur.role_id + where au.revoked_at is null and ar.code = 'owner'; + if v_owner_count <= 1 and exists ( + select 1 from public.admin_user_roles where admin_user_id = p_target_user_id and role_id = v_role_id + ) then + raise exception 'last_owner_protected' using errcode = '23514'; + end if; + end if; + delete from public.admin_user_roles where admin_user_id = p_target_user_id and role_id = v_role_id; + delete from identity.sessions where user_id = p_target_user_id; + insert into public.admin_session_revocations (admin_user_id, revoked_by, reason) + values (p_target_user_id, p_actor_user_id, btrim(p_reason)); + end if; + + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + before_value, after_value, request_id, permission_used, reason + ) values ( + p_actor_user_id, v_actor_email, 'admin', + case when p_assign then 'admin.role.assign' else 'admin.role.revoke' end, + 'administrator', p_target_user_id, + null, jsonb_build_object('email', v_target_email, 'role', p_role_code, 'assigned', p_assign), + p_request_id, 'admin.users.manage_roles', btrim(p_reason) + ) on conflict do nothing; + + return query select p_target_user_id, p_role_code, p_assign; +end; +$$; + +create or replace function public.admin_read_customer_birth_data( + p_actor_user_id uuid, + p_target_user_ids uuid[], + p_request_id text +) +returns table ( + user_id uuid, + birth_date date, + birth_time_status text, + birth_place_label text +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_actor_email text; +begin + if not public.admin_has_permission(p_actor_user_id, 'admin.customers.birth_data.read') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if p_target_user_ids is null or cardinality(p_target_user_ids) <> 1 then + raise exception 'admin_customer_scope_invalid' using errcode = '22023'; + end if; + if char_length(btrim(coalesce(p_request_id, ''))) not between 1 and 200 then + raise exception 'admin_request_id_invalid' using errcode = '22023'; + end if; + + select lower(btrim(email)) into v_actor_email + from identity.users + where id = p_actor_user_id; + if v_actor_email is null then + raise exception 'admin_user_not_found' using errcode = '22023'; + end if; + + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + after_value, request_id, permission_used, reason + ) + select + p_actor_user_id, v_actor_email, 'admin', 'admin.customer.birth_data.read', + 'customer', p.id, + jsonb_build_object('fields', array['birth_date', 'birth_time_status', 'birth_place_label']), + btrim(p_request_id), 'admin.customers.birth_data.read', 'sensitive customer birth data view' + from public.profiles p + where p.id = any(p_target_user_ids) + on conflict do nothing; + + return query + select p.id, p.birth_date, p.birth_time_status, p.birth_place_label + from public.profiles p + where p.id = any(p_target_user_ids); +end; +$$; + +revoke all on table public.admin_roles, public.admin_permissions, public.admin_user_roles, + public.admin_role_permissions, public.admin_session_revocations from public, anon, authenticated; +grant select on table public.admin_roles, public.admin_permissions, public.admin_user_roles, + public.admin_role_permissions, public.admin_session_revocations to service_role; +grant select, insert, update on table public.admin_users to service_role; + +revoke all on function public.admin_has_permission(uuid, text), + public.admin_permission_keys(uuid), public.admin_manage_role(uuid, uuid, text, boolean, text, text), + public.admin_read_customer_birth_data(uuid, uuid[], text) + from public, anon, authenticated; +grant execute on function public.admin_has_permission(uuid, text), + public.admin_permission_keys(uuid), public.admin_manage_role(uuid, uuid, text, boolean, text, text), + public.admin_read_customer_birth_data(uuid, uuid[], text) + to service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + if pg_catalog.pg_has_role('admin_runtime', 'service_role', 'MEMBER') then + raise exception 'admin_runtime_service_role_membership_must_be_revoked_by_bootstrap'; + end if; + revoke select (birth_date, birth_time_status, birth_place_label) + on table public.profiles from admin_runtime; + grant select on table public.admin_users, public.admin_roles, public.admin_permissions, + public.admin_user_roles, public.admin_role_permissions, public.admin_session_revocations + to admin_runtime; + grant execute on function public.admin_has_permission(uuid, text), + public.admin_permission_keys(uuid), public.admin_manage_role(uuid, uuid, text, boolean, text, text), + public.admin_read_customer_birth_data(uuid, uuid[], text) + to admin_runtime; + end if; +end; +$$; + +commit;