feat: ACN Application Security Standard V7.30 compliance (19/23 items)
CRITICAL — Authentication & Access: - TOTP MFA: otpauth-based, QR setup UI, sign-in flow integration, admin disable override, /account/security self-service page - Session Timeouts: 8h absolute (maxAge), 30min idle (updateAge) - Failed Auth Logging: Pino warn for invalid password/user/totp, info for successful login, audit entries for all auth events - Concurrent Session Limit: ActiveSession model, oldest-kick strategy, max 3 per user (configurable in SystemSettings) CRITICAL — HTTP Security: - HSTS: max-age=31536000; includeSubDomains - CSP: script/style/img/font/connect-src with Gemini/OpenAI whitelist - X-XSS-Protection: 0 (CSP replaces legacy) - Auth page cache: no-store, no-cache, must-revalidate - Rate Limiting: 100/15min general API, 5/15min auth (Map-based) Data Protection: - XSS Sanitization: DOMPurify on comment bodies - autocomplete="new-password" on all password/secret fields - SameSite=Strict on all cookies (Credentials-only, no OAuth) - File Upload Magic Bytes validation (PNG/JPEG/WebP/GIF/BMP/TIFF) Logging & Monitoring: - Login/Logout audit entries (Auth entityType) - External API call logging with timing (OpenAI, Gemini) - Input validation failure logging at warn level - Concurrent session tracking in ActiveSession table Documentation: - docs/security-architecture.md (11 sections) - docs/sdlc.md (CI pipeline, security gates, incident response) - .gitea/PULL_REQUEST_TEMPLATE.md (security checklist) Schema: User.totpSecret/totpEnabled, SystemSettings.sessionMaxAge/ sessionIdleTimeout/maxConcurrentSessions, ActiveSession model Tests: 310 engine + 37 staffing pass. TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type SetupStep = "idle" | "show-secret" | "verify" | "done";
|
||||
|
||||
export function MfaSetup() {
|
||||
const [step, setStep] = useState<SetupStep>("idle");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [uri, setUri] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
||||
const generateMutation = trpc.user.generateTotpSecret.useMutation();
|
||||
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
|
||||
|
||||
async function handleGenerate() {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await generateMutation.mutateAsync();
|
||||
setSecret(result.secret);
|
||||
setUri(result.uri);
|
||||
setStep("show-secret");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate TOTP secret");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
setError(null);
|
||||
try {
|
||||
await verifyMutation.mutateAsync({ token });
|
||||
setStep("done");
|
||||
setSuccess("MFA has been enabled successfully.");
|
||||
setSecret("");
|
||||
setUri("");
|
||||
setToken("");
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (mfaStatus?.totpEnabled && step !== "done") {
|
||||
return (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
|
||||
<svg className="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">MFA Enabled</h3>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
Two-factor authentication is active on your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "idle" && (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
|
||||
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Two-Factor Authentication (TOTP)</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Add an extra layer of security by requiring a code from your authenticator app when signing in.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generateMutation.isPending ? "Generating..." : "Set up MFA"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "show-secret" && (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1: Scan the QR code</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
|
||||
</p>
|
||||
|
||||
{/* QR Code via public Google Charts API (otpauth URI) */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(uri)}`}
|
||||
alt="TOTP QR Code"
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Or enter this key manually:
|
||||
</p>
|
||||
<code className="block rounded-lg bg-gray-100 dark:bg-gray-800 px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 break-all select-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep("verify"); setError(null); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "verify" && (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 2: Verify your code</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter the 6-digit code from your authenticator app to confirm setup.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mfa-verify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
id="mfa-verify-token"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
className="w-48 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2 text-center text-xl font-mono tracking-[0.3em] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerify}
|
||||
disabled={token.length !== 6 || verifyMutation.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{verifyMutation.isPending ? "Verifying..." : "Enable MFA"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep("show-secret"); setToken(""); setError(null); }}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user