diff --git a/.env.example b/.env.example index 9a5a3f8..1979348 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,8 @@ CONTACT_FROM_EMAIL=CodeForge # skipped outside production but fails closed (blocks all # submissions) when NODE_ENV=production. TURNSTILE_SECRET_KEY=your_turnstile_secret_key +# Comma-separated production hostnames returned by Turnstile Siteverify. +TURNSTILE_HOSTNAMES=codeforgedev.vercel.app # Cloudflare Turnstile site key — public, used client-side to render # the widget. Must be set together with TURNSTILE_SECRET_KEY above, @@ -55,3 +57,6 @@ VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key # but setting your own value is recommended. CONTACT_IP_HASH_SALT=choose_a_random_string + +# Server-only random salt used to hash client IPs for login lockout keys. +LOGIN_IP_HASH_SALT=replace_with_a_long_random_secret diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..ac486e9 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,32 @@ +name: Security checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + application: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Dependency audit + run: npm audit --audit-level=high diff --git a/src/components/SecurityTab.tsx b/src/components/SecurityTab.tsx index 72a88ed..6edae84 100644 --- a/src/components/SecurityTab.tsx +++ b/src/components/SecurityTab.tsx @@ -1,93 +1,69 @@ /** * SecurityTab * - * Renders inside the Dashboard's . - * Handles the complete MFA lifecycle: - * - Status display (enabled / disabled) - * - Enable flow: password → QR code → verify → recovery codes - * - Disable flow: password → TOTP code → unenroll - * - Recovery code generation and regeneration + * Administrator MFA management. MFA is mandatory for CodeForge admin access. + * A second TOTP factor can be enrolled on another trusted device as the + * recovery mechanism; the application deliberately does not implement a + * weaker password-only MFA reset path. */ import { useState, useEffect, useCallback } from "react"; -import { - ShieldCheck, ShieldOff, Shield, Copy, Download, Check, - KeyRound, Eye, EyeOff, RefreshCw, X, -} from "lucide-react"; +import { ShieldCheck, ShieldOff, Shield, KeyRound, Eye, EyeOff, Plus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/use-auth"; -import { generateRecoveryCodes, hashRecoveryCode } from "@/lib/recovery-codes"; import { toast } from "sonner"; -// ─── Types ──────────────────────────────────────────────────────────────────── +type Step = "idle" | "enable-password" | "enable-qr" | "enable-complete"; -type Step = - | "idle" - | "enable-password" - | "enable-qr" - | "enable-codes" - | "disable-password" - | "disable-totp" - | "regen-confirm"; - -// ─── Main Component ─────────────────────────────────────────────────────────── - -export function SecurityTab() { +export function SecurityTab({ + requiredSetup = false, + onRequiredComplete, +}: { + requiredSetup?: boolean; + onRequiredComplete?: () => void; +}) { const { user } = useAuth(); - const [mfaEnabled, setMfaEnabled] = useState(false); - const [factorId, setFactorId] = useState(""); + const [mfaCount, setMfaCount] = useState(0); const [loading, setLoading] = useState(true); const [step, setStep] = useState("idle"); - - // Enrollment state const [enrollFactorId, setEnrollFactorId] = useState(""); const [qrCode, setQrCode] = useState(""); const [secret, setSecret] = useState(""); const [totpCode, setTotpCode] = useState(""); - const [recoveryCodes, setRecoveryCodes] = useState([]); - - // Password inputs const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - - // Disable-flow TOTP code - const [disableTotpCode, setDisableTotpCode] = useState(""); - const [busy, setBusy] = useState(false); - const [codeCopied, setCopied] = useState(false); const [secretCopied, setSecretCopied] = useState(false); - // ─── Load MFA status ────────────────────────────────────────────────────── - const loadStatus = useCallback(async () => { setLoading(true); - const { data } = await supabase.auth.mfa.listFactors(); - const verified = (data?.totp ?? [])[0]; - setMfaEnabled(!!verified); - setFactorId(verified?.id ?? ""); + const { data, error } = await supabase.auth.mfa.listFactors(); + if (error) { + setMfaCount(0); + } else { + setMfaCount((data?.totp ?? []).filter((factor) => factor.status === "verified").length); + } setLoading(false); }, []); - useEffect(() => { loadStatus(); }, [loadStatus]); - - // ─── Helpers ────────────────────────────────────────────────────────────── + useEffect(() => { + void loadStatus(); + }, [loadStatus]); const reset = () => { setStep("idle"); setPassword(""); setTotpCode(""); - setDisableTotpCode(""); setQrCode(""); setSecret(""); setEnrollFactorId(""); - setRecoveryCodes([]); setShowPassword(false); + setSecretCopied(false); setBusy(false); }; - /** Re-authenticate with password by signing in again */ const verifyPassword = async (pw: string): Promise => { if (!user?.email) return false; const { error } = await supabase.auth.signInWithPassword({ @@ -97,13 +73,13 @@ export function SecurityTab() { return !error; }; - // ─── ENABLE FLOW ────────────────────────────────────────────────────────── - - /** Step 1: verify password, then enroll TOTP factor */ const handleEnablePassword = async () => { - if (!password) { toast.error("Enter your password."); return; } - setBusy(true); + if (!password) { + toast.error("Enter your password."); + return; + } + setBusy(true); const ok = await verifyPassword(password); if (!ok) { toast.error("Incorrect password."); @@ -111,19 +87,17 @@ export function SecurityTab() { return; } - // Clean up any lingering unverified factors first const { data: existing } = await supabase.auth.mfa.listFactors(); - for (const f of existing?.all ?? []) { - if (f.status === "unverified") { - await supabase.auth.mfa.unenroll({ factorId: f.id }); + for (const factor of existing?.all ?? []) { + if (factor.status === "unverified") { + await supabase.auth.mfa.unenroll({ factorId: factor.id }); } } - // Enroll new TOTP factor const { data, error } = await supabase.auth.mfa.enroll({ factorType: "totp", issuer: "CodeForge", - friendlyName: "CodeForge Admin", + friendlyName: mfaCount === 0 ? "CodeForge Admin" : `CodeForge Backup ${mfaCount + 1}`, }); if (error || !data) { @@ -140,135 +114,41 @@ export function SecurityTab() { setStep("enable-qr"); }; - /** Step 2: verify TOTP code to activate the factor */ const handleEnableVerify = async () => { - if (!/^\d{6}$/.test(totpCode)) { toast.error("Enter a valid 6-digit code."); return; } - setBusy(true); + if (!/^\d{6}$/.test(totpCode)) { + toast.error("Enter a valid 6-digit code."); + return; + } + setBusy(true); const { error } = await supabase.auth.mfa.challengeAndVerify({ factorId: enrollFactorId, code: totpCode, }); if (error) { - toast.error(error.message.toLowerCase().includes("expired") - ? "Code expired — wait for the next one." - : "Invalid code. Try again."); + toast.error( + error.message.toLowerCase().includes("expired") + ? "Code expired — wait for the next one." + : "Invalid code. Try again.", + ); setBusy(false); return; } - // Generate and store recovery codes - const { plaintext, hashes } = await generateRecoveryCodes(); - - // Delete old recovery codes first - if (user?.id) { - await supabase.from("recovery_codes").delete().eq("user_id", user.id); - await supabase.from("recovery_codes").insert( - hashes.map((code_hash) => ({ user_id: user.id, code_hash })) - ); - } - - setRecoveryCodes(plaintext); setTotpCode(""); setBusy(false); - setStep("enable-codes"); await loadStatus(); - toast.success("Two-factor authentication enabled!"); - }; - - // ─── DISABLE FLOW ───────────────────────────────────────────────────────── - - /** Step 1: verify password */ - const handleDisablePassword = async () => { - if (!password) { toast.error("Enter your password."); return; } - setBusy(true); - const ok = await verifyPassword(password); - if (!ok) { toast.error("Incorrect password."); setBusy(false); return; } - setPassword(""); - setBusy(false); - setStep("disable-totp"); - }; - - /** Step 2: verify TOTP, then unenroll */ - const handleDisableVerify = async () => { - if (!/^\d{6}$/.test(disableTotpCode)) { toast.error("Enter a valid 6-digit code."); return; } - setBusy(true); - - // Challenge + verify to confirm identity - const { error: verifyError } = await supabase.auth.mfa.challengeAndVerify({ - factorId, - code: disableTotpCode, - }); - - if (verifyError) { - toast.error("Invalid code. MFA not disabled."); - setBusy(false); - return; - } - - // Unenroll the factor - const { error: unenrollError } = await supabase.auth.mfa.unenroll({ factorId }); - if (unenrollError) { - toast.error("Failed to disable MFA: " + unenrollError.message); - setBusy(false); - return; - } - - // Delete recovery codes - if (user?.id) { - await supabase.from("recovery_codes").delete().eq("user_id", user.id); - } - - await loadStatus(); - reset(); - toast.success("Two-factor authentication disabled."); - }; - - // ─── RECOVERY CODE REGENERATION ─────────────────────────────────────────── - - const handleRegen = async () => { - if (!user?.id) return; - setBusy(true); - const { plaintext, hashes } = await generateRecoveryCodes(); - await supabase.from("recovery_codes").delete().eq("user_id", user.id); - await supabase.from("recovery_codes").insert( - hashes.map((code_hash) => ({ user_id: user.id, code_hash })) - ); - setRecoveryCodes(plaintext); - setBusy(false); - setStep("enable-codes"); - toast.success("Recovery codes regenerated. Save them now."); - }; - - // ─── Clipboard & Download ───────────────────────────────────────────────── - - const copyAllCodes = async () => { - await navigator.clipboard.writeText(recoveryCodes.join("\n")); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const downloadCodes = () => { - const blob = new Blob( - [`CodeForge Recovery Codes\nGenerated: ${new Date().toISOString()}\n\n${recoveryCodes.join("\n")}`], - { type: "text/plain" } - ); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = "codeforge-recovery-codes.txt"; - a.click(); - URL.revokeObjectURL(a.href); + setStep("enable-complete"); + toast.success("Authenticator enabled successfully."); }; const copySecret = async () => { await navigator.clipboard.writeText(secret); setSecretCopied(true); - setTimeout(() => setSecretCopied(false), 2000); + window.setTimeout(() => setSecretCopied(false), 2000); }; - // ─── RENDER ─────────────────────────────────────────────────────────────── - if (loading) { return (
@@ -279,11 +159,10 @@ export function SecurityTab() { return (
- {/* ── Status Card ── */}
- {mfaEnabled ? ( + {mfaCount > 0 ? (
@@ -295,91 +174,88 @@ export function SecurityTab() {
Two-Step Verification
- {mfaEnabled ? ( - <>Enabled + {mfaCount > 0 ? ( + <> + + + Enabled{mfaCount > 1 ? ` · ${mfaCount} authenticators` : ""} + + ) : ( - <>Disabled + <> + + Required — not configured + )}
-
- {mfaEnabled ? ( + + - + + Add Backup Authenticator ) : ( - + <> + + Enable 2FA + )} -
+
- {!mfaEnabled && ( -

- Add an extra layer of security to your account. After enabling, you'll need your - authenticator app each time you sign in. -

- )} -
- {/* ══════════════ ENABLE FLOW ══════════════ */} +

+ TOTP is mandatory for the CodeForge administrator. For recovery, keep a second + authenticator on another trusted device instead of using a weaker bypass path. +

+
- {/* Step: Password verification */} {step === "enable-password" && ( } - title="Verify Your Password" - description="Enter your current password to begin setting up two-factor authentication." + title={mfaCount > 0 ? "Add Backup Authenticator" : "Verify Your Password"} + description="Re-enter your current password before creating a new TOTP factor." onClose={reset} > setShowPassword((v) => !v)} + onToggle={() => setShowPassword((visible) => !visible)} />
- +
)} - {/* Step: QR code */} {step === "enable-qr" && ( } title="Scan QR Code" - description="Scan this QR code with Microsoft Authenticator, Google Authenticator, or Authy." + description="Use Microsoft Authenticator, Google Authenticator, Authy, or another TOTP authenticator." onClose={reset} > - {/* QR code */}
- {/* Manual secret */}
-

+

Can't scan? Enter this key manually

{secret} -
- {/* Verify first code */}
+
- +
)} - {/* Step: Recovery codes */} - {step === "enable-codes" && ( + {step === "enable-complete" && ( } - title="Save Your Recovery Codes" - description="" - onClose={() => { reset(); }} + icon={} + title="Authenticator Enabled" + description="This authenticator can now be used for administrator sign-in." + onClose={reset} > -
- ⚠️ Save these recovery codes in a safe place. They will not be shown again. Each code can only be used once. -
-
- {recoveryCodes.map((c) => ( -
- {c} -
- ))} -
-
- - +
+ Your authenticator is active. If this is your primary device, add a second + authenticator on another trusted device for recovery.
)} - {/* ══════════════ DISABLE FLOW ══════════════ */} - - {step === "disable-password" && ( - } - title="Disable Two-Factor Authentication" - description="Enter your password to proceed. You will also need your authenticator code." - onClose={reset} - > - setShowPassword((v) => !v)} - /> -
- - -
-
- )} - - {step === "disable-totp" && ( - } - title="Confirm with Authenticator Code" - description="Enter the current 6-digit code from your authenticator app to disable 2FA." - onClose={reset} - > - setDisableTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} - onKeyDown={(e) => { if (e.key === "Enter") handleDisableVerify(); }} - className="rounded-xl text-center font-mono text-lg tracking-[0.5em]" - autoComplete="one-time-code" - autoFocus - /> -
- - -
-
- )} - - {/* ══════════════ REGEN CODES ══════════════ */} - - {step === "regen-confirm" && ( - } - title="Regenerate Recovery Codes" - description="Your old recovery codes will be permanently invalidated. New codes will be generated." - onClose={reset} - > -
- - -
-
+ {requiredSetup && mfaCount === 0 && step === "idle" && ( +
+ Administrator access remains locked until you finish TOTP enrollment. +
)}
); } -// ─── Sub-components ─────────────────────────────────────────────────────────── - function StepCard({ - icon, title, description, children, onClose, + icon, + title, + description, + children, + onClose, }: { icon: React.ReactNode; title: string; @@ -576,7 +377,12 @@ function StepCard({ {description &&
{description}
}
- @@ -586,20 +392,27 @@ function StepCard({ } function PasswordField({ - value, show, onChange, onToggle, + value, + show, + onChange, + onToggle, }: { - value: string; show: boolean; - onChange: (v: string) => void; onToggle: () => void; + value: string; + show: boolean; + onChange: (value: string) => void; + onToggle: () => void; }) { return (
- +
onChange(e.target.value)} + onChange={(event) => onChange(event.target.value)} className="rounded-xl pr-10" autoComplete="current-password" autoFocus @@ -608,6 +421,7 @@ function PasswordField({ type="button" onClick={onToggle} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" + aria-label={show ? "Hide password" : "Show password"} > {show ? : } diff --git a/src/components/TurnstileWidget.tsx b/src/components/TurnstileWidget.tsx index 8d96c3e..b09dfc5 100644 --- a/src/components/TurnstileWidget.tsx +++ b/src/components/TurnstileWidget.tsx @@ -28,7 +28,7 @@ function loadTurnstileScript(): Promise { return scriptPromise; } -export function TurnstileWidget({ onToken }: { onToken: (token: string) => void }) { +export function TurnstileWidget({ action = "default", onToken }: { action?: string; onToken: (token: string) => void }) { const id = useId(); const containerRef = useRef(null); const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; @@ -43,6 +43,7 @@ export function TurnstileWidget({ onToken }: { onToken: (token: string) => void widgetId = window.turnstile.render(containerRef.current, { sitekey: siteKey, theme: "dark", + action, callback: (token: string) => onToken(token), "expired-callback": () => onToken(""), "error-callback": () => onToken(""), @@ -53,7 +54,7 @@ export function TurnstileWidget({ onToken }: { onToken: (token: string) => void cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [siteKey]); + }, [siteKey, action, onToken]); if (!siteKey) return null; diff --git a/src/contexts/EditModeContext.tsx b/src/contexts/EditModeContext.tsx index 027ed92..b76a15d 100644 --- a/src/contexts/EditModeContext.tsx +++ b/src/contexts/EditModeContext.tsx @@ -5,7 +5,7 @@ import { useAuth } from "@/hooks/use-auth"; interface EditModeCtx { editMode: boolean; isAdmin: boolean; // role-only - canEdit: boolean; // effective capability: admin + optional MFA/AAL requirements + canEdit: boolean; // effective capability: admin + mandatory MFA/AAL2 requirements toggleEditMode: () => void; exitEditMode: () => void; } @@ -72,30 +72,14 @@ export function EditModeProvider({ children }: { children: ReactNode }) { return; } - // isAdmin remains role-only and true from this point onward - let nextCanEdit = true; - - const { data: factors, error: factorsError } = await supabase.auth.mfa.listFactors(); - if (factorsError) { - // fail closed + // Admin editing is allowed only for an AAL2 session. + const { data: aal, error: aalError } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (aalError) { setStateSafely({ isAdmin: true, canEdit: false, forceEditOff: true }); return; } - const hasVerifiedTotp = (factors?.all ?? []).some( - (f) => f.factor_type === "totp" && f.status === "verified", - ); - - if (hasVerifiedTotp) { - const { data: aal, error: aalError } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); - if (aalError) { - // fail closed - setStateSafely({ isAdmin: true, canEdit: false, forceEditOff: true }); - return; - } - nextCanEdit = aal?.currentLevel === "aal2"; - } - + const nextCanEdit = aal?.currentLevel === "aal2"; setStateSafely({ isAdmin: true, canEdit: nextCanEdit, forceEditOff: !nextCanEdit }); } finally { recomputingRef.current = false; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 6e9cff5..3757ba8 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -584,6 +584,16 @@ export type Database = { Functions: { get_post_like_count: { Args: { _post_id: string }; Returns: number } increment_post_views: { Args: { p_post_id: string }; Returns: number } + submit_contact_message: { + Args: { + p_email: string + p_ip_hash: string + p_message: string + p_name: string + p_subject: string | null + } + Returns: string + } submit_guest_comment: { Args: { p_content: string diff --git a/src/lib/auth.functions.ts b/src/lib/auth.functions.ts index 243081c..077b88b 100644 --- a/src/lib/auth.functions.ts +++ b/src/lib/auth.functions.ts @@ -13,7 +13,7 @@ export const verifyLoginTurnstile = createServerFn({ method: "POST" }) return { token }; }) .handler(async ({ data }) => { - const verified = await verifyTurnstile(data.token, getClientIp()); + const verified = await verifyTurnstile(data.token, getClientIp(), "auth"); if (!verified) throw new Error("Human verification failed. Please try again."); return { success: true }; }); diff --git a/src/lib/comment.functions.ts b/src/lib/comment.functions.ts index 7aa8f31..68aed1d 100644 --- a/src/lib/comment.functions.ts +++ b/src/lib/comment.functions.ts @@ -51,7 +51,7 @@ export const submitGuestComment = createServerFn({ method: "POST" }) const ip = getClientIp(); const ipHash = hashIp(ip); - const turnstileOk = await verifyTurnstile(data.turnstileToken, ip); + const turnstileOk = await verifyTurnstile(data.turnstileToken, ip, "comment"); if (!turnstileOk) throw new Error("Spam check failed. Please try again."); const { error } = await supabaseAdmin.rpc("submit_guest_comment", { diff --git a/src/lib/contact.functions.ts b/src/lib/contact.functions.ts index ab38c23..e08e1eb 100644 --- a/src/lib/contact.functions.ts +++ b/src/lib/contact.functions.ts @@ -10,8 +10,6 @@ const MAX_FILES = 3; const MAX_FILE_BYTES = 8 * 1024 * 1024; // 8MB per file const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 20MB per submission const MIN_SUBMIT_MS = 2000; // reject if the form was "filled" faster than this -const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -const RATE_LIMIT_MAX = 5; // max submissions per IP per window const ALLOWED_TYPES: Record boolean }> = { "image/jpeg": { @@ -106,19 +104,9 @@ export const submitContactForm = createServerFn({ method: "POST" }) const ipHash = hashIp(ip); // -- spam check ----------------------------------------------------------- - const turnstileOk = await verifyTurnstile(turnstileToken, ip); + const turnstileOk = await verifyTurnstile(turnstileToken, ip, "contact"); if (!turnstileOk) throw new Error("Spam check failed. Please try again."); - // -- rate limit ------------------------------------------------------------- - const { count } = await supabaseAdmin - .from("contact_messages") - .select("*", { count: "exact", head: true }) - .eq("ip_hash", ipHash) - .gte("created_at", new Date(Date.now() - RATE_LIMIT_WINDOW_MS).toISOString()); - if ((count ?? 0) >= RATE_LIMIT_MAX) { - throw new Error("You've sent several messages recently. Please try again later."); - } - // -- attachments ------------------------------------------------------------ const files = data .getAll("attachments") @@ -143,12 +131,18 @@ export const submitContactForm = createServerFn({ method: "POST" }) } // -- insert message row ------------------------------------------------------- - const { data: inserted, error: insertError } = await supabaseAdmin - .from("contact_messages") - .insert({ name, email, subject: subject || null, message, ip_hash: ipHash }) - .select("id") - .single(); + const { data: insertedRows, error: insertError } = await supabaseAdmin.rpc("submit_contact_message", { + p_name: name, + p_email: email, + p_subject: subject || null, + p_message: message, + p_ip_hash: ipHash, + }); + const inserted = insertedRows ? { id: insertedRows as string } : null; if (insertError || !inserted) { + if (insertError?.message.includes("rate limit")) { + throw new Error("You've sent several messages recently. Please try again later."); + } console.error("[contact] insert failed", insertError); throw new Error("Couldn't send your message. Please try again."); } diff --git a/src/lib/login.functions.ts b/src/lib/login.functions.ts index d18de75..b8f95f3 100644 --- a/src/lib/login.functions.ts +++ b/src/lib/login.functions.ts @@ -1,8 +1,8 @@ import { createServerFn } from "@tanstack/react-start"; +import { createHash } from "node:crypto"; import { verifyTurnstile, getClientIp } from "@/lib/turnstile.server"; -const ADMIN_EMAIL = "simakahmed002@gmail.com"; -const LOCKOUT_MINUTES = 10; +const LOCKOUT_MINUTES = 30; const MAX_FAILED_ATTEMPTS = 4; type LockoutRow = { @@ -25,11 +25,19 @@ function validateInput(data: unknown) { throw new Error("Invalid login request."); } - return { email: email.trim(), password, turnstileToken }; + return { email: email.trim().toLowerCase(), password, turnstileToken }; } -async function getLockout(supabaseAdmin: any): Promise { - const { data, error } = await supabaseAdmin.rpc("login_lockout_get"); +function getLockoutKey(ip: string): string { + const salt = process.env.LOGIN_IP_HASH_SALT ?? "codeforge-login"; + const hash = createHash("sha256").update(`${salt}:${ip}`).digest("hex"); + return `admin_password:${hash}`; +} + +async function getLockout(supabaseAdmin: any, lockoutKey: string): Promise { + const { data, error } = await supabaseAdmin.rpc("login_lockout_get", { + p_lockout_key: lockoutKey, + }); if (error) { console.error("[login] lockout lookup failed", error); throw new Error("Unable to prepare login."); @@ -37,6 +45,17 @@ async function getLockout(supabaseAdmin: any): Promise { return (data?.[0] ?? null) as LockoutRow | null; } +async function recordFailure(supabaseAdmin: any, lockoutKey: string) { + const { data, error } = await supabaseAdmin.rpc("login_lockout_record_failure", { + p_lockout_key: lockoutKey, + }); + if (error) { + console.error("[login] failed-attempt recording failed", error); + throw new Error("Unable to record login attempt."); + } + return (data?.[0] ?? null) as LockoutRow | null; +} + export const getLoginLockout = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => { if (!data || typeof data !== "object") throw new Error("Invalid login request."); @@ -44,7 +63,8 @@ export const getLoginLockout = createServerFn({ method: "POST" }) }) .handler(async () => { const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const row = await getLockout(supabaseAdmin); + const lockoutKey = getLockoutKey(getClientIp()); + const row = await getLockout(supabaseAdmin, lockoutKey); const lockedUntil = row?.locked_until ?? null; const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); @@ -60,16 +80,17 @@ export const getLoginLockout = createServerFn({ method: "POST" }) export const loginWithProtection = createServerFn({ method: "POST" }) .inputValidator(validateInput) .handler(async ({ data }) => { - const email = data.email.toLowerCase(); + const ip = getClientIp(); + const lockoutKey = getLockoutKey(ip); // Turnstile must pass before an authentication attempt can affect lockout state. - const verified = await verifyTurnstile(data.turnstileToken, getClientIp()); + const verified = await verifyTurnstile(data.turnstileToken, ip, "login"); if (!verified) { throw new Error("Human verification failed. Please try again."); } const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const lockRow = await getLockout(supabaseAdmin); + const lockRow = await getLockout(supabaseAdmin, lockoutKey); if (lockRow?.locked_until && new Date(lockRow.locked_until).getTime() > Date.now()) { return { @@ -81,38 +102,6 @@ export const loginWithProtection = createServerFn({ method: "POST" }) }; } - const isAdminEmail = email === ADMIN_EMAIL.toLowerCase(); - - // This remains an admin-only login endpoint. Non-admin accounts are not authenticated - // or granted a session; their attempts are recorded by the existing login lockout. - if (!isAdminEmail) { - const { data: failureRows, error: failureError } = await supabaseAdmin.rpc( - "login_lockout_record_failure", - ); - if (failureError) { - console.error("[login] non-admin attempt recording failed", failureError); - throw new Error("Unable to record login attempt."); - } - - const failureRow = (failureRows?.[0] ?? null) as LockoutRow | null; - const lockedUntil = failureRow?.locked_until ?? null; - const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); - - return { - success: false, - reason: locked ? ("locked" as const) : ("not_admin" as const), - locked, - lockedUntil: locked ? lockedUntil : null, - failedAttempts: failureRow?.failed_attempts ?? 1, - error: locked - ? `Security lockout. Please try again after ${LOCKOUT_MINUTES} minutes.` - : "Only admins can access this page", - }; - } - - // Password authentication must use the same Supabase project/key pair as the browser client. - // Prefer the VITE_* values because they are the canonical public Supabase configuration - // used by src/integrations/supabase/client.ts, with the server-side names as fallback. const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; const publishableKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY; @@ -130,20 +119,12 @@ export const loginWithProtection = createServerFn({ method: "POST" }) }); const { data: authData, error: authError } = await userClient.auth.signInWithPassword({ - email, + email: data.email, password: data.password, }); if (authError || !authData.user || !authData.session) { - const { data: failureRows, error: failureError } = await supabaseAdmin.rpc( - "login_lockout_record_failure", - ); - if (failureError) { - console.error("[login] failed-attempt recording failed", failureError); - throw new Error("Unable to record login attempt."); - } - - const failureRow = (failureRows?.[0] ?? null) as LockoutRow | null; + const failureRow = await recordFailure(supabaseAdmin, lockoutKey); const lockedUntil = failureRow?.locked_until ?? null; const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); @@ -163,7 +144,39 @@ export const loginWithProtection = createServerFn({ method: "POST" }) }; } - const { error: clearError } = await supabaseAdmin.rpc("login_lockout_clear"); + // Authorization is role-based, not based on a hard-coded email address. + const { data: roleRow, error: roleError } = await supabaseAdmin + .from("user_roles") + .select("role") + .eq("user_id", authData.user.id) + .eq("role", "admin") + .maybeSingle(); + + if (roleError || !roleRow) { + await userClient.auth.signOut(); + const failureRow = await recordFailure(supabaseAdmin, lockoutKey); + const lockedUntil = failureRow?.locked_until ?? null; + const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); + + return { + success: false, + reason: locked ? ("locked" as const) : ("invalid_credentials" as const), + locked, + lockedUntil: locked ? lockedUntil : null, + failedAttempts: failureRow?.failed_attempts ?? 1, + remainingAttempts: Math.max( + 0, + MAX_FAILED_ATTEMPTS - (failureRow?.failed_attempts ?? 1), + ), + error: locked + ? `Security lockout. Please try again after ${LOCKOUT_MINUTES} minutes.` + : "Invalid username or password.", + }; + } + + const { error: clearError } = await supabaseAdmin.rpc("login_lockout_clear", { + p_lockout_key: lockoutKey, + }); if (clearError) console.error("[login] lockout clear failed", clearError); return { diff --git a/src/lib/mfa.functions.ts b/src/lib/mfa.functions.ts index a3b1f9e..aa10090 100644 --- a/src/lib/mfa.functions.ts +++ b/src/lib/mfa.functions.ts @@ -1,6 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; -const ADMIN_EMAIL = "simakahmed002@gmail.com"; const LOCKOUT_MINUTES = 10; const MAX_FAILED_ATTEMPTS = 4; @@ -20,9 +19,16 @@ function validateInput(data: unknown) { async function getAdminUser(accessToken: string) { const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const { data, error } = await supabaseAdmin.auth.getUser(accessToken); - if (error || !data.user || data.user.email?.toLowerCase() !== ADMIN_EMAIL.toLowerCase()) { - throw new Error("Authentication required."); - } + if (error || !data.user) throw new Error("Authentication required."); + + const { data: role, error: roleError } = await supabaseAdmin + .from("user_roles") + .select("role") + .eq("user_id", data.user.id) + .eq("role", "admin") + .maybeSingle(); + if (roleError || !role) throw new Error("Authentication required."); + return { supabaseAdmin, user: data.user }; } diff --git a/src/lib/recovery-codes.ts b/src/lib/recovery-codes.ts deleted file mode 100644 index 4188eb3..0000000 --- a/src/lib/recovery-codes.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Recovery code utilities - * - * Recovery codes are stored as SHA-256 hashes in the database. - * The plaintext is shown ONCE to the user and never stored. - * - * Why SHA-256 instead of bcrypt? - * - Recovery codes are long random strings (cryptographically strong) - * - bcrypt is designed for low-entropy passwords; SHA-256 is fine for - * high-entropy random tokens - * - WebCrypto (SHA-256) is available in browsers and Vercel Edge without - * any extra packages - */ - -/** Generate a random recovery code like XXXX-XXXX-XXXX */ -function generateCode(): string { - const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I confusion - const segment = () => - Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); - return `${segment()}-${segment()}-${segment()}`; -} - -/** SHA-256 hash a string using WebCrypto */ -async function sha256(text: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(text); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** Generate 10 recovery codes and return both plaintext and hashes */ -export async function generateRecoveryCodes(): Promise<{ plaintext: string[]; hashes: string[] }> { - const plaintext = Array.from({ length: 10 }, generateCode); - const hashes = await Promise.all(plaintext.map(sha256)); - return { plaintext, hashes }; -} - -/** Hash a single code for verification */ -export async function hashRecoveryCode(code: string): Promise { - return sha256(code.toUpperCase().replace(/\s/g, "")); -} diff --git a/src/lib/turnstile.server.ts b/src/lib/turnstile.server.ts index b56b9f0..585a996 100644 --- a/src/lib/turnstile.server.ts +++ b/src/lib/turnstile.server.ts @@ -2,12 +2,14 @@ import { getRequest } from "@tanstack/react-start/server"; export function getClientIp(): string { const req = getRequest(); + const vercelForwarded = req?.headers?.get("x-vercel-forwarded-for"); + if (vercelForwarded) return vercelForwarded.trim(); const fwd = req?.headers?.get("x-forwarded-for"); if (fwd) return fwd.split(",")[0].trim(); return req?.headers?.get("x-real-ip") ?? "unknown"; } -export async function verifyTurnstile(token: string, ip: string): Promise { +export async function verifyTurnstile(token: string, ip: string, expectedAction: string): Promise { const secret = process.env.TURNSTILE_SECRET_KEY; if (!secret) { // Turnstile not configured — fail closed in production, but don't hard-block local dev. @@ -15,13 +17,36 @@ export async function verifyTurnstile(token: string, ip: string): Promise 2048) return false; + const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ secret, response: token, remoteip: ip }), }); - const data = (await res.json()) as { success: boolean }; - return data.success === true; + if (!res.ok) return false; + const data = (await res.json()) as { + success: boolean; + hostname?: string; + action?: string; + }; + if (!data.success || data.action !== expectedAction) return false; + + const requestHost = getRequest()?.headers?.get("host")?.split(":")[0]?.toLowerCase(); + const configuredHostnames = (process.env.TURNSTILE_HOSTNAMES ?? "") + .split(",") + .map((hostname) => hostname.trim().toLowerCase()) + .filter(Boolean); + const expectedHostnames = configuredHostnames.length > 0 + ? configuredHostnames + : requestHost + ? [requestHost] + : []; + + if (!data.hostname || expectedHostnames.length === 0) return false; + if (!expectedHostnames.includes(data.hostname.toLowerCase())) return false; + + return true; } catch (e) { console.error("[turnstile] verification failed", e); return false; diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx index e7744e0..5357f80 100644 --- a/src/routes/_authenticated/route.tsx +++ b/src/routes/_authenticated/route.tsx @@ -3,12 +3,11 @@ * * Logic: * 1. No session → /auth - * 2. Has verified TOTP factor BUT session is only AAL1 → /mfa-verify - * (handles the case where the user has MFA enrolled but hasn't verified this session) - * 3. Everything else → allow through + * 2. No verified TOTP factor → /mfa-setup + * 3. Has verified TOTP factor BUT session is only AAL1 → /mfa-verify + * 4. AAL2 session → allow through * - * MFA is OPTIONAL. If a user has no TOTP factor, they pass through normally. - * MFA is enforced only when the user HAS enrolled it. + * MFA is mandatory for the admin account. * * Device-session monitoring: * - Registers the current admin browser/device. @@ -36,7 +35,11 @@ export const Route = createFileRoute("/_authenticated")({ const { data: factors } = await supabase.auth.mfa.listFactors(); const hasVerifiedFactor = (factors?.totp ?? []).length > 0; - if (hasVerifiedFactor) { + if (!hasVerifiedFactor) { + throw redirect({ to: "/mfa-setup" }); + } + + { // User has MFA — enforce AAL2 for this session const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); if (aalData?.currentLevel !== "aal2") { @@ -44,7 +47,6 @@ export const Route = createFileRoute("/_authenticated")({ throw redirect({ to: "/mfa-verify" }); } } - // No MFA enrolled → pass through normally return { user: userData.user }; }, diff --git a/src/routes/auth.tsx b/src/routes/auth.tsx index d963ea3..8916b8d 100644 --- a/src/routes/auth.tsx +++ b/src/routes/auth.tsx @@ -2,10 +2,10 @@ * /auth — Admin Login * * Flow after successful password login: - * - Only the configured admin email can continue - * - Four invalid admin password or non-admin login attempts trigger a 30-minute server-side lockout + * - Authorization is role-based on the server; no admin email is hard-coded here + * - Four invalid login attempts per client IP trigger a 30-minute server-side lockout * - If the admin has a verified TOTP factor → /mfa-verify - * - Otherwise → /dashboard + * - Otherwise → /mfa-setup */ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; @@ -19,8 +19,6 @@ import { getLoginLockout, loginWithProtection } from "@/lib/login.functions"; import { supabase } from "@/integrations/supabase/client"; import { toast } from "sonner"; -const ADMIN_EMAIL = "simakahmed002@gmail.com"; - function formatRemaining(seconds: number) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; @@ -143,8 +141,8 @@ function AuthPage() { // Must verify MFA before accessing dashboard. navigate({ to: "/mfa-verify" }); } else { - // No MFA enrolled → go straight to dashboard. - navigate({ to: "/dashboard" }); + // MFA is mandatory for the admin account. + navigate({ to: "/mfa-setup" }); } } catch (error) { resetTurnstile(); @@ -206,6 +204,7 @@ function AuthPage() { {!isLocked && ( )} diff --git a/src/routes/blog_.$slug.tsx b/src/routes/blog_.$slug.tsx index 6620e9f..43637b0 100644 --- a/src/routes/blog_.$slug.tsx +++ b/src/routes/blog_.$slug.tsx @@ -13,6 +13,13 @@ import { toast } from "sonner"; import { TurnstileWidget } from "@/components/TurnstileWidget"; import { submitGuestComment } from "@/lib/comment.functions"; +function toSafeJsonLd(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026"); +} + export const Route = createFileRoute("/blog_/$slug")({ loader: async ({ params }) => { const { data } = await supabase @@ -47,7 +54,7 @@ export const Route = createFileRoute("/blog_/$slug")({ ? [ { type: "application/ld+json", - children: JSON.stringify({ + children: toSafeJsonLd({ "@context": "https://schema.org", "@type": "Article", headline: m.title, @@ -83,9 +90,11 @@ function PostPage() { return; } + const randomBytes = new Uint8Array(16); + crypto.getRandomValues(randomBytes); const newVisitorId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + : Array.from(randomBytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); window.localStorage.setItem(storageKey, newVisitorId); setVisitorId(newVisitorId); }, []); @@ -384,7 +393,7 @@ function CommentForm({ postId, onPosted }: { postId: string; onPosted: () => voi