BillTracker/services/totpService.js

112 lines
3.4 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const crypto = require('crypto');
const { generateSecret, generateURI, generateSync, verifySync } = 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;
function newSecret() {
return generateSecret(20);
}
async function generateQrCode(secret, username) {
const uri = generateURI({ secret, account: username, issuer: APP_NAME, type: 'totp' });
const qr_data_url = await QRCode.toDataURL(uri, { width: 200, margin: 2 });
return { uri, qr_data_url };
}
function verifyToken(encryptedSecret, token) {
if (!encryptedSecret || !token) return false;
try {
const secret = decryptSecret(encryptedSecret);
const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' });
return result?.valid === true;
} catch {
return false;
}
}
function verifyTokenRaw(secret, token) {
if (!secret || !token) return false;
try {
const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' });
return result?.valid === true;
} catch {
return false;
}
}
function makeRecoveryCodes() {
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');
}
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 };
}
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: newSecret,
generateQrCode,
verifyToken,
verifyTokenRaw,
generateRecoveryCodes: makeRecoveryCodes,
hashRecoveryCode,
consumeRecoveryCode,
createChallenge,
consumeChallenge,
pruneExpiredChallenges,
};