← back to Wine Finder Next
lib/twoFactorAuth.ts
472 lines
// Two-Factor Authentication (2FA) Implementation
// TOTP (Time-based One-Time Password) and backup codes support
import crypto from 'crypto';
import { logAudit } from './auth';
import { logSecurityEvent } from './securityMonitoring';
interface TOTPSecret {
userId: string;
secret: string;
backupCodes: string[];
usedBackupCodes: string[];
createdAt: Date;
lastUsed?: Date;
verified: boolean;
}
interface TOTPVerification {
userId: string;
attempts: number;
lastAttempt: Date;
lockedUntil?: Date;
}
// Storage - in production use database
const totpSecrets = new Map<string, TOTPSecret>();
const verificationAttempts = new Map<string, TOTPVerification>();
const pendingSetup = new Map<string, { secret: string; qrCode: string; expiresAt: Date }>();
// Configuration
const TOTP_CONFIG = {
algorithm: 'sha1',
digits: 6,
period: 30, // seconds
window: 1, // accept codes from 1 period before/after
maxAttempts: 5,
lockoutDuration: 15 * 60 * 1000, // 15 minutes
backupCodeCount: 10,
backupCodeLength: 8,
setupTimeout: 10 * 60 * 1000, // 10 minutes for setup
issuer: 'Wine DAO Platform',
label: 'WineDAO'
};
// Generate TOTP secret
export function generateTOTPSecret(userId: string): {
secret: string;
qrCode: string;
backupCodes: string[];
} {
// Generate secret
const secret = base32Encode(crypto.randomBytes(20));
// Generate backup codes
const backupCodes = generateBackupCodes();
// Generate QR code URL
const otpauthUrl = `otpauth://totp/${TOTP_CONFIG.label}:${userId}?` +
`secret=${secret}&issuer=${TOTP_CONFIG.issuer}&algorithm=${TOTP_CONFIG.algorithm.toUpperCase()}` +
`&digits=${TOTP_CONFIG.digits}&period=${TOTP_CONFIG.period}`;
// Store pending setup
pendingSetup.set(userId, {
secret,
qrCode: otpauthUrl,
expiresAt: new Date(Date.now() + TOTP_CONFIG.setupTimeout)
});
return {
secret,
qrCode: otpauthUrl,
backupCodes
};
}
// Enable TOTP for user
export function enableTOTP(
userId: string,
verificationCode: string
): { success: boolean; backupCodes?: string[]; error?: string } {
const pending = pendingSetup.get(userId);
if (!pending) {
return { success: false, error: 'No pending 2FA setup found' };
}
if (new Date() > pending.expiresAt) {
pendingSetup.delete(userId);
return { success: false, error: '2FA setup expired' };
}
// Verify the code
const valid = verifyTOTPCode(pending.secret, verificationCode);
if (!valid) {
return { success: false, error: 'Invalid verification code' };
}
// Generate and store secret with backup codes
const backupCodes = generateBackupCodes();
const totpSecret: TOTPSecret = {
userId,
secret: pending.secret,
backupCodes: [...backupCodes], // Copy array
usedBackupCodes: [],
createdAt: new Date(),
verified: true
};
totpSecrets.set(userId, totpSecret);
pendingSetup.delete(userId);
logAudit({
userId,
action: '2FA_ENABLE',
resource: `user/${userId}/2fa`,
ipAddress: 'unknown',
userAgent: 'unknown',
success: true
});
return { success: true, backupCodes };
}
// Verify TOTP code
export function verifyTOTP(
userId: string,
code: string,
ipAddress?: string
): { valid: boolean; reason?: string } {
const totpSecret = totpSecrets.get(userId);
if (!totpSecret) {
return { valid: false, reason: '2FA not enabled' };
}
// Check if account is locked
const attempts = verificationAttempts.get(userId);
if (attempts && attempts.lockedUntil && new Date() < attempts.lockedUntil) {
const remainingTime = Math.ceil((attempts.lockedUntil.getTime() - Date.now()) / 1000 / 60);
return { valid: false, reason: `Account locked. Try again in ${remainingTime} minutes` };
}
// Try TOTP code first
const validTOTP = verifyTOTPCode(totpSecret.secret, code);
if (validTOTP) {
// Reset attempts
verificationAttempts.delete(userId);
totpSecret.lastUsed = new Date();
logAudit({
userId,
action: '2FA_VERIFY_SUCCESS',
resource: `user/${userId}/2fa`,
ipAddress: ipAddress || 'unknown',
userAgent: 'unknown',
success: true
});
return { valid: true };
}
// Try backup code
const validBackup = verifyBackupCode(userId, code);
if (validBackup) {
logAudit({
userId,
action: '2FA_BACKUP_USED',
resource: `user/${userId}/2fa`,
ipAddress: ipAddress || 'unknown',
userAgent: 'unknown',
success: true,
metadata: { remainingBackupCodes: totpSecret.backupCodes.length - totpSecret.usedBackupCodes.length }
});
return { valid: true };
}
// Invalid code - track attempts
trackFailedAttempt(userId, ipAddress);
return { valid: false, reason: 'Invalid 2FA code' };
}
// Verify backup code
function verifyBackupCode(userId: string, code: string): boolean {
const totpSecret = totpSecrets.get(userId);
if (!totpSecret) {
return false;
}
// Normalize code (remove spaces, dashes)
const normalizedCode = code.replace(/[\s-]/g, '').toUpperCase();
// Check if code exists and hasn't been used
const codeIndex = totpSecret.backupCodes.findIndex(
bc => bc === normalizedCode && !totpSecret.usedBackupCodes.includes(bc)
);
if (codeIndex === -1) {
return false;
}
// Mark code as used
totpSecret.usedBackupCodes.push(totpSecret.backupCodes[codeIndex]);
// Alert if running low on backup codes
const remainingCodes = totpSecret.backupCodes.length - totpSecret.usedBackupCodes.length;
if (remainingCodes <= 2) {
logSecurityEvent({
type: 'SUSPICIOUS_ACTIVITY',
severity: 'low',
userId,
ipAddress: 'unknown',
userAgent: 'unknown',
details: {
reason: 'Low on backup codes',
remaining: remainingCodes
}
});
}
return true;
}
// Track failed verification attempts
function trackFailedAttempt(userId: string, ipAddress?: string): void {
let attempts = verificationAttempts.get(userId);
if (!attempts) {
attempts = {
userId,
attempts: 0,
lastAttempt: new Date()
};
verificationAttempts.set(userId, attempts);
}
attempts.attempts++;
attempts.lastAttempt = new Date();
// Lock account after max attempts
if (attempts.attempts >= TOTP_CONFIG.maxAttempts) {
attempts.lockedUntil = new Date(Date.now() + TOTP_CONFIG.lockoutDuration);
logSecurityEvent({
type: 'BRUTE_FORCE_ATTEMPT',
severity: 'high',
userId,
ipAddress: ipAddress || 'unknown',
userAgent: 'unknown',
details: {
reason: '2FA brute force detected',
attempts: attempts.attempts
}
});
}
logAudit({
userId,
action: '2FA_VERIFY_FAIL',
resource: `user/${userId}/2fa`,
ipAddress: ipAddress || 'unknown',
userAgent: 'unknown',
success: false,
metadata: { attempt: attempts.attempts }
});
}
// Generate new backup codes
export function regenerateBackupCodes(userId: string): {
success: boolean;
backupCodes?: string[];
error?: string;
} {
const totpSecret = totpSecrets.get(userId);
if (!totpSecret) {
return { success: false, error: '2FA not enabled' };
}
const newBackupCodes = generateBackupCodes();
totpSecret.backupCodes = [...newBackupCodes];
totpSecret.usedBackupCodes = [];
logAudit({
userId,
action: '2FA_BACKUP_REGENERATE',
resource: `user/${userId}/2fa`,
ipAddress: 'unknown',
userAgent: 'unknown',
success: true
});
return { success: true, backupCodes: newBackupCodes };
}
// Disable TOTP
export function disableTOTP(userId: string): { success: boolean } {
const had2FA = totpSecrets.has(userId);
totpSecrets.delete(userId);
verificationAttempts.delete(userId);
pendingSetup.delete(userId);
if (had2FA) {
logAudit({
userId,
action: '2FA_DISABLE',
resource: `user/${userId}/2fa`,
ipAddress: 'unknown',
userAgent: 'unknown',
success: true
});
}
return { success: true };
}
// Check if user has 2FA enabled
export function has2FAEnabled(userId: string): boolean {
return totpSecrets.has(userId);
}
// Get 2FA status
export function get2FAStatus(userId: string): {
enabled: boolean;
verified: boolean;
backupCodesRemaining: number;
lastUsed?: Date;
} {
const totpSecret = totpSecrets.get(userId);
if (!totpSecret) {
return {
enabled: false,
verified: false,
backupCodesRemaining: 0
};
}
return {
enabled: true,
verified: totpSecret.verified,
backupCodesRemaining: totpSecret.backupCodes.length - totpSecret.usedBackupCodes.length,
lastUsed: totpSecret.lastUsed
};
}
// Helper functions
// Generate backup codes
function generateBackupCodes(): string[] {
const codes: string[] = [];
for (let i = 0; i < TOTP_CONFIG.backupCodeCount; i++) {
const code = crypto.randomBytes(TOTP_CONFIG.backupCodeLength)
.toString('hex')
.toUpperCase()
.substring(0, TOTP_CONFIG.backupCodeLength);
// Format as XXXX-XXXX for readability
const formatted = code.match(/.{1,4}/g)?.join('-') || code;
codes.push(formatted);
}
return codes;
}
// Verify TOTP code
function verifyTOTPCode(secret: string, code: string): boolean {
const now = Math.floor(Date.now() / 1000);
const period = TOTP_CONFIG.period;
// Check current period and window
for (let i = -TOTP_CONFIG.window; i <= TOTP_CONFIG.window; i++) {
const time = now + (i * period);
const counter = Math.floor(time / period);
const generatedCode = generateTOTPCode(secret, counter);
if (generatedCode === code) {
return true;
}
}
return false;
}
// Generate TOTP code
function generateTOTPCode(secret: string, counter: number): string {
const decodedSecret = base32Decode(secret);
const buffer = Buffer.alloc(8);
buffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buffer.writeUInt32BE(counter & 0xffffffff, 4);
const hmac = crypto.createHmac(TOTP_CONFIG.algorithm, decodedSecret);
hmac.update(buffer);
const digest = hmac.digest();
const offset = digest[digest.length - 1] & 0x0f;
const code = (
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff)
) % Math.pow(10, TOTP_CONFIG.digits);
return code.toString().padStart(TOTP_CONFIG.digits, '0');
}
// Base32 encoding/decoding
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(buffer: Buffer): string {
let result = '';
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
result += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return result;
}
function base32Decode(str: string): Buffer {
const buffer: number[] = [];
let bits = 0;
let value = 0;
for (const char of str.toUpperCase()) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) continue;
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
buffer.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(buffer);
}
// Cleanup old attempts
setInterval(() => {
const now = new Date();
for (const [userId, attempts] of verificationAttempts.entries()) {
if (attempts.lockedUntil && now > attempts.lockedUntil) {
verificationAttempts.delete(userId);
}
}
}, 60000); // Every minute
// Export configuration for testing
export { TOTP_CONFIG };