All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
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;
|
|
}
|