From 95af276d2e3eb6736ef73a1f69edd927e9d7c78c Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 12 Jul 2026 07:53:48 -0700 Subject: [PATCH] Refactor login flow with reset token support --- dev.db | Bin 155648 -> 155648 bytes src/app/api/auth/login/route.ts | 107 +---- .../api/auth/password-reset/complete/route.ts | 24 ++ .../api/auth/password-reset/request/route.ts | 25 ++ .../api/auth/password-reset/verify/route.ts | 25 ++ src/app/login/page.tsx | 397 +++++++++++++----- src/lib/auth.ts | 41 +- src/lib/rateLimiter.ts | 17 +- src/lib/validation/authSchemas.ts | 19 + src/{middleware.ts => proxy.ts} | 23 +- src/services/authService.ts | 227 ++++++++++ 11 files changed, 679 insertions(+), 226 deletions(-) create mode 100644 src/app/api/auth/password-reset/complete/route.ts create mode 100644 src/app/api/auth/password-reset/request/route.ts create mode 100644 src/app/api/auth/password-reset/verify/route.ts create mode 100644 src/lib/validation/authSchemas.ts rename src/{middleware.ts => proxy.ts} (64%) create mode 100644 src/services/authService.ts diff --git a/dev.db b/dev.db index a0c488e18c0c9b57e5f24fc9a6a47493f96bdd44..3dc8b2c0b46461bb0b6bead63e88903fe84fc62a 100644 GIT binary patch delta 447 zcmZ{gJ8KkC9L4A6A#M?Fwp`Z240by4zVFPYT7?Z_EH*Cjfv~x+83%M`ckU!Eifk0p zS!f!IRDJ@5APXrZpCRAF4`AnLt#FD{9M1Xwe@7Rsql?zZF6ex^b{BNs?SJ0~ALeUh zgjtW%9+jI6Eq=|x)%7+o!3r1zEx&ZSv;hXdoVC{(n0Hr~x1c(xpO3xsJ}--L<_cG- zy0J6}f*<_-LuFs)#dxB;e^pj?TsiORH=)fl=j*T+63YllxDkpQ=NM&)kVadUSWPvz zOr=zy6cfr)!MM^w7%7!jSZn!=AcDhNVNn*wHC;H>v2+GSsa7^(v0{-P-h=`DkaPu|qp}5f|r{rn)_VdOGi0@1fN#=&T6I4yNaers@WL6#U z{*679KblPjv%_6n+uJwsy==QQuj64{is1-l4|Y(xr>6H`$jAM~=?O^J+VD%T0*?cD k4v*nacnL4h+Sfhg-Q_LtAAqY3z|OzuU0Ixe1%q$D0Sf?#RsaA1 delta 166 zcmZoTz}awsbAmLZ$V3@uMv=yZtqF`h^}L25Mg~^K##V;rdM2jZf7UZ{2=Fp6FfcPI zGH@0%&1O>EEGQt%G+l8LqlHt6d6}b$bFy(jSh$mgmq}Pvl%sJ(xk^B0zIjnzdU{a4 zcST`PaJp-*X;Pt~uXna-gkgq}xoNpsSxBmJVwFi$`u5qA7}I#UK^E}uV&LD!|8~2; S0>)DQi3=>Z?^@63wgCVgV=;jM diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 018a809..d609732 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,116 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import { createSession } from "@/lib/auth"; +import { loginSchema } from "@/lib/validation/authSchemas"; import { checkRateLimit } from "@/lib/rateLimiter"; - -// Progressive lockout thresholds from Section 4 -function getLockoutDuration(failedAttempts: number): number { - if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours - if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes - if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes - if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute - return 0; -} +import { login } from "@/services/authService"; export async function POST(request: NextRequest) { - // Rate limit by IP - const forwarded = request.headers.get("x-forwarded-for"); - const ip = forwarded?.split(",")[0]?.trim() ?? "unknown"; - const rateCheck = checkRateLimit(ip); - - if (!rateCheck.allowed) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + if (!checkRateLimit(`login:${ip}`).allowed) { return NextResponse.json( { error: "Too many requests. Try again shortly." }, { status: 429 } ); } - const body = await request.json().catch(() => null); - if (!body?.password || typeof body.password !== "string") { + const parsed = loginSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { return NextResponse.json( - { error: "Password is required" }, + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 } ); } - // Check lockout state - let security = await prisma.authSecurity.findUnique({ where: { id: 1 } }); - if (!security) { - security = await prisma.authSecurity.create({ data: { id: 1 } }); + const result = await login(parsed.data.password); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); } - if (security.lockedUntil && security.lockedUntil > new Date()) { - const remainingMs = - security.lockedUntil.getTime() - Date.now(); - const remainingMin = Math.ceil(remainingMs / 60000); - return NextResponse.json( - { - error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`, - }, - { status: 423 } - ); - } - - // Verify password - const setting = await prisma.setting.findUnique({ - where: { key: "admin_password_hash" }, - }); - - let isValid = false; - - if (!setting) { - // Initial setup: hash and save the new password - const argon2 = await import("argon2"); - const newHash = await argon2.hash(body.password); - await prisma.setting.create({ - data: { - key: "admin_password_hash", - value: newHash, - }, - }); - isValid = true; - } else { - // Verify existing password - const passwordHash = setting.value; - try { - const argon2 = await import("argon2"); - isValid = await argon2.verify(passwordHash, body.password); - } catch { - isValid = body.password === passwordHash; - } - } - - if (!isValid) { - const newFailedAttempts = security.failedAttempts + 1; - const lockoutMs = getLockoutDuration(newFailedAttempts); - const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null; - - await prisma.authSecurity.update({ - where: { id: 1 }, - data: { - failedAttempts: newFailedAttempts, - lockedUntil, - lastAttemptAt: new Date(), - }, - }); - - return NextResponse.json( - { error: "Invalid password" }, - { status: 401 } - ); - } - - // Success — reset lockout, create session - await prisma.authSecurity.update({ - where: { id: 1 }, - data: { - failedAttempts: 0, - lockedUntil: null, - lastAttemptAt: new Date(), - }, - }); - - await createSession(); - return NextResponse.json({ success: true }); } diff --git a/src/app/api/auth/password-reset/complete/route.ts b/src/app/api/auth/password-reset/complete/route.ts new file mode 100644 index 0000000..e614085 --- /dev/null +++ b/src/app/api/auth/password-reset/complete/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { completePasswordResetSchema } from "@/lib/validation/authSchemas"; +import { completePasswordReset } from "@/services/authService"; + +export async function POST(request: NextRequest) { + const parsed = completePasswordResetSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { status: 400 } + ); + } + + if (!(await completePasswordReset(parsed.data.password))) { + return NextResponse.json( + { error: "Reset authorization is invalid or expired" }, + { status: 401 } + ); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts new file mode 100644 index 0000000..5ac61d3 --- /dev/null +++ b/src/app/api/auth/password-reset/request/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkRateLimit } from "@/lib/rateLimiter"; +import { requestPasswordReset } from "@/services/authService"; + +export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rateLimit = checkRateLimit(`password-reset-request:${ip}`, { + windowMs: 60_000, + maxRequests: 1, + }); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: "A reset token was requested recently. Try again shortly." }, + { status: 429 } + ); + } + + if (!(await requestPasswordReset())) { + return NextResponse.json( + { error: "Password recovery is unavailable before initial setup." }, + { status: 409 } + ); + } + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/auth/password-reset/verify/route.ts b/src/app/api/auth/password-reset/verify/route.ts new file mode 100644 index 0000000..fc05690 --- /dev/null +++ b/src/app/api/auth/password-reset/verify/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkRateLimit } from "@/lib/rateLimiter"; +import { resetTokenSchema } from "@/lib/validation/authSchemas"; +import { verifyPasswordResetToken } from "@/services/authService"; + +export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) { + return NextResponse.json( + { error: "Too many attempts. Try again shortly." }, + { status: 429 } + ); + } + + const parsed = resetTokenSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 }); + } + + if (!(await verifyPasswordResetToken(parsed.data.token))) { + return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 401 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 30b8ab0..32ce56c 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,158 +1,333 @@ "use client"; -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; +type LoginStage = "login" | "token" | "password" | "success"; + +async function postJson(path: string, body?: object) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + const data: { error?: string } = await response.json(); + if (!response.ok) throw new Error(data.error || "Request failed"); +} + export default function LoginPage() { + const [stage, setStage] = useState("login"); const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [token, setToken] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const [isSetup, setIsSetup] = useState(null); - const router = useRouter(); + const [setupRequired, setSetupRequired] = useState(null); useEffect(() => { fetch("/api/auth/setup-status") - .then((res) => res.json()) - .then((data) => setIsSetup(data.setupRequired)) - .catch(() => setIsSetup(false)); // fallback + .then((response) => response.json()) + .then((data) => setSetupRequired(data.setupRequired)) + .catch(() => setSetupRequired(false)); }, []); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + async function run(action: () => Promise) { setError(""); setLoading(true); - try { - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - - const data = await res.json(); - - if (!res.ok) { - setError(data.error || "Login failed"); - return; - } - - window.location.href = "/"; - } catch { - setError("Network error. Please try again."); + await action(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Network error. Please try again."); } finally { setLoading(false); } } - if (isSetup === null) { + function handleLogin(event: React.FormEvent) { + event.preventDefault(); + void run(async () => { + await postJson("/api/auth/login", { password }); + window.location.href = "/"; + }); + } + + function beginReset() { + void run(async () => { + await postJson("/api/auth/password-reset/request"); + setToken(""); + setStage("token"); + }); + } + + function handleToken(event: React.FormEvent) { + event.preventDefault(); + void run(async () => { + await postJson("/api/auth/password-reset/verify", { token: token.trim() }); + setPassword(""); + setConfirmPassword(""); + setStage("password"); + }); + } + + function handlePasswordReset(event: React.FormEvent) { + event.preventDefault(); + void run(async () => { + await postJson("/api/auth/password-reset/complete", { + password, + confirmPassword, + }); + setToken(""); + setPassword(""); + setConfirmPassword(""); + setStage("success"); + }); + } + + function returnToLogin() { + setStage("login"); + setError(""); + setToken(""); + setPassword(""); + setConfirmPassword(""); + } + + if (setupRequired === null) { return (
-
-
-
+
); } + const title = + stage === "token" + ? "Enter reset token" + : stage === "password" + ? "Choose a new password" + : stage === "success" + ? "Password updated" + : setupRequired + ? "Welcome to Study Desk" + : "Welcome back"; + + const description = + stage === "token" + ? "Find the token in the Study Desk Docker logs. It expires after 15 minutes." + : stage === "password" + ? "Use at least eight characters and enter the password twice." + : stage === "success" + ? "Your old password and existing sessions are no longer valid." + : setupRequired + ? "Please set your admin password for the first time." + : "Open your notes, decks, and practice quizzes."; + return (
-
- {/* Logo / Title area */} -
+ +
+
S

Personal workspace

-

- {isSetup ? "Welcome to Study Desk" : "Welcome back"} -

-

- {isSetup - ? "Please set your admin password for the first time." - : "Open your notes, decks, and practice quizzes."} -

-
+

{title}

+

{description}

+ - {/* Login card */}
-
-
- - + setPassword(e.target.value)} - placeholder={isSetup ? "Create a new password" : "Enter your password"} + onChange={setPassword} + placeholder={setupRequired ? "Create a new password" : "Enter your password"} autoFocus - className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" /> -
- - {error && ( -
- + + {setupRequired ? "Save Password & Login" : "Sign In"} + + {!setupRequired && ( +
- )} - - )} - -
+ + )} + + {stage === "token" && ( +
+
+ + setToken(event.target.value)} + autoComplete="one-time-code" + autoFocus + className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary" + placeholder="Paste the token from Docker logs" + /> +
+ + Validate Token + Back to sign in + + )} + + {stage === "password" && ( +
+
+ + = 8} + validText="Password meets the length requirement." + invalidText="Password must be at least 8 characters." + /> +
+
+ 0 && confirmPassword !== password} + /> + {confirmPassword && ( + + )} +
+ + + Set New Password + + Cancel + + )} + + {stage === "success" && ( +
+
+ Your password has been reset successfully. +
+ Sign In +
+ )}
-
+
); } + +function PasswordField({ + id, + label, + value, + onChange, + placeholder, + describedBy, + invalid = false, + autoFocus = false, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + describedBy?: string; + invalid?: boolean; + autoFocus?: boolean; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + autoComplete={id === "password" ? "current-password" : "new-password"} + aria-describedby={describedBy} + aria-invalid={invalid} + autoFocus={autoFocus} + className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" + /> +
+ ); +} + +function ValidationMessage({ id, valid, validText, invalidText }: { + id: string; + valid: boolean; + validText: string; + invalidText: string; +}) { + return ( +

+ {valid ? validText : invalidText} +

+ ); +} + +function ErrorMessage({ message }: { message: string }) { + if (!message) return null; + return
{message}
; +} + +function PrimaryButton({ loading, disabled = false, onClick, children }: { + loading: boolean; + disabled?: boolean; + onClick?: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 2a3182d..c3e4bae 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,12 @@ import { cookies } from "next/headers"; export interface SessionData { isAuthenticated: boolean; + sessionGeneration?: number; +} + +interface PasswordResetSessionData { + nonce?: string; + expiresAt?: string; } const sessionOptions: SessionOptions = { @@ -15,14 +21,27 @@ const sessionOptions: SessionOptions = { }, }; +const passwordResetSessionOptions: SessionOptions = { + password: sessionOptions.password, + cookieName: "study-app-password-reset", + cookieOptions: { + secure: sessionOptions.cookieOptions?.secure, + httpOnly: true, + sameSite: "lax" as const, + maxAge: 15 * 60, + path: "/", + }, +}; + export async function getSession() { const cookieStore = await cookies(); return getIronSession(cookieStore, sessionOptions); } -export async function createSession() { +export async function createSession(sessionGeneration: number) { const session = await getSession(); session.isAuthenticated = true; + session.sessionGeneration = sessionGeneration; await session.save(); } @@ -35,3 +54,23 @@ export async function isAuthenticated() { const session = await getSession(); return session.isAuthenticated === true; } + +export async function getPasswordResetSession() { + const cookieStore = await cookies(); + return getIronSession( + cookieStore, + passwordResetSessionOptions + ); +} + +export async function authorizePasswordReset(nonce: string, expiresAt: string) { + const session = await getPasswordResetSession(); + session.nonce = nonce; + session.expiresAt = expiresAt; + await session.save(); +} + +export async function destroyPasswordResetAuthorization() { + const session = await getPasswordResetSession(); + session.destroy(); +} diff --git a/src/lib/rateLimiter.ts b/src/lib/rateLimiter.ts index 0bf1b4b..9513c69 100644 --- a/src/lib/rateLimiter.ts +++ b/src/lib/rateLimiter.ts @@ -12,20 +12,25 @@ const store = new Map(); const WINDOW_MS = 60 * 1000; // 1 minute const MAX_REQUESTS = 10; -export function checkRateLimit(ip: string): { allowed: boolean; retryAfterMs: number } { +export function checkRateLimit( + key: string, + options: { windowMs?: number; maxRequests?: number } = {} +): { allowed: boolean; retryAfterMs: number } { const now = Date.now(); - const entry = store.get(ip) ?? { timestamps: [] }; + const windowMs = options.windowMs ?? WINDOW_MS; + const maxRequests = options.maxRequests ?? MAX_REQUESTS; + const entry = store.get(key) ?? { timestamps: [] }; // Remove timestamps outside the window - entry.timestamps = entry.timestamps.filter((t) => now - t < WINDOW_MS); + entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs); - if (entry.timestamps.length >= MAX_REQUESTS) { + if (entry.timestamps.length >= maxRequests) { const oldestInWindow = entry.timestamps[0]; - const retryAfterMs = WINDOW_MS - (now - oldestInWindow); + const retryAfterMs = windowMs - (now - oldestInWindow); return { allowed: false, retryAfterMs }; } entry.timestamps.push(now); - store.set(ip, entry); + store.set(key, entry); return { allowed: true, retryAfterMs: 0 }; } diff --git a/src/lib/validation/authSchemas.ts b/src/lib/validation/authSchemas.ts new file mode 100644 index 0000000..bb4c32c --- /dev/null +++ b/src/lib/validation/authSchemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +export const resetTokenSchema = z.object({ + token: z.string().min(1, "Reset token is required"), +}); + +export const completePasswordResetSchema = z + .object({ + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string(), + }) + .refine((value) => value.password === value.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); diff --git a/src/middleware.ts b/src/proxy.ts similarity index 64% rename from src/middleware.ts rename to src/proxy.ts index 2bbe2d6..8a22eb4 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getIronSession } from "iron-session"; import type { SessionData } from "@/lib/auth"; +import { prisma } from "@/lib/db"; const sessionOptions = { password: @@ -9,18 +10,15 @@ const sessionOptions = { cookieName: "study-app-session", }; -// Routes that don't require authentication const publicPaths = ["/login", "/api/auth", "/shared"]; -export async function middleware(request: NextRequest) { +export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - // Allow public routes if (publicPaths.some((path) => pathname.startsWith(path))) { return NextResponse.next(); } - // Allow static assets and Next.js internals if ( pathname.startsWith("/_next") || pathname.startsWith("/favicon") || @@ -29,15 +27,18 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } - // Check session const response = NextResponse.next(); - const session = await getIronSession( - request, - response, - sessionOptions - ); + const session = await getIronSession(request, response, sessionOptions); + const generationSetting = await prisma.setting.findUnique({ + where: { key: "auth_session_generation" }, + }); + const currentGeneration = Number.parseInt(generationSetting?.value ?? "1", 10); - if (!session.isAuthenticated) { + if ( + !session.isAuthenticated || + session.sessionGeneration !== currentGeneration + ) { + session.destroy(); return NextResponse.redirect(new URL("/login", request.url)); } diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..c54f9c5 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,227 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { + authorizePasswordReset, + createSession, + destroyPasswordResetAuthorization, + getPasswordResetSession, +} from "@/lib/auth"; + +const PASSWORD_HASH_KEY = "admin_password_hash"; +const RESET_TOKEN_KEY = "admin_password_reset"; +const SESSION_GENERATION_KEY = "auth_session_generation"; +const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000; + +interface ResetTokenRecord { + digest: string; + nonce: string; + expiresAt: string; +} + +export type LoginResult = + | { ok: true } + | { ok: false; status: 401 | 423; error: string }; + +function getLockoutDuration(failedAttempts: number): number { + if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; + if (failedAttempts >= 15) return 30 * 60 * 1000; + if (failedAttempts >= 10) return 5 * 60 * 1000; + if (failedAttempts >= 5) return 60 * 1000; + return 0; +} + +function digestToken(token: string) { + return createHash("sha256").update(token, "utf8").digest(); +} + +function parseResetTokenRecord(value: string | undefined): ResetTokenRecord | null { + if (!value) return null; + + try { + const parsed: unknown = JSON.parse(value); + if ( + typeof parsed === "object" && + parsed !== null && + "digest" in parsed && + "nonce" in parsed && + "expiresAt" in parsed && + typeof parsed.digest === "string" && + typeof parsed.nonce === "string" && + typeof parsed.expiresAt === "string" + ) { + return parsed as ResetTokenRecord; + } + } catch { + // A malformed reset record is treated as invalid. + } + + return null; +} + +async function getSessionGeneration(): Promise { + const setting = await prisma.setting.findUnique({ + where: { key: SESSION_GENERATION_KEY }, + }); + const generation = Number.parseInt(setting?.value ?? "1", 10); + return Number.isSafeInteger(generation) && generation > 0 ? generation : 1; +} + +async function getOrCreateSecurityRecord() { + return prisma.authSecurity.upsert({ + where: { id: 1 }, + update: {}, + create: { id: 1 }, + }); +} + +export async function login(password: string): Promise { + const security = await getOrCreateSecurityRecord(); + + if (security.lockedUntil && security.lockedUntil > new Date()) { + const remainingMin = Math.ceil( + (security.lockedUntil.getTime() - Date.now()) / 60_000 + ); + return { + ok: false, + status: 423, + error: `Account locked. Try again in ${remainingMin} minute${remainingMin === 1 ? "" : "s"}.`, + }; + } + + const passwordSetting = await prisma.setting.findUnique({ + where: { key: PASSWORD_HASH_KEY }, + }); + const argon2 = await import("argon2"); + let isValid = false; + + if (!passwordSetting) { + await prisma.setting.create({ + data: { key: PASSWORD_HASH_KEY, value: await argon2.hash(password) }, + }); + isValid = true; + } else { + try { + isValid = await argon2.verify(passwordSetting.value, password); + } catch { + isValid = false; + } + } + + if (!isValid) { + const failedAttempts = security.failedAttempts + 1; + const lockoutMs = getLockoutDuration(failedAttempts); + await prisma.authSecurity.update({ + where: { id: 1 }, + data: { + failedAttempts, + lockedUntil: lockoutMs ? new Date(Date.now() + lockoutMs) : null, + lastAttemptAt: new Date(), + }, + }); + return { ok: false, status: 401, error: "Invalid password" }; + } + + await prisma.authSecurity.update({ + where: { id: 1 }, + data: { failedAttempts: 0, lockedUntil: null, lastAttemptAt: new Date() }, + }); + await createSession(await getSessionGeneration()); + return { ok: true }; +} + +export async function requestPasswordReset(): Promise { + const passwordSetting = await prisma.setting.findUnique({ + where: { key: PASSWORD_HASH_KEY }, + }); + if (!passwordSetting) return false; + + const token = randomBytes(24).toString("base64url"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString(); + const record: ResetTokenRecord = { + digest: digestToken(token).toString("hex"), + nonce: randomUUID(), + expiresAt, + }; + + await prisma.setting.upsert({ + where: { key: RESET_TOKEN_KEY }, + update: { value: JSON.stringify(record) }, + create: { key: RESET_TOKEN_KEY, value: JSON.stringify(record) }, + }); + + console.info(`[password-reset] Token: ${token} (expires ${expiresAt})`); + return true; +} + +export async function verifyPasswordResetToken(token: string): Promise { + const setting = await prisma.setting.findUnique({ where: { key: RESET_TOKEN_KEY } }); + const record = parseResetTokenRecord(setting?.value); + if (!record || new Date(record.expiresAt).getTime() <= Date.now()) return false; + + const submittedDigest = digestToken(token); + const storedDigest = Buffer.from(record.digest, "hex"); + if ( + submittedDigest.length !== storedDigest.length || + !timingSafeEqual(submittedDigest, storedDigest) + ) { + return false; + } + + await authorizePasswordReset(record.nonce, record.expiresAt); + return true; +} + +export async function completePasswordReset(password: string): Promise { + const resetSession = await getPasswordResetSession(); + if (!resetSession.nonce || !resetSession.expiresAt) { + await destroyPasswordResetAuthorization(); + return false; + } + + const argon2 = await import("argon2"); + const passwordHash = await argon2.hash(password); + const completed = await prisma.$transaction(async (transaction) => { + const setting = await transaction.setting.findUnique({ + where: { key: RESET_TOKEN_KEY }, + }); + const record = parseResetTokenRecord(setting?.value); + if ( + !record || + resetSession.nonce !== record.nonce || + resetSession.expiresAt !== record.expiresAt || + new Date(record.expiresAt).getTime() <= Date.now() + ) { + return false; + } + + const generationSetting = await transaction.setting.findUnique({ + where: { key: SESSION_GENERATION_KEY }, + }); + const parsedGeneration = Number.parseInt(generationSetting?.value ?? "1", 10); + const currentGeneration = + Number.isSafeInteger(parsedGeneration) && parsedGeneration > 0 + ? parsedGeneration + : 1; + + await transaction.setting.upsert({ + where: { key: PASSWORD_HASH_KEY }, + update: { value: passwordHash }, + create: { key: PASSWORD_HASH_KEY, value: passwordHash }, + }); + await transaction.setting.upsert({ + where: { key: SESSION_GENERATION_KEY }, + update: { value: String(currentGeneration + 1) }, + create: { key: SESSION_GENERATION_KEY, value: String(currentGeneration + 1) }, + }); + await transaction.setting.delete({ where: { key: RESET_TOKEN_KEY } }); + await transaction.authSecurity.upsert({ + where: { id: 1 }, + update: { failedAttempts: 0, lockedUntil: null, lastAttemptAt: new Date() }, + create: { id: 1 }, + }); + return true; + }); + + await destroyPasswordResetAuthorization(); + return completed; +}