feat(2FA): add core library functions for TOTP, backup codes and rate limiting

This commit is contained in:
Rudy B. committed 2026-04-25 01:40:06 +02:00
1 parent 8ce618c5a3
commit 683b956b9f
5 files changed
+148

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
import { randomBytes } from 'crypto';
import bcrypt from 'bcryptjs';
const BACKUP_CODE_COUNT = 10;
const BCRYPT_ROUNDS = 10;
export async function generateBackupCodes(): Promise<{
plaintext: Array<string>;
hashed: Array<string>;
}> {
const plaintext = Array.from(
{ length: BACKUP_CODE_COUNT },
() =>
randomBytes(5).toString('hex').toUpperCase() +
'-' +
randomBytes(5).toString('hex').toUpperCase(),
);
const hashed = await Promise.all(plaintext.map(code => bcrypt.hash(code, BCRYPT_ROUNDS)));
return { plaintext, hashed };
}
export async function verifyBackupCode(
inputCode: string,
storedHashes: Array<string>,
): Promise<number | null> {
for (let i = 0; i < storedHashes.length; i++) {
const match = await bcrypt.compare(inputCode.toUpperCase(), storedHashes[i]);
if (match) return i;
}
return null;
}
+39
View File
@@ -0,0 +1,39 @@
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
/** Loads and validates the 256-bit encryption key from TWO_FACTOR_ENCRYPTION_KEY. */
function getKey(): Buffer {
const hex = process.env.TWO_FACTOR_ENCRYPTION_KEY;
if (!hex || hex.length !== 64) throw new Error('TWO_FACTOR_ENCRYPTION_KEY is missing or invalid');
return Buffer.from(hex, 'hex');
}
/** Encrypts a TOTP secret using AES-256-GCM. Returns `ciphertext:iv:authTag` as hex. */
export function encryptSecret(plaintext: string): string {
const key = getKey();
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${encrypted.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}`;
}
/** Decrypts a TOTP secret previously encrypted by {@link encryptSecret}. */
export function decryptSecret(stored: string): string {
const key = getKey();
const [ciphertextHex, ivHex, authTagHex] = stored.split(':');
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertextHex, 'hex')),
decipher.final(),
]);
return decrypted.toString('utf8');
}
/** Compares two strings in constant time to prevent timing attacks. */
export function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
+31
View File
@@ -0,0 +1,31 @@
import prisma from '@/lib/prisma';
const MAX_ATTEMPTS = 5;
const LOCKOUT_MINUTES = 15;
export async function checkRateLimit(
userId: string,
): Promise<{ allowed: boolean; lockedUntil?: Date }> {
const record = await prisma.client.twoFactorRateLimit.findUnique({ where: { userId } });
if (!record) return { allowed: true };
if (record.lockedUntil && record.lockedUntil > new Date()) {
return { allowed: false, lockedUntil: record.lockedUntil };
}
return { allowed: true };
}
export async function recordFailedAttempt(userId: string): Promise<void> {
const record = await prisma.client.twoFactorRateLimit.upsert({
where: { userId },
update: { attempts: { increment: 1 }, updatedAt: new Date() },
create: { userId, attempts: 1, updatedAt: new Date() },
});
if (record.attempts >= MAX_ATTEMPTS) {
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000);
await prisma.client.twoFactorRateLimit.update({ where: { userId }, data: { lockedUntil } });
}
}
export async function resetRateLimit(userId: string): Promise<void> {
await prisma.client.twoFactorRateLimit.deleteMany({ where: { userId } });
}
+24
View File
@@ -0,0 +1,24 @@
import prisma from '@/lib/prisma';
/** Records a one-time password as used so it cannot be reused within the next 90 seconds. */
export async function markOtpUsed(userId: string, otp: string): Promise<void> {
const expiresAt = new Date(Date.now() + 90 * 1000);
await prisma.client.twoFactorOtpUsed.upsert({
where: { userId_otp: { userId, otp } },
update: { expiresAt },
create: { userId, otp, expiresAt },
});
}
/** Returns true if this one-time password was already used recently, indicating a replay attack. Expired records are cleaned up automatically. */
export async function isOtpReplayed(userId: string, otp: string): Promise<boolean> {
const record = await prisma.client.twoFactorOtpUsed.findUnique({
where: { userId_otp: { userId, otp } },
});
if (!record) return false;
if (record.expiresAt < new Date()) {
await prisma.client.twoFactorOtpUsed.delete({ where: { userId_otp: { userId, otp } } });
return false;
}
return true;
}
+23
View File
@@ -0,0 +1,23 @@
import { generateSecret, generateURI, verify } from 'otplib';
import qrcode from 'qrcode';
export function generateTotpSecret(): string {
return generateSecret();
}
export function generateOtpAuthUri(secret: string, userEmail: string): string {
return generateURI({
issuer: 'Umami',
label: userEmail,
secret,
});
}
export async function generateQrCodeDataUrl(otpAuthUri: string): Promise<string> {
return qrcode.toDataURL(otpAuthUri, { errorCorrectionLevel: 'H' });
}
export async function verifyTotp(token: string, secret: string): Promise<boolean> {
const result = await verify({ secret, token });
return result.valid;
}