Refactor login flow with reset token support
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
This commit is contained in:
parent
f0548bcc6e
commit
95af276d2e
11 changed files with 679 additions and 226 deletions
BIN
dev.db
BIN
dev.db
Binary file not shown.
|
|
@ -1,116 +1,29 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { loginSchema } from "@/lib/validation/authSchemas";
|
||||||
import { createSession } from "@/lib/auth";
|
|
||||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||||
|
import { login } from "@/services/authService";
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Rate limit by IP
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
if (!checkRateLimit(`login:${ip}`).allowed) {
|
||||||
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rateCheck = checkRateLimit(ip);
|
|
||||||
|
|
||||||
if (!rateCheck.allowed) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Too many requests. Try again shortly." },
|
{ error: "Too many requests. Try again shortly." },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
|
||||||
if (!body?.password || typeof body.password !== "string") {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Password is required" },
|
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check lockout state
|
const result = await login(parsed.data.password);
|
||||||
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
|
if (!result.ok) {
|
||||||
if (!security) {
|
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||||
security = await prisma.authSecurity.create({ data: { id: 1 } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/app/api/auth/password-reset/complete/route.ts
Normal file
24
src/app/api/auth/password-reset/complete/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
25
src/app/api/auth/password-reset/request/route.ts
Normal file
25
src/app/api/auth/password-reset/request/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
25
src/app/api/auth/password-reset/verify/route.ts
Normal file
25
src/app/api/auth/password-reset/verify/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -1,158 +1,333 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
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() {
|
export default function LoginPage() {
|
||||||
|
const [stage, setStage] = useState<LoginStage>("login");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isSetup, setIsSetup] = useState<boolean | null>(null);
|
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/setup-status")
|
fetch("/api/auth/setup-status")
|
||||||
.then((res) => res.json())
|
.then((response) => response.json())
|
||||||
.then((data) => setIsSetup(data.setupRequired))
|
.then((data) => setSetupRequired(data.setupRequired))
|
||||||
.catch(() => setIsSetup(false)); // fallback
|
.catch(() => setSetupRequired(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function run(action: () => Promise<void>) {
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/login", {
|
await action();
|
||||||
method: "POST",
|
} catch (caught) {
|
||||||
headers: { "Content-Type": "application/json" },
|
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
|
||||||
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.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-4">
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="animate-pulse flex items-center justify-center">
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
<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 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 -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-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="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
||||||
<div className="relative w-full max-w-md animate-fade-in">
|
|
||||||
{/* Logo / Title area */}
|
<main className="relative w-full max-w-md animate-fade-in">
|
||||||
<div className="mb-7 text-center">
|
<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>
|
<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>
|
<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">
|
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
|
||||||
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
|
<p className="mt-1 text-sm text-text-muted">{description}</p>
|
||||||
</h1>
|
</header>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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">
|
<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">
|
{stage === "login" && (
|
||||||
<div>
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
<label
|
<PasswordField
|
||||||
htmlFor="password"
|
|
||||||
className="block text-sm font-medium text-text-secondary mb-1.5"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={setPassword}
|
||||||
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
|
||||||
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>
|
<ErrorMessage message={error} />
|
||||||
|
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
||||||
{error && (
|
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||||
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
|
</PrimaryButton>
|
||||||
<svg
|
{!setupRequired && (
|
||||||
className="w-4 h-4 flex-shrink-0"
|
<button
|
||||||
fill="none"
|
type="button"
|
||||||
stroke="currentColor"
|
onClick={beginReset}
|
||||||
viewBox="0 0 24 24"
|
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
|
Forgot password?
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
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"
|
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ import { cookies } from "next/headers";
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
sessionGeneration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PasswordResetSessionData {
|
||||||
|
nonce?: string;
|
||||||
|
expiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionOptions: SessionOptions = {
|
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() {
|
export async function getSession() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
return getIronSession<SessionData>(cookieStore, sessionOptions);
|
return getIronSession<SessionData>(cookieStore, sessionOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession() {
|
export async function createSession(sessionGeneration: number) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
session.isAuthenticated = true;
|
session.isAuthenticated = true;
|
||||||
|
session.sessionGeneration = sessionGeneration;
|
||||||
await session.save();
|
await session.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,3 +54,23 @@ export async function isAuthenticated() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
return session.isAuthenticated === true;
|
return session.isAuthenticated === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPasswordResetSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
return getIronSession<PasswordResetSessionData>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,25 @@ const store = new Map<string, RateLimitEntry>();
|
||||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||||
const MAX_REQUESTS = 10;
|
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 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
|
// 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 oldestInWindow = entry.timestamps[0];
|
||||||
const retryAfterMs = WINDOW_MS - (now - oldestInWindow);
|
const retryAfterMs = windowMs - (now - oldestInWindow);
|
||||||
return { allowed: false, retryAfterMs };
|
return { allowed: false, retryAfterMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.timestamps.push(now);
|
entry.timestamps.push(now);
|
||||||
store.set(ip, entry);
|
store.set(key, entry);
|
||||||
return { allowed: true, retryAfterMs: 0 };
|
return { allowed: true, retryAfterMs: 0 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/lib/validation/authSchemas.ts
Normal file
19
src/lib/validation/authSchemas.ts
Normal file
|
|
@ -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"],
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getIronSession } from "iron-session";
|
import { getIronSession } from "iron-session";
|
||||||
import type { SessionData } from "@/lib/auth";
|
import type { SessionData } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
const sessionOptions = {
|
const sessionOptions = {
|
||||||
password:
|
password:
|
||||||
|
|
@ -9,18 +10,15 @@ const sessionOptions = {
|
||||||
cookieName: "study-app-session",
|
cookieName: "study-app-session",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Routes that don't require authentication
|
|
||||||
const publicPaths = ["/login", "/api/auth", "/shared"];
|
const publicPaths = ["/login", "/api/auth", "/shared"];
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Allow public routes
|
|
||||||
if (publicPaths.some((path) => pathname.startsWith(path))) {
|
if (publicPaths.some((path) => pathname.startsWith(path))) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow static assets and Next.js internals
|
|
||||||
if (
|
if (
|
||||||
pathname.startsWith("/_next") ||
|
pathname.startsWith("/_next") ||
|
||||||
pathname.startsWith("/favicon") ||
|
pathname.startsWith("/favicon") ||
|
||||||
|
|
@ -29,15 +27,18 @@ export async function middleware(request: NextRequest) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check session
|
|
||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
const session = await getIronSession<SessionData>(
|
const session = await getIronSession<SessionData>(request, response, sessionOptions);
|
||||||
request,
|
const generationSetting = await prisma.setting.findUnique({
|
||||||
response,
|
where: { key: "auth_session_generation" },
|
||||||
sessionOptions
|
});
|
||||||
);
|
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));
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
227
src/services/authService.ts
Normal file
227
src/services/authService.ts
Normal file
|
|
@ -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<number> {
|
||||||
|
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<LoginResult> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue