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
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue