|
| 1 | +// Two-factor auth (TOTP) — Phase 1 controller. Opt-in, password-login only. |
| 2 | +// Endpoints: |
| 3 | +// POST /api/v2/auth/2fa/setup (authed) → otpauth URL + QR, stores a PENDING secret |
| 4 | +// POST /api/v2/auth/2fa/verify (authed) { code } → enable + return recovery codes once |
| 5 | +// POST /api/v2/auth/2fa/disable (authed) { code } → clear 2FA (needs a valid TOTP/recovery code) |
| 6 | +// POST /api/v2/auth/2fa/validate (public) { tempToken, code } → exchange for a real session |
| 7 | +// The secret is AES-encrypted at rest; recovery codes are bcrypt-hashed and |
| 8 | +// single-use. Nothing secret is ever returned to the client except the one-time |
| 9 | +// manual-entry key (setup) and the one-time recovery codes (verify). |
| 10 | +const mongoose = require('mongoose'); |
| 11 | +const QRCode = require('qrcode'); |
| 12 | +const mongoC = require('../../../utils/mongo-handler/mongoQueries'); |
| 13 | +const { dbCollections } = require('../../../Config/collections'); |
| 14 | +const logger = require('../../../Config/loggerConfig'); |
| 15 | +const rules = require('../helpers/twoFactorRules'); |
| 16 | +const loginSessionCtrl = require('./loginSession'); |
| 17 | + |
| 18 | +// ── DB helpers (userAuth lives in the global DB, keyed by _id) ─────────── |
| 19 | +const findUserAuthById = (uid) => mongoC.MongoDbCrudOpration( |
| 20 | + dbCollections.GLOBAL, |
| 21 | + { type: dbCollections.USER_AUTH, data: [{ _id: new mongoose.Types.ObjectId(uid) }] }, |
| 22 | + 'findOne', |
| 23 | +); |
| 24 | +// twoFactor is a Mixed object — write it wholesale to avoid partial-update pitfalls. |
| 25 | +const setTwoFactor = (uid, twoFactor) => mongoC.MongoDbCrudOpration( |
| 26 | + dbCollections.GLOBAL, |
| 27 | + { type: dbCollections.USER_AUTH, data: [{ _id: new mongoose.Types.ObjectId(uid) }, { $set: { twoFactor } }] }, |
| 28 | + 'updateOne', |
| 29 | +); |
| 30 | + |
| 31 | +// Index of the first matching (still-unused) recovery code, or -1. |
| 32 | +const findRecoveryMatch = async (code, hashes) => { |
| 33 | + if (!code || !Array.isArray(hashes)) return -1; |
| 34 | + for (let i = 0; i < hashes.length; i += 1) { |
| 35 | + // eslint-disable-next-line no-await-in-loop |
| 36 | + if (await rules.verifyRecoveryCode(code, hashes[i])) return i; |
| 37 | + } |
| 38 | + return -1; |
| 39 | +}; |
| 40 | + |
| 41 | +// GET /api/v2/auth/2fa/status (authed) — is 2FA enabled for the logged-in user? |
| 42 | +exports.twoFaStatus = async (req, res) => { |
| 43 | + try { |
| 44 | + const user = await findUserAuthById(req.uid); |
| 45 | + return res.status(200).json({ status: true, data: { enabled: !!(user && user.twoFactor && user.twoFactor.enabled) } }); |
| 46 | + } catch (error) { |
| 47 | + logger.error(`2FA status error: ${error?.message || error}`); |
| 48 | + return res.status(400).json({ status: false, message: error?.message || 'Failed to read 2FA status' }); |
| 49 | + } |
| 50 | +}; |
| 51 | + |
| 52 | +// POST /api/v2/auth/2fa/setup (authed) — start enrollment. |
| 53 | +exports.twoFaSetup = async (req, res) => { |
| 54 | + try { |
| 55 | + const uid = req.uid; |
| 56 | + const user = await findUserAuthById(uid); |
| 57 | + if (!user?._id) return res.status(404).json({ status: false, message: 'User not found' }); |
| 58 | + if (user.twoFactor && user.twoFactor.enabled) { |
| 59 | + return res.status(409).json({ status: false, message: 'Two-factor authentication is already enabled. Disable it first to re-enroll.' }); |
| 60 | + } |
| 61 | + const secret = rules.generateSecret(); |
| 62 | + const otpauthUrl = rules.keyuri(user.email, secret); |
| 63 | + const qrDataUrl = await QRCode.toDataURL(otpauthUrl); |
| 64 | + await setTwoFactor(uid, { ...(user.twoFactor || {}), enabled: false, pendingSecretEnc: rules.encryptSecret(secret) }); |
| 65 | + return res.status(200).json({ |
| 66 | + status: true, |
| 67 | + statusText: 'Scan the QR code or enter the key in your authenticator app.', |
| 68 | + data: { otpauthUrl, qrDataUrl, secret }, // `secret` = manual-entry key, shown once |
| 69 | + }); |
| 70 | + } catch (error) { |
| 71 | + logger.error(`2FA setup error: ${error?.message || error}`); |
| 72 | + return res.status(400).json({ status: false, message: error?.message || 'Failed to start 2FA setup' }); |
| 73 | + } |
| 74 | +}; |
| 75 | + |
| 76 | +// POST /api/v2/auth/2fa/verify (authed) { code } — confirm a code, enable, return recovery codes. |
| 77 | +exports.twoFaVerify = async (req, res) => { |
| 78 | + try { |
| 79 | + const uid = req.uid; |
| 80 | + const code = req.body?.code; |
| 81 | + const user = await findUserAuthById(uid); |
| 82 | + if (!user?._id) return res.status(404).json({ status: false, message: 'User not found' }); |
| 83 | + const pending = user.twoFactor && user.twoFactor.pendingSecretEnc; |
| 84 | + if (!pending) return res.status(400).json({ status: false, message: 'Start 2FA setup first.' }); |
| 85 | + const secret = rules.decryptSecret(pending); |
| 86 | + if (!secret || !rules.verifyTotp(code, secret)) { |
| 87 | + return res.status(400).json({ status: false, message: 'That code is not valid. Check your authenticator app and try again.' }); |
| 88 | + } |
| 89 | + const recoveryCodes = rules.generateRecoveryCodes(); |
| 90 | + const hashed = await Promise.all(recoveryCodes.map((c) => rules.hashRecoveryCode(c))); |
| 91 | + await setTwoFactor(uid, { enabled: true, secretEnc: pending, recoveryCodes: hashed, enrolledAt: new Date() }); |
| 92 | + return res.status(200).json({ |
| 93 | + status: true, |
| 94 | + statusText: 'Two-factor authentication is now enabled. Save these recovery codes — they are shown only once.', |
| 95 | + data: { recoveryCodes }, |
| 96 | + }); |
| 97 | + } catch (error) { |
| 98 | + logger.error(`2FA verify error: ${error?.message || error}`); |
| 99 | + return res.status(400).json({ status: false, message: error?.message || 'Failed to enable 2FA' }); |
| 100 | + } |
| 101 | +}; |
| 102 | + |
| 103 | +// POST /api/v2/auth/2fa/disable (authed) { code } — needs a current TOTP or a recovery code. |
| 104 | +exports.twoFaDisable = async (req, res) => { |
| 105 | + try { |
| 106 | + const uid = req.uid; |
| 107 | + const code = req.body?.code; |
| 108 | + const user = await findUserAuthById(uid); |
| 109 | + if (!user?._id) return res.status(404).json({ status: false, message: 'User not found' }); |
| 110 | + if (!(user.twoFactor && user.twoFactor.enabled)) { |
| 111 | + return res.status(400).json({ status: false, message: 'Two-factor authentication is not enabled.' }); |
| 112 | + } |
| 113 | + const secret = rules.decryptSecret(user.twoFactor.secretEnc); |
| 114 | + let ok = !!secret && rules.verifyTotp(code, secret); |
| 115 | + if (!ok) ok = (await findRecoveryMatch(code, user.twoFactor.recoveryCodes)) !== -1; |
| 116 | + if (!ok) { |
| 117 | + return res.status(400).json({ status: false, message: 'That code is not valid. Enter a current code or a recovery code to disable 2FA.' }); |
| 118 | + } |
| 119 | + await setTwoFactor(uid, { enabled: false }); |
| 120 | + return res.status(200).json({ status: true, statusText: 'Two-factor authentication has been disabled.' }); |
| 121 | + } catch (error) { |
| 122 | + logger.error(`2FA disable error: ${error?.message || error}`); |
| 123 | + return res.status(400).json({ status: false, message: error?.message || 'Failed to disable 2FA' }); |
| 124 | + } |
| 125 | +}; |
| 126 | + |
| 127 | +// POST /api/v2/auth/2fa/validate (PUBLIC) { tempToken, code } — login second-step. |
| 128 | +// On failure: set req.errorMessageObject + next() so the route's manageAttempt |
| 129 | +// handler rate-limits the attempt by IP (same as password login). |
| 130 | +exports.twoFaValidate = async (req, res, next) => { |
| 131 | + try { |
| 132 | + const { tempToken, code } = req.body || {}; |
| 133 | + const payload = tempToken ? rules.verifyTempToken(tempToken) : null; |
| 134 | + if (!payload) { |
| 135 | + req.errorMessageObject = { message: 'Your verification session has expired. Please sign in again.' }; |
| 136 | + return next(); |
| 137 | + } |
| 138 | + const user = await findUserAuthById(payload.uid); |
| 139 | + if (!user?._id || !(user.twoFactor && user.twoFactor.enabled)) { |
| 140 | + req.errorMessageObject = { message: 'Two-factor authentication is not enabled for this account.' }; |
| 141 | + return next(); |
| 142 | + } |
| 143 | + const secret = rules.decryptSecret(user.twoFactor.secretEnc); |
| 144 | + let ok = !!secret && rules.verifyTotp(code, secret); |
| 145 | + if (!ok) { |
| 146 | + const idx = await findRecoveryMatch(code, user.twoFactor.recoveryCodes); |
| 147 | + if (idx !== -1) { |
| 148 | + ok = true; |
| 149 | + // Recovery codes are single-use — remove the one that matched. |
| 150 | + const remaining = user.twoFactor.recoveryCodes.slice(); |
| 151 | + remaining.splice(idx, 1); |
| 152 | + await setTwoFactor(payload.uid, { ...user.twoFactor, recoveryCodes: remaining }); |
| 153 | + } |
| 154 | + } |
| 155 | + if (!ok) { |
| 156 | + req.errorMessageObject = { message: 'That code is not valid. Try again or use a recovery code.' }; |
| 157 | + return next(); |
| 158 | + } |
| 159 | + // Identical session issuance to a normal password login. |
| 160 | + return loginSessionCtrl.finalizeSession(req, res, payload.uid, next); |
| 161 | + } catch (error) { |
| 162 | + logger.error(`2FA validate error: ${error?.message || error}`); |
| 163 | + req.errorMessageObject = { message: error?.message || 'Verification failed' }; |
| 164 | + return next(); |
| 165 | + } |
| 166 | +}; |
0 commit comments