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

227
src/services/authService.ts Normal file
View 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;
}