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
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