114 lines
3.5 KiB
JavaScript
114 lines
3.5 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const { authenticator } = require('otplib');
|
|
const QRCode = require('qrcode');
|
|
const { encryptSecret, decryptSecret } = require('./encryptionService');
|
|
|
|
const APP_NAME = 'Bill Tracker';
|
|
const RECOVERY_CODE_COUNT = 8;
|
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
authenticator.options = { window: 1 }; // accept ±1 time step for clock drift
|
|
|
|
function generateSecret() {
|
|
return authenticator.generateSecret(20);
|
|
}
|
|
|
|
async function generateQrCode(secret, username) {
|
|
const uri = authenticator.keyuri(username, APP_NAME, secret);
|
|
const dataUrl = await QRCode.toDataURL(uri, { width: 200, margin: 2 });
|
|
return { uri, qr_data_url: dataUrl };
|
|
}
|
|
|
|
function verifyToken(encryptedSecret, token) {
|
|
if (!encryptedSecret || !token) return false;
|
|
try {
|
|
const secret = decryptSecret(encryptedSecret);
|
|
return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret });
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function verifyTokenRaw(secret, token) {
|
|
if (!secret || !token) return false;
|
|
try {
|
|
return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret });
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function generateRecoveryCodes() {
|
|
return Array.from({ length: RECOVERY_CODE_COUNT }, () => {
|
|
const bytes = crypto.randomBytes(5);
|
|
const hex = bytes.toString('hex').toUpperCase();
|
|
return `${hex.slice(0, 5)}-${hex.slice(5)}`;
|
|
});
|
|
}
|
|
|
|
function hashRecoveryCode(code) {
|
|
return crypto.createHash('sha256').update(code.replace(/-/g, '').toUpperCase()).digest('hex');
|
|
}
|
|
|
|
// Returns { used: true } if a matching unused recovery code was found and consumed.
|
|
function consumeRecoveryCode(db, userId, code) {
|
|
const normalized = code.replace(/[-\s]/g, '').toUpperCase();
|
|
const user = db.prepare('SELECT totp_recovery_codes FROM users WHERE id = ?').get(userId);
|
|
if (!user?.totp_recovery_codes) return { used: false };
|
|
|
|
let stored;
|
|
try {
|
|
stored = JSON.parse(decryptSecret(user.totp_recovery_codes));
|
|
} catch {
|
|
return { used: false };
|
|
}
|
|
|
|
const incomingHash = crypto.createHash('sha256').update(normalized).digest('hex');
|
|
const idx = stored.findIndex(h => h === incomingHash);
|
|
if (idx === -1) return { used: false };
|
|
|
|
stored.splice(idx, 1);
|
|
db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?')
|
|
.run(encryptSecret(JSON.stringify(stored)), userId);
|
|
|
|
return { used: true, remaining: stored.length };
|
|
}
|
|
|
|
// Short-lived challenge token issued after password passes, before TOTP is verified.
|
|
function createChallenge(db, userId) {
|
|
const id = crypto.randomUUID();
|
|
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS)
|
|
.toISOString().slice(0, 19).replace('T', ' ');
|
|
db.prepare('DELETE FROM totp_challenges WHERE user_id = ?').run(userId);
|
|
db.prepare('INSERT INTO totp_challenges (id, user_id, expires_at) VALUES (?, ?, ?)').run(id, userId, expiresAt);
|
|
return id;
|
|
}
|
|
|
|
function consumeChallenge(db, challengeId) {
|
|
const row = db.prepare(
|
|
"SELECT user_id FROM totp_challenges WHERE id = ? AND expires_at > datetime('now')"
|
|
).get(challengeId);
|
|
if (!row) return null;
|
|
db.prepare('DELETE FROM totp_challenges WHERE id = ?').run(challengeId);
|
|
return row.user_id;
|
|
}
|
|
|
|
function pruneExpiredChallenges(db) {
|
|
db.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run();
|
|
}
|
|
|
|
module.exports = {
|
|
generateSecret,
|
|
generateQrCode,
|
|
verifyToken,
|
|
verifyTokenRaw,
|
|
generateRecoveryCodes,
|
|
hashRecoveryCode,
|
|
consumeRecoveryCode,
|
|
createChallenge,
|
|
consumeChallenge,
|
|
pruneExpiredChallenges,
|
|
};
|