feat(2FA): add core library functions for TOTP, backup codes and rate limiting
This commit is contained in:
1 parent
8ce618c5a3
commit
683b956b9f
5 files changed
+148
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user