Refactor login flow with reset token support
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s

This commit is contained in:
Elijah 2026-07-12 07:53:48 -07:00
parent f0548bcc6e
commit 95af276d2e
11 changed files with 679 additions and 226 deletions

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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<LoginStage>("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<boolean | null>(null);
const router = useRouter();
const [setupRequired, setSetupRequired] = useState<boolean | null>(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<void>) {
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 (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="animate-pulse flex items-center justify-center">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
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 (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
<div className="absolute inset-0 paper-grid opacity-70" />
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
<div className="relative w-full max-w-md animate-fade-in">
{/* Logo / Title area */}
<div className="mb-7 text-center">
<main className="relative w-full max-w-md animate-fade-in">
<header className="mb-7 text-center">
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
<h1 className="editorial-title mt-2 text-4xl text-text-heading">
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
</h1>
<p className="text-text-muted mt-1 text-sm">
{isSetup
? "Please set your admin password for the first time."
: "Open your notes, decks, and practice quizzes."}
</p>
</div>
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
<p className="mt-1 text-sm text-text-muted">{description}</p>
</header>
{/* Login card */}
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-text-secondary mb-1.5"
>
Password
</label>
<input
{stage === "login" && (
<form onSubmit={handleLogin} className="space-y-4">
<PasswordField
id="password"
type="password"
label="Password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<ErrorMessage message={error} />
<PrimaryButton loading={loading} disabled={password.length < 8}>
{setupRequired ? "Save Password & Login" : "Sign In"}
</PrimaryButton>
{!setupRequired && (
<button
type="button"
onClick={beginReset}
disabled={loading}
className="w-full rounded-lg py-2 text-sm font-semibold text-primary hover:text-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !password}
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{isSetup ? "Saving..." : "Signing in..."}
</span>
) : isSetup ? (
"Save Password & Login"
) : (
"Sign In"
Forgot password?
</button>
)}
</button>
</form>
</form>
)}
{stage === "token" && (
<form onSubmit={handleToken} className="space-y-4">
<div>
<label htmlFor="reset-token" className="mb-1.5 block text-sm font-medium text-text-secondary">Reset token</label>
<input
id="reset-token"
value={token}
onChange={(event) => 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"
/>
</div>
<ErrorMessage message={error} />
<PrimaryButton loading={loading} disabled={!token.trim()}>Validate Token</PrimaryButton>
<SecondaryButton onClick={returnToLogin}>Back to sign in</SecondaryButton>
</form>
)}
{stage === "password" && (
<form onSubmit={handlePasswordReset} className="space-y-4">
<div>
<PasswordField
id="new-password"
label="New password"
value={password}
onChange={setPassword}
describedBy="password-length-status"
invalid={password.length < 8}
autoFocus
/>
<ValidationMessage
id="password-length-status"
valid={password.length >= 8}
validText="Password meets the length requirement."
invalidText="Password must be at least 8 characters."
/>
</div>
<div>
<PasswordField
id="confirm-password"
label="Confirm new password"
value={confirmPassword}
onChange={setConfirmPassword}
describedBy={confirmPassword ? "password-match-status" : undefined}
invalid={confirmPassword.length > 0 && confirmPassword !== password}
/>
{confirmPassword && (
<ValidationMessage
id="password-match-status"
valid={confirmPassword === password}
validText="Passwords match."
invalidText="Passwords do not match."
/>
)}
</div>
<ErrorMessage message={error} />
<PrimaryButton
loading={loading}
disabled={password.length < 8 || confirmPassword !== password}
>
Set New Password
</PrimaryButton>
<SecondaryButton onClick={returnToLogin}>Cancel</SecondaryButton>
</form>
)}
{stage === "success" && (
<div className="space-y-4 text-center">
<div className="rounded-xl bg-success-bg px-4 py-3 text-sm text-success" role="status">
Your password has been reset successfully.
</div>
<PrimaryButton loading={false} onClick={returnToLogin}>Sign In</PrimaryButton>
</div>
)}
</div>
</div>
</main>
</div>
);
}
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 (
<div>
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
<input
id={id}
type="password"
value={value}
onChange={(event) => 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"
/>
</div>
);
}
function ValidationMessage({ id, valid, validText, invalidText }: {
id: string;
valid: boolean;
validText: string;
invalidText: string;
}) {
return (
<p
id={id}
aria-live="polite"
className={`mt-1.5 text-sm ${valid ? "text-success" : "text-error"}`}
>
{valid ? validText : invalidText}
</p>
);
}
function ErrorMessage({ message }: { message: string }) {
if (!message) return null;
return <div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">{message}</div>;
}
function PrimaryButton({ loading, disabled = false, onClick, children }: {
loading: boolean;
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
return (
<button
type={onClick ? "button" : "submit"}
onClick={onClick}
disabled={loading || disabled}
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Working..." : children}
</button>
);
}
function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button type="button" onClick={onClick} className="min-h-11 w-full rounded-xl border border-border px-4 font-semibold text-text-secondary hover:bg-bg-surface-alt focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
{children}
</button>
);
}