diff --git a/CHANGELOG.md b/CHANGELOG.md index 5549336..554ccf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address. +- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off. +- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity. +- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again. + +### Changed + +- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`). + ## [1.10.6] - 2026-10-08 ### Fixed diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 8233b49..b6c69a8 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -43,7 +43,9 @@ Tokens are issued by `POST /api/users/login`. | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/users` | public | Register new account | -| POST | `/api/users/login` | public | Login, returns JWT | +| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on | +| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT | +| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) | | POST | `/api/users/forgot` | public | Request password reset email | | POST | `/api/users/reset` | public | Reset password with token | | POST | `/api/users/activate` | public | Activate account from email link | @@ -51,9 +53,13 @@ Tokens are issued by `POST /api/users/login`. | PUT | `/api/users/profile` | user+ | Update own profile | | POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions | | POST | `/api/users/close-account` | user+ | Deactivate own account | +| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel | +| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code | +| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) | +| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session | | GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` | | GET | `/api/users/:id` | admin | Get user by ID | -| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) | +| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way | | DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) | | POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user | | POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account | @@ -71,10 +77,28 @@ Returns: `{ _id, name, email, role, token }` ### POST /api/users/login ```json -{ "email": "jane@example.com", "password": "securepass123" } +{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" } ``` Returns: `{ _id, name, email, role, token }` +If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead: +```json +{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" } +``` + +### POST /api/users/login/2fa +```json +{ "challengeId": "uuid", "code": "123456", "rememberDevice": true } +``` +Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked. + +### Two-factor setup (`/api/users/2fa/*`) +- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session. +- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on. +- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. + +At login, if a WhatsApp code can't be sent, the code falls back to the account's email address. + ### PUT /api/users/profile ```json { "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" } diff --git a/backend/prisma/migrations/20261008120000_add_two_factor/migration.sql b/backend/prisma/migrations/20261008120000_add_two_factor/migration.sql new file mode 100644 index 0000000..6d30349 --- /dev/null +++ b/backend/prisma/migrations/20261008120000_add_two_factor/migration.sql @@ -0,0 +1,62 @@ +-- CreateEnum +CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_enabled'; +ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "twoFactorMethod" "TwoFactorMethod"; + +-- CreateTable +CREATE TABLE "TwoFactorChallenge" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "method" "TwoFactorMethod" NOT NULL, + "codeHash" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TrustedDevice" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "device" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash"); + +-- CreateIndex +CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId"); + +-- AddForeignKey +ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fb97c84..e64bab4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,6 +51,13 @@ enum SecurityEventType { login password_changed password_reset + two_factor_enabled + two_factor_disabled +} + +enum TwoFactorMethod { + email + whatsapp } model User { @@ -67,6 +74,8 @@ model User { lockUntil DateTime? tokenVersion Int @default(0) notificationPreference NotificationPreference @default(email) + twoFactorEnabled Boolean @default(false) + twoFactorMethod TwoFactorMethod? registrations Registration[] payments Payment[] paymentsRecorded Payment[] @relation("PaymentRecordedBy") @@ -87,6 +96,9 @@ model User { securityEvents SecurityEvent[] adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor") + + twoFactorChallenges TwoFactorChallenge[] + trustedDevices TrustedDevice[] } model Event { @@ -330,6 +342,40 @@ model PasswordReset { @@index([userId]) } +// A 6-digit code sent by email/WhatsApp, either as the second step of a login +// ('login') or to prove the channel works before 2FA is switched on ('enable'). +// Only the HMAC of the code is stored (see backend/src/utils/twoFactor.js). +model TwoFactorChallenge { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + purpose String // 'login' | 'enable' + method TwoFactorMethod + codeHash String + attempts Int @default(0) + lastSentAt DateTime @default(now()) + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) +} + +// A browser that passed the 2FA code step with "Trust this device" ticked — logins +// presenting its token skip the code until it expires or is cleared. +model TrustedDevice { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique + device String? + createdAt DateTime @default(now()) + lastUsedAt DateTime @default(now()) + expiresAt DateTime + + @@index([userId]) +} + // Append-only "Account activity" log for the Profile & Security page (logins, password // changes). Nullable FK with SetNull (not Cascade) so entries survive account // close/anonymization, the same pattern EventCashup uses for its performedBy audit trail. diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index e42e2e4..1bb5c2f 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -4,6 +4,16 @@ const { v4: uuidv4 } = require('uuid'); const { safeErrorMessage } = require('../utils/errorUtils'); const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents'); const { getSettingSync } = require('../utils/settingsCache'); +const { + METHODS: TWO_FACTOR_METHODS, + availableMethods, + createChallenge, + verifyChallenge, + resendChallenge, + issueTrustedDevice, + isTrustedDevice, + clearTrustedDevices, +} = require('../utils/twoFactor'); const axios = require('axios'); // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -124,6 +134,35 @@ async function sendWelcomeEmail(user) { } } +// Fields the login flow needs once the password (and any 2FA code) has checked out. +const LOGIN_USER_SELECT = { + id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true, + phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, +}; + +// Errors from utils/twoFactor carry their own HTTP status; everything else follows +// the controller convention of res.status(...) before throwing. +function errorStatus(error, res) { + return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode); +} + +// Final step of a successful login (after the password, and the 2FA code when +// required): notify, record the activity, and issue the session token. +function completeLogin(user, req, res, extra = {}) { + // Send login notification in the background + sendLoginNotification(user, req).catch(() => {}); + logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); + + res.json({ + id: user.id, + name: user.name, + email: user.email, + role: user.role, + token: generateToken(user.id, user.role, user.tokenVersion), + ...extra + }); +} + // @desc Register a new user // @route POST /api/users // @access Public @@ -198,7 +237,7 @@ const registerUser = async (req, res) => { // @access Public const loginUser = async (req, res) => { try { - const {email, password} = req.body; + const {email, password, deviceToken} = req.body; const rawInput = email; @@ -278,20 +317,152 @@ const loginUser = async (req, res) => { const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, - select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true }, + select: LOGIN_USER_SELECT, }); - // Send login notification in the background - sendLoginNotification(updated, req).catch(() => {}); - logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); + // Two-factor: the password alone isn't enough unless this browser was + // remembered earlier. Send a code and hand back a challenge instead of a token. + if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) { + const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email'); + return res.json({ twoFactorRequired: true, ...challenge }); + } - res.json({ - id: updated.id, - name: updated.name, - email: updated.email, - role: updated.role, - token: generateToken(updated.id, updated.role, updated.tokenVersion) + completeLogin(updated, req, res); + } catch (error) { + res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Second login step — exchange an emailed/WhatsApp code for a token +// @route POST /api/users/login/2fa +// @access Public +const verifyLoginTwoFactor = async (req, res) => { + try { + const { challengeId, code, rememberDevice } = req.body || {}; + const challenge = await verifyChallenge(challengeId, code, 'login'); + + const user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT }); + if (!user || !user.isActive) { + res.status(401); + throw new Error('Your account has been deactivated'); + } + + const extra = {}; + if (rememberDevice === true) { + extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']); + } + completeLogin(user, req, res, extra); + } catch (error) { + res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Send a new code for a pending login challenge +// @route POST /api/users/login/2fa/resend +// @access Public +const resendLoginTwoFactor = async (req, res) => { + try { + const { challengeId } = req.body || {}; + res.json(await resendChallenge(challengeId, 'login')); + } catch (error) { + res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works +// @route POST /api/users/2fa/enable +// @access Private +const startEnableTwoFactor = async (req, res) => { + try { + const { method, password } = req.body || {}; + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) { res.status(404); throw new Error('User not found'); } + + if (typeof password !== 'string' || !(await comparePassword(password, user.password))) { + res.status(400); + throw new Error('Incorrect password'); + } + if (!TWO_FACTOR_METHODS.includes(method)) { + res.status(400); + throw new Error('Choose email or WhatsApp for your verification codes'); + } + if (!(await availableMethods(user)).includes(method)) { + res.status(400); + throw new Error(method === 'whatsapp' + ? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.' + : 'Add a valid email address to your profile before turning on two-factor authentication.'); + } + + // Unlike login, don't fall back to email here — the point of this step is to + // prove the channel the user picked actually reaches them. + res.json(await createChallenge(user, 'enable', method, { allowFallback: false })); + } catch (error) { + res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Finish turning on 2FA with the code that was sent +// @route POST /api/users/2fa/confirm +// @access Private +const confirmEnableTwoFactor = async (req, res) => { + try { + const { challengeId, code } = req.body || {}; + const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id); + + await prisma.user.update({ + where: { id: req.user.id }, + data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() }, }); + logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); + + res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method }); + } catch (error) { + res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Turn off 2FA +// @route POST /api/users/2fa/disable +// @access Private +const disableTwoFactor = async (req, res) => { + try { + const { password } = req.body || {}; + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) { res.status(404); throw new Error('User not found'); } + + if (typeof password !== 'string' || !(await comparePassword(password, user.password))) { + res.status(400); + throw new Error('Incorrect password'); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() }, + }); + await clearTrustedDevices(user.id); + if (user.twoFactorEnabled) { + logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); + } + + res.json({ twoFactorEnabled: false, twoFactorMethod: null }); + } catch (error) { + res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Re-confirm the logged-in user's password without starting a new session +// (the self-service kiosk uses this before letting the event be changed) +// @route POST /api/users/verify-password +// @access Private +const verifyPassword = async (req, res) => { + try { + const { password } = req.body || {}; + const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } }); + if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) { + res.status(401); + throw new Error('Incorrect password'); + } + res.json({ ok: true }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } @@ -311,6 +482,8 @@ const getUserProfile = async (req, res) => { role: true, phoneNumber: true, notificationPreference: true, + twoFactorEnabled: true, + twoFactorMethod: true, createdAt: true, updatedAt: true, isActive: true @@ -375,6 +548,11 @@ const updateUserProfile = async (req, res) => { const { resolveNotificationPreference } = require('../utils/notificationPreference'); const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference); + // 2FA codes can't go to WhatsApp without a usable number — move them to email + // rather than leave the account pointing at a channel that no longer exists. + const { isValidZAPhone } = require('../utils/whatsapp'); + const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod; + // Update user data const updatedUser = await prisma.user.update({ where: { id: req.user.id }, @@ -384,6 +562,7 @@ const updateUserProfile = async (req, res) => { password: newHashedPassword ? newHashedPassword : user.password, phoneNumber: newPhone, notificationPreference: newPref, + twoFactorMethod: newTwoFactorMethod, updatedAt: new Date() }, select: { @@ -393,6 +572,8 @@ const updateUserProfile = async (req, res) => { role: true, phoneNumber: true, notificationPreference: true, + twoFactorEnabled: true, + twoFactorMethod: true, createdAt: true, updatedAt: true, isActive: true, @@ -402,6 +583,7 @@ const updateUserProfile = async (req, res) => { // If the password was changed, send a security alert email (fire-and-forget) if (newHashedPassword) { + await clearTrustedDevices(updatedUser.id); const { sendMail, buildPasswordChangedEmail } = require('../utils/email'); const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || ''); const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail }); @@ -432,7 +614,7 @@ const getUsers = async (req, res) => { const select = { id: true, name: true, email: true, role: true, - phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true + phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true }; // Build filters @@ -559,7 +741,7 @@ const updateUser = async (req, res) => { throw new Error('User not found'); } - const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body; + const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body; const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber; const { resolveNotificationPreference } = require('../utils/notificationPreference'); @@ -580,6 +762,14 @@ const updateUser = async (req, res) => { data.password = await hashPassword(password.trim()); } + // Admins can switch 2FA off for someone who has lost access to their codes, + // but never on — enabling requires the user to prove the channel themselves. + const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled; + if (resetTwoFactor) { + data.twoFactorEnabled = false; + data.twoFactorMethod = null; + } + const updatedUser = await prisma.user.update({ where: { id: req.params.id }, data, @@ -590,12 +780,20 @@ const updateUser = async (req, res) => { role: true, phoneNumber: true, notificationPreference: true, + twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true } }); + if (resetTwoFactor || data.password) { + await clearTrustedDevices(updatedUser.id); + } + if (resetTwoFactor) { + logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); + } + res.json(updatedUser); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); @@ -646,10 +844,13 @@ const anonymizeUser = async (req, res) => { email: `deleted-${req.params.id}@deleted.local`, phoneNumber: null, isActive: false, + twoFactorEnabled: false, + twoFactorMethod: null, tokenVersion: { increment: 1 }, updatedAt: new Date() } }); + await clearTrustedDevices(req.params.id); res.json({ message: 'User data deleted' }); } catch (error) { @@ -754,6 +955,7 @@ const resetPassword = async (req, res) => { prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }), prisma.passwordReset.update({ where: { token }, data: { used: true } }) ]); + await clearTrustedDevices(user.id); logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); @@ -838,6 +1040,7 @@ const revokeMySession = async (req, res) => { data: { tokenVersion: { increment: 1 } }, select: { id: true, role: true, tokenVersion: true }, }); + await clearTrustedDevices(updated.id); // Bumping tokenVersion invalidates every existing token, including the one // this request just used. Issue a fresh token for the current device so it // isn't logged out too. @@ -864,6 +1067,7 @@ const adminRevokeUserSessions = async (req, res) => { where: { id: req.params.id }, data: { tokenVersion: { increment: 1 } }, }); + await clearTrustedDevices(req.params.id); res.json({ message: `Sessions revoked for ${target.name}.` }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); @@ -901,10 +1105,13 @@ const closeAccount = async (req, res) => { password: '', phoneNumber: null, isActive: false, + twoFactorEnabled: false, + twoFactorMethod: null, tokenVersion: { increment: 1 }, updatedAt: new Date(), }, }); + await clearTrustedDevices(req.user.id); // Send closure confirmation to the real address (before it was wiped) if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) { const { sendMail, buildAccountClosedEmail } = require('../utils/email'); @@ -926,6 +1133,7 @@ const closeAccount = async (req, res) => { updatedAt: new Date(), }, }); + await clearTrustedDevices(req.user.id); // Send closure confirmation if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) { const { sendMail, buildAccountClosedEmail } = require('../utils/email'); @@ -957,6 +1165,12 @@ const getMyActivity = async (req, res) => { module.exports = { registerUser, loginUser, + verifyLoginTwoFactor, + resendLoginTwoFactor, + startEnableTwoFactor, + confirmEnableTwoFactor, + disableTwoFactor, + verifyPassword, getUserProfile, updateUserProfile, getUsers, diff --git a/backend/src/index.js b/backend/src/index.js index 42b3d95..5949335 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -391,8 +391,23 @@ app.get('/docs', async (req, res) => { request:{ body:{ email:'jane@example.com', password:'secret123' }}, responses:[ { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }}, + { status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }}, { status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }}, ]}, + { method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT', + notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.', + request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }}, + responses:[ + { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }}, + { status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }}, + ]}, + { method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge', + notes:'At most one send per 60 seconds.', + request:{ body:{ challengeId:'c0ffee00-...' }}, + responses:[ + { status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }}, + { status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }}, + ]}, { method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email', request:{ body:{ email:'jane@example.com' }}, responses:[ @@ -424,6 +439,25 @@ app.get('/docs', async (req, res) => { responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]}, { method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account', responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]}, + { method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel', + notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.', + request:{ body:{ method:'email', password:'secret123' }}, + responses:[ + { status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }}, + { status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }}, + ]}, + { method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent', + request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }}, + responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]}, + { method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication', + request:{ body:{ password:'secret123' }}, + responses:[{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }}]}, + { method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session', + request:{ body:{ password:'secret123' }}, + responses:[ + { status:200, desc:'Correct', body:{ ok:true }}, + { status:401, desc:'Wrong password', body:{ message:'Incorrect password' }}, + ]}, { method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.', queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' }, responses:[ diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js index 1f8f574..bf250b6 100644 --- a/backend/src/routes/userRoutes.js +++ b/backend/src/routes/userRoutes.js @@ -3,6 +3,12 @@ const router = express.Router(); const { registerUser, loginUser, + verifyLoginTwoFactor, + resendLoginTwoFactor, + startEnableTwoFactor, + confirmEnableTwoFactor, + disableTwoFactor, + verifyPassword, getUserProfile, updateUserProfile, getUsers, @@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM // Public routes router.post('/', registerUser); router.post('/login', loginLimiter, loginUser); +router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor); +router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor); router.post('/forgot', requestPasswordReset); router.post('/reset', resetPassword); router.post('/activate', activateAccount); @@ -36,6 +44,10 @@ router.route('/profile') router.post('/revoke-sessions', protect, revokeMySession); router.post('/close-account', protect, closeAccount); router.get('/activity', protect, getMyActivity); +router.post('/verify-password', protect, loginLimiter, verifyPassword); +router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor); +router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor); +router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor); // Admin routes router.route('/') diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js index 606ccb4..fc0d5a1 100644 --- a/backend/src/utils/email.js +++ b/backend/src/utils/email.js @@ -234,6 +234,28 @@ function buildPasswordResetEmail({ name, resetUrl }) { return { text, html: emailWrapper(body, { preheader }) }; } +function buildTwoFactorCodeEmail({ name, code }) { + const org = getOrg(); + const preheader = `Your ${org.name} verification code. It expires in 10 minutes.`; + const body = ` +
Your verification code
+Two-factor authentication for your account
+ +Hi ${name || 'there'},
+Enter this code to continue. It will expire in 10 minutes.
+ +${code}
+ + ${divider()} ++ Never share this code with anyone. If you did not try to log in, someone may know your password — + change it and contact us at ${org.email}. +
`; + + const text = `Hi ${name || 'there'},\n\nYour ${org.name} verification code is: ${code}\n\nIt expires in 10 minutes. Never share this code with anyone.\n\nIf you did not try to log in, change your password and contact ${org.email}.\n\n${org.name} — ${org.email}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + function buildPasswordChangedEmail({ name, when, supportEmail }) { const org = getOrg(); const contact = supportEmail || org.email; @@ -403,6 +425,7 @@ module.exports = { callout, paymentOption, buildPasswordResetEmail, + buildTwoFactorCodeEmail, buildPasswordChangedEmail, buildLoginNotificationEmail, buildWelcomeEmail, diff --git a/backend/src/utils/twoFactor.js b/backend/src/utils/twoFactor.js new file mode 100644 index 0000000..6dcc4ed --- /dev/null +++ b/backend/src/utils/twoFactor.js @@ -0,0 +1,250 @@ +/** + * Two-factor authentication helpers: 6-digit codes delivered by email or + * WhatsApp, and "trust this device" tokens that let a browser skip the code. + * + * Neither the code nor the device token is stored in the clear — only an + * HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed. + */ + +const crypto = require('crypto'); +const prisma = require('../config/db'); +const { describeUserAgent } = require('./userAgent'); + +const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes +const MAX_ATTEMPTS = 5; // wrong guesses allowed per code +const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends +const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend +const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +const METHODS = ['email', 'whatsapp']; + +// Application error carrying the HTTP status the controller should respond with. +function fail(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +function generateCode() { + return String(crypto.randomInt(0, 1000000)).padStart(6, '0'); +} + +function hmac(value) { + return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex'); +} + +// Namespaced so a code hash can never collide with a device-token hash. +const hashCode = (code) => hmac(`2fa-code:${code}`); +const hashDeviceToken = (token) => hmac(`2fa-device:${token}`); + +function safeEqual(a, b) { + const ba = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + return ba.length === bb.length && crypto.timingSafeEqual(ba, bb); +} + +function hasRealEmail(user) { + return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local')); +} + +function maskEmail(email) { + const [local, domain] = String(email || '').split('@'); + if (!local || !domain) return '•••'; + return `${local[0]}•••@${domain}`; +} + +function maskPhone(phone) { + const digits = String(phone || '').replace(/\D/g, ''); + return `••• ••• ${digits.slice(-4)}`; +} + +/** Channels this user could receive a code on right now. */ +async function availableMethods(user) { + const { isValidZAPhone, isConfigured } = require('./whatsapp'); + const methods = []; + if (hasRealEmail(user)) methods.push('email'); + if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp'); + return methods; +} + +/** + * Sends the code and reports where it actually went. A WhatsApp failure (session + * disconnected, not configured) falls back to email so a user is never locked out + * by the WhatsApp gateway being down. Throws when no channel could deliver. + * Pass `allowFallback: false` when the point is to prove the chosen channel works. + */ +async function deliverCode(user, method, code, { allowFallback = true } = {}) { + const { getSettingSync } = require('./settingsCache'); + const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'); + + if (process.env.NODE_ENV !== 'production') { + console.info(`[2fa][dev] code for ${user.email}: ${code}`); + } + + if (method === 'whatsapp') { + const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp'); + const { buildWATwoFactorCode } = require('./waMessages'); + try { + // sendText silently no-ops when WhatsApp isn't set up, so check first. + if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable'); + await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code })); + return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) }; + } catch (e) { + console.warn('[2fa] WhatsApp code failed:', e?.message || e); + if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503); + if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503); + } + } + + if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503); + const { sendMail, buildTwoFactorCodeEmail } = require('./email'); + try { + await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) }); + } catch (e) { + console.warn('[2fa] Email code failed:', e?.message || e); + throw fail('We couldn\'t send your verification code. Please try again later.', 503); + } + return { method: 'email', destination: maskEmail(user.email) }; +} + +/** + * Sends a fresh code and records the challenge. Any earlier challenge of the same + * purpose for this user is discarded, so only the newest code works. + */ +async function createChallenge(user, purpose, method, options) { + const code = generateCode(); + const sent = await deliverCode(user, method, code, options); + + await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } }); + const challenge = await prisma.twoFactorChallenge.create({ + data: { + userId: user.id, + purpose, + method: sent.method, + codeHash: hashCode(code), + expiresAt: new Date(Date.now() + CODE_TTL_MS), + }, + }); + + return { challengeId: challenge.id, method: sent.method, destination: sent.destination }; +} + +/** + * Checks a submitted code. Returns the challenge (with its user) on success and + * throws otherwise. Pass `userId` to also require the challenge belong to that user. + */ +async function verifyChallenge(challengeId, code, purpose, userId) { + const invalid = () => fail('That code is invalid or has expired. Please request a new one.'); + if (!challengeId || typeof challengeId !== 'string') throw invalid(); + const submitted = String(code ?? '').replace(/\D/g, ''); + + // Count the attempt before comparing, atomically, so parallel guesses can't + // slip past the attempt limit. + const counted = await prisma.twoFactorChallenge.updateMany({ + where: { + id: challengeId, + purpose, + ...(userId ? { userId } : {}), + usedAt: null, + expiresAt: { gt: new Date() }, + attempts: { lt: MAX_ATTEMPTS }, + }, + data: { attempts: { increment: 1 } }, + }); + if (counted.count !== 1) throw invalid(); + + const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } }); + if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) { + const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0; + throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.'); + } + + // Single use — if two requests race with the right code, only one wins. + const used = await prisma.twoFactorChallenge.updateMany({ + where: { id: challengeId, usedAt: null }, + data: { usedAt: new Date() }, + }); + if (used.count !== 1) throw invalid(); + + return challenge; +} + +/** Replaces the code on an existing challenge and sends it again. */ +async function resendChallenge(challengeId, purpose, userId) { + const expired = () => fail('This verification has expired. Please start again.'); + if (!challengeId || typeof challengeId !== 'string') throw expired(); + + const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } }); + if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired(); + if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired(); + + const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime()); + if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429); + + // Claim the send slot first so two simultaneous resends can't both go out. + const claimed = await prisma.twoFactorChallenge.updateMany({ + where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null }, + data: { lastSentAt: new Date() }, + }); + if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429); + + const code = generateCode(); + const sent = await deliverCode(challenge.user, challenge.method, code); + await prisma.twoFactorChallenge.update({ + where: { id: challengeId }, + data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) }, + }); + + return { challengeId, method: sent.method, destination: sent.destination }; +} + +/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */ +async function issueTrustedDevice(userId, userAgent) { + const token = crypto.randomBytes(32).toString('hex'); + await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } }); + await prisma.trustedDevice.create({ + data: { + userId, + tokenHash: hashDeviceToken(token), + device: describeUserAgent(userAgent), + expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS), + }, + }); + return token; +} + +/** True when `token` is an unexpired trusted-device token belonging to this user. */ +async function isTrustedDevice(userId, token) { + if (!token || typeof token !== 'string') return false; + const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } }); + if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false; + prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {}); + return true; +} + +/** Forgets every trusted device — call whenever credentials or sessions are reset. */ +async function clearTrustedDevices(userId) { + try { + await prisma.trustedDevice.deleteMany({ where: { userId } }); + } catch (e) { + console.error('Failed to clear trusted devices:', e?.message); + } +} + +module.exports = { + METHODS, + MAX_ATTEMPTS, + generateCode, + hashCode, + hashDeviceToken, + maskEmail, + maskPhone, + hasRealEmail, + availableMethods, + createChallenge, + verifyChallenge, + resendChallenge, + issueTrustedDevice, + isTrustedDevice, + clearTrustedDevices, +}; diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js index ceb0d8f..cf466f0 100644 --- a/backend/src/utils/waMessages.js +++ b/backend/src/utils/waMessages.js @@ -149,6 +149,25 @@ function buildWALogin({ name, when, location, userAgent }) { ].join('\n'); } +// ─── Two-factor code ────────────────────────────────────────────────────────── + +function buildWATwoFactorCode({ name, code }) { + const org = getOrg(); + return [ + `🔑 *Your verification code*`, + '', + `Hi ${name || 'there'},`, + '', + `Your *${org.name}* verification code is:`, + '', + `*${code}*`, + '', + `_It expires in 10 minutes. Never share this code with anyone._`, + '', + `> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`, + ].join('\n'); +} + // ─── Welcome ────────────────────────────────────────────────────────────────── function buildWAWelcome({ name, events }) { @@ -306,6 +325,7 @@ module.exports = { buildWADonationAppliedToRegistrant, buildWADonationUnassignedFromRegistrant, buildWALogin, + buildWATwoFactorCode, buildWAWelcome, buildWAAccountClosed, buildWATicketCaption, diff --git a/backend/tests/twoFactor.test.js b/backend/tests/twoFactor.test.js new file mode 100644 index 0000000..bf3a4a2 --- /dev/null +++ b/backend/tests/twoFactor.test.js @@ -0,0 +1,74 @@ +process.env.JWT_SECRET = 'test-secret'; + +// The pure helpers under test never touch the database. +jest.mock('../src/config/db', () => ({})); + +const { + generateCode, + hashCode, + hashDeviceToken, + maskEmail, + maskPhone, + hasRealEmail, +} = require('../src/utils/twoFactor'); + +describe('generateCode', () => { + test('always returns exactly six digits', () => { + for (let i = 0; i < 500; i++) { + expect(generateCode()).toMatch(/^\d{6}$/); + } + }); +}); + +describe('hashCode / hashDeviceToken', () => { + test('is deterministic and never returns the input', () => { + expect(hashCode('123456')).toBe(hashCode('123456')); + expect(hashCode('123456')).not.toContain('123456'); + expect(hashCode('123456')).toMatch(/^[0-9a-f]{64}$/); + }); + + test('different codes hash differently', () => { + expect(hashCode('123456')).not.toBe(hashCode('123457')); + }); + + test('code and device-token hashes are namespaced apart', () => { + expect(hashCode('123456')).not.toBe(hashDeviceToken('123456')); + }); + + test('depends on JWT_SECRET', () => { + const before = hashCode('123456'); + process.env.JWT_SECRET = 'another-secret'; + expect(hashCode('123456')).not.toBe(before); + process.env.JWT_SECRET = 'test-secret'; + }); +}); + +describe('masking', () => { + test('maskEmail keeps the first letter and the domain', () => { + expect(maskEmail('jane@example.com')).toBe('j•••@example.com'); + }); + + test('maskEmail copes with junk input', () => { + expect(maskEmail('')).toBe('•••'); + expect(maskEmail(null)).toBe('•••'); + }); + + test('maskPhone keeps only the last four digits', () => { + expect(maskPhone('27821234567')).toBe('••• ••• 4567'); + expect(maskPhone('+27 82 123 4567')).toBe('••• ••• 4567'); + }); +}); + +describe('hasRealEmail', () => { + test('accepts a normal address', () => { + expect(hasRealEmail({ email: 'jane@example.com' })).toBe(true); + }); + + test('rejects guest and deleted placeholders and missing values', () => { + expect(hasRealEmail({ email: 'abc@guest.local' })).toBe(false); + expect(hasRealEmail({ email: 'deleted-1@deleted.invalid' })).toBe(false); + expect(hasRealEmail({ email: 'deleted-1@deleted.local' })).toBe(false); + expect(hasRealEmail({})).toBe(false); + expect(hasRealEmail(null)).toBe(false); + }); +}); diff --git a/backend/tests/twoFactorFlow.test.js b/backend/tests/twoFactorFlow.test.js new file mode 100644 index 0000000..b940fe0 --- /dev/null +++ b/backend/tests/twoFactorFlow.test.js @@ -0,0 +1,281 @@ +process.env.JWT_SECRET = 'test-secret'; +process.env.NODE_ENV = 'test'; + +// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ───────── +// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }. +function matches(row, where = {}) { + return Object.entries(where).every(([key, cond]) => { + const value = row[key]; + if (cond && typeof cond === 'object' && !(cond instanceof Date)) { + if ('gt' in cond) return value > cond.gt; + if ('lt' in cond) return value < cond.lt; + } + if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime(); + return value === cond; + }); +} + +function applyData(row, data) { + for (const [key, val] of Object.entries(data)) { + row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val; + } +} + +function makeModel(defaults, relations = {}) { + let rows = []; + let seq = 0; + const withRelations = (row, include) => { + if (!row) return null; + const out = { ...row }; + for (const key of Object.keys(include || {})) out[key] = relations[key](row); + return out; + }; + return { + _rows: () => rows, + _reset: () => { rows = []; }, + create: async ({ data }) => { + const row = { id: `id-${++seq}`, ...defaults(), ...data }; + rows.push(row); + return { ...row }; + }, + findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include), + update: async ({ where, data }) => { + const row = rows.find(r => matches(r, where)); + applyData(row, data); + return { ...row }; + }, + updateMany: async ({ where, data }) => { + const hit = rows.filter(r => matches(r, where)); + hit.forEach(r => applyData(r, data)); + return { count: hit.length }; + }, + deleteMany: async ({ where }) => { + const before = rows.length; + rows = rows.filter(r => !matches(r, where)); + return { count: before - rows.length }; + }, + }; +} + +const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' }; + +const mockDb = { + twoFactorChallenge: makeModel( + () => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }), + { user: () => USER }, + ), + trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })), +}; + +const mockSent = { emails: [], whatsapps: [] }; +const mockWhatsApp = { configured: true, fail: false }; + +jest.mock('../src/config/db', () => mockDb); +jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback })); +jest.mock('../src/utils/email', () => ({ + sendMail: async (mail) => { mockSent.emails.push(mail); }, + buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }), +})); +jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` })); +jest.mock('../src/utils/whatsapp', () => ({ + isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')), + isConfigured: async () => mockWhatsApp.configured, + sendText: async (to, message) => { + if (mockWhatsApp.fail) throw new Error('session disconnected'); + mockSent.whatsapps.push({ to, message }); + }, +})); + +const { + MAX_ATTEMPTS, + availableMethods, + createChallenge, + verifyChallenge, + resendChallenge, + issueTrustedDevice, + isTrustedDevice, + clearTrustedDevices, +} = require('../src/utils/twoFactor'); + +const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1]; +const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1]; +const wrongCode = (code) => (code === '000000' ? '111111' : '000000'); + +beforeEach(() => { + mockDb.twoFactorChallenge._reset(); + mockDb.trustedDevice._reset(); + mockSent.emails = []; + mockSent.whatsapps = []; + mockWhatsApp.configured = true; + mockWhatsApp.fail = false; + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'info').mockImplementation(() => {}); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('availableMethods', () => { + test('offers WhatsApp only with a valid phone and a configured gateway', async () => { + expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']); + mockWhatsApp.configured = false; + expect(await availableMethods(USER)).toEqual(['email']); + mockWhatsApp.configured = true; + expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']); + }); + + test('offers nothing by email for a guest placeholder address', async () => { + expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']); + }); +}); + +describe('createChallenge', () => { + test('emails the code and stores only its hash', async () => { + const res = await createChallenge(USER, 'login', 'email'); + expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' }); + const [row] = mockDb.twoFactorChallenge._rows(); + expect(row.id).toBe(res.challengeId); + expect(row.codeHash).not.toContain(lastEmailCode()); + expect(mockSent.emails[0].to).toBe(USER.email); + }); + + test('sends by WhatsApp when asked', async () => { + const res = await createChallenge(USER, 'login', 'whatsapp'); + expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' }); + expect(mockSent.whatsapps).toHaveLength(1); + expect(mockSent.emails).toHaveLength(0); + }); + + test('falls back to email when WhatsApp fails or is not configured', async () => { + mockWhatsApp.fail = true; + expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email'); + mockWhatsApp.fail = false; + mockWhatsApp.configured = false; + expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email'); + expect(mockSent.emails).toHaveLength(2); + }); + + test('does not fall back when allowFallback is false, and records no challenge', async () => { + mockWhatsApp.fail = true; + await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 }); + expect(mockSent.emails).toHaveLength(0); + expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0); + }); + + test('a new challenge replaces the previous one for the same purpose', async () => { + const first = await createChallenge(USER, 'login', 'email'); + const firstCode = lastEmailCode(); + await createChallenge(USER, 'login', 'email'); + await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/); + }); +}); + +describe('verifyChallenge', () => { + test('accepts the right code exactly once', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + const code = lastEmailCode(); + const challenge = await verifyChallenge(challengeId, code, 'login'); + expect(challenge.userId).toBe(USER.id); + await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/); + }); + + test('tolerates spaces in the submitted code', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + const code = lastEmailCode(); + await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy(); + }); + + test('locks the challenge after the maximum wrong attempts, even for the right code', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + const code = lastEmailCode(); + for (let i = 1; i <= MAX_ATTEMPTS; i++) { + await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow( + i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/ + ); + } + await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/); + }); + + test('rejects an expired code', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000); + await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/); + }); + + test('rejects a code used for the wrong purpose or by another user', async () => { + const { challengeId } = await createChallenge(USER, 'enable', 'email'); + const code = lastEmailCode(); + await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/); + await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/); + await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy(); + }); + + test('rejects missing or malformed input', async () => { + await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow(); + await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow(); + const { challengeId } = await createChallenge(USER, 'login', 'email'); + await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/); + }); +}); + +describe('resendChallenge', () => { + test('refuses inside the cooldown', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 }); + expect(mockSent.emails).toHaveLength(1); + }); + + test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + const oldCode = lastEmailCode(); + await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow(); + mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000); + + await resendChallenge(challengeId, 'login'); + const newCode = lastEmailCode(); + expect(mockSent.emails).toHaveLength(2); + expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0); + if (newCode !== oldCode) { + await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/); + } + await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy(); + }); + + test('refuses once the challenge is too old or already used', async () => { + const { challengeId } = await createChallenge(USER, 'login', 'email'); + const row = mockDb.twoFactorChallenge._rows()[0]; + row.lastSentAt = new Date(Date.now() - 61 * 1000); + row.createdAt = new Date(Date.now() - 31 * 60 * 1000); + await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/); + + row.createdAt = new Date(); + row.usedAt = new Date(); + await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/); + }); +}); + +describe('trusted devices', () => { + test('a freshly issued token is trusted for its own user only', async () => { + const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36'); + expect(token).toMatch(/^[0-9a-f]{64}$/); + expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token); + expect(await isTrustedDevice(USER.id, token)).toBe(true); + expect(await isTrustedDevice('someone-else', token)).toBe(false); + }); + + test('unknown, malformed and expired tokens are not trusted', async () => { + const token = await issueTrustedDevice(USER.id, ''); + expect(await isTrustedDevice(USER.id, 'nope')).toBe(false); + expect(await isTrustedDevice(USER.id, undefined)).toBe(false); + expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false); + mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000); + expect(await isTrustedDevice(USER.id, token)).toBe(false); + }); + + test('clearTrustedDevices forgets every device for the user', async () => { + const a = await issueTrustedDevice(USER.id, ''); + const b = await issueTrustedDevice(USER.id, ''); + await clearTrustedDevices(USER.id); + expect(await isTrustedDevice(USER.id, a)).toBe(false); + expect(await isTrustedDevice(USER.id, b)).toBe(false); + }); +}); diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx index 34556be..3efdb26 100644 --- a/frontend/src/app/dashboard/admin/users/page.tsx +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -15,6 +15,7 @@ interface UserItem { role: string; phoneNumber?: string | null; notificationPreference?: string; + twoFactorEnabled?: boolean; isActive: boolean; createdAt: string; updatedAt: string; @@ -186,6 +187,16 @@ export default function AdminUsersPage() { } }; + const resetTwoFactor = async (id: string, name: string) => { + if (!confirm(`Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`)) return; + try { + await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } }); + await loadUsers(page); + } catch (e: any) { + setError(e?.message || "Failed to reset two-factor authentication"); + } + }; + const deactivate = async (id: string) => { if (!confirm("Deactivate this user? Their account will be disabled.")) return; try { @@ -352,6 +363,9 @@ export default function AdminUsersPage() {+ On. When you log in we send a 6-digit code by{" "} + {TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you've chosen to trust skip the code for 30 days. +
+ {tfStep !== "disable" ? ( + + ) : ( + + )} + > + ) : tfStep === "code" && tfChallenge ? ( + + ) : ( + + )} + {tfMsg &&+ We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "} + +
+{setupError}
} )} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index c9e7c0f..66343a3 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -1,12 +1,16 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; +import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; +// Matches the backend's minimum gap between code sends. +const RESEND_COOLDOWN_SECONDS = 60; + export function LoginForm() { - const { login } = useAuth(); + const { login, verifyTwoFactor, resendTwoFactor } = useAuth(); const searchParams = useSearchParams(); const raw = searchParams.get("redirect") || ""; const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//"); @@ -19,6 +23,25 @@ export function LoginForm() { const [loading, setLoading] = useState(false); const [error, setError] = useState