Require two-factor authentication for admins and supervisors
Admins and supervisors without 2FA are sent a code at their next login, and verifying it turns 2FA on. They can change the channel but not turn it off. Older sessions without 2FA are rejected with TWO_FACTOR_REQUIRED, and promoting a user ends their sessions so they enrol at next login. The requirement is skipped when no email or WhatsApp channel can deliver a code, and TWO_FACTOR_REQUIRED_ROLES=false switches it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
### Added
|
||||
|
||||
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
|
||||
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
|
||||
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
|
||||
|
||||
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
|
||||
|
||||
### Changed
|
||||
|
||||
+8
-4
@@ -354,10 +354,14 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
created on someone's behalf).
|
||||
- JWT authentication (HS256 pinned) with **session revocation** (token
|
||||
versioning) — users can sign out everywhere, and admins can do it for any user.
|
||||
- **Two-factor authentication** — optional for every user, from Profile & Security: a
|
||||
6-digit login code by email or WhatsApp (10-minute expiry, 5 attempts, resend),
|
||||
with "Trust this device for 30 days". Admins can reset it for a locked-out
|
||||
user, and the kiosk asks for the code at setup.
|
||||
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
|
||||
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
|
||||
**Required for Admins and Supervisors**: their next login sends a code that turns
|
||||
it on, and they can change the channel but not turn it off (skipped only when no
|
||||
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
|
||||
is the emergency switch). Optional for everyone else from Profile & Security.
|
||||
Admins can reset it for a locked-out user, and the kiosk asks for the code at
|
||||
setup.
|
||||
- **Login lockout** after repeated failed attempts, plus login rate limiting
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
|
||||
@@ -4,6 +4,11 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
|
||||
# Two-factor authentication is required for admins and supervisors whenever
|
||||
# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
|
||||
# switch, e.g. if the mail server is down and admins are locked out.
|
||||
# TWO_FACTOR_REQUIRED_ROLES=true
|
||||
|
||||
# ─── Server ───────────────────────────────────────────────────────────────────
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
@@ -86,6 +86,8 @@ If the account has two-factor authentication on and `deviceToken` is missing, ex
|
||||
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
|
||||
```
|
||||
|
||||
**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
|
||||
|
||||
### POST /api/users/login/2fa
|
||||
```json
|
||||
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
|
||||
@@ -95,7 +97,9 @@ Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberD
|
||||
### 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.
|
||||
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
|
||||
|
||||
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
|
||||
|
||||
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const generateToken = (userId, role, tokenVersion = 0) => {
|
||||
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
|
||||
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
|
||||
return jwt.sign(
|
||||
{ id: userId, role, tokenVersion },
|
||||
{ ...extra, id: userId, role, tokenVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
@@ -172,7 +172,9 @@ const setupRegister = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
|
||||
// Marked as a setup token so the 2FA requirement doesn't block the rest of the
|
||||
// wizard; the admin enrols at their first normal login.
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
|
||||
res.json({ token, name: user.name, email: user.email });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -13,6 +13,8 @@ const {
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
@@ -327,6 +329,17 @@ const loginUser = async (req, res) => {
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
|
||||
// first channel that can deliver one, and verifying it turns 2FA on. A
|
||||
// trusted device can't skip this — nothing has been verified yet.
|
||||
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
|
||||
const method = await enforcementMethod(updated);
|
||||
if (method) {
|
||||
const challenge = await createChallenge(updated, 'login', method);
|
||||
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
@@ -341,12 +354,23 @@ const verifyLoginTwoFactor = async (req, res) => {
|
||||
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 });
|
||||
let 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');
|
||||
}
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the
|
||||
// channel works, so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
@@ -430,6 +454,11 @@ const disableTwoFactor = async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (requiresTwoFactor(user)) {
|
||||
res.status(403);
|
||||
throw new Error("Two-factor authentication is required for your role and can't be turned off.");
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
@@ -491,7 +520,7 @@ const getUserProfile = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -596,6 +625,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -770,6 +800,13 @@ const updateUser = async (req, res) => {
|
||||
data.twoFactorMethod = null;
|
||||
}
|
||||
|
||||
// Promoted to a role that requires 2FA without having it: end their sessions
|
||||
// so the next login enrols them.
|
||||
const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
|
||||
if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
|
||||
@@ -392,6 +392,7 @@ app.get('/docs', async (req, res) => {
|
||||
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:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling: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',
|
||||
@@ -451,7 +452,11 @@ app.get('/docs', async (req, res) => {
|
||||
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 }}]},
|
||||
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
|
||||
responses:[
|
||||
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
|
||||
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[
|
||||
|
||||
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
|
||||
|
||||
// The setup wizard's token may finish setup without 2FA for this long.
|
||||
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
|
||||
|
||||
// Protect routes - verify token
|
||||
const protect = async (req, res, next) => {
|
||||
@@ -29,7 +33,8 @@ const protect = async (req, res, next) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
tokenVersion: true,
|
||||
twoFactorEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,6 +56,17 @@ const protect = async (req, res, next) => {
|
||||
return next(new Error('Session has been revoked. Please log in again.'));
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. A session that predates the
|
||||
// requirement (or the user's promotion) is sent back to log in, where they
|
||||
// enrol. The setup wizard's own token gets a short grace period.
|
||||
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
|
||||
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
|
||||
return res.status(401).json({
|
||||
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
|
||||
code: 'TWO_FACTOR_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -49,6 +49,11 @@ function _getTransporter() {
|
||||
return { transporter: _cachedTransporter, cfg };
|
||||
}
|
||||
|
||||
/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
|
||||
function isSmtpConfigured() {
|
||||
return !!_smtpConfig().host;
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text, attachments }) {
|
||||
// Never send to anonymised/deleted accounts
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
@@ -417,6 +422,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
|
||||
@@ -18,6 +18,9 @@ const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const METHODS = ['email', 'whatsapp'];
|
||||
|
||||
// Roles that must use two-factor authentication.
|
||||
const REQUIRED_ROLES = ['admin', 'supervisor'];
|
||||
|
||||
// Application error carrying the HTTP status the controller should respond with.
|
||||
function fail(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
@@ -67,6 +70,34 @@ async function availableMethods(user) {
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this user's role must use two-factor authentication.
|
||||
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
|
||||
*/
|
||||
function requiresTwoFactor(user) {
|
||||
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
|
||||
return REQUIRED_ROLES.includes(user?.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a required-2FA user should be enrolled on, or null when no channel
|
||||
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
|
||||
* requirement is skipped rather than locking the account out.
|
||||
*/
|
||||
async function enforcementMethod(user) {
|
||||
const { isSmtpConfigured } = require('./email');
|
||||
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this user must enrol in 2FA before using their account. */
|
||||
async function mustEnrolTwoFactor(user) {
|
||||
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
|
||||
return (await enforcementMethod(user)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -233,6 +264,10 @@ async function clearTrustedDevices(userId) {
|
||||
|
||||
module.exports = {
|
||||
METHODS,
|
||||
REQUIRED_ROLES,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
mustEnrolTwoFactor,
|
||||
MAX_ATTEMPTS,
|
||||
generateCode,
|
||||
hashCode,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Two-factor is required for admins and supervisors — but only when some channel
|
||||
// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
|
||||
|
||||
const mockChannels = { smtp: true, whatsapp: true };
|
||||
const mockUsers = new Map();
|
||||
|
||||
jest.mock('../src/config/db', () => ({
|
||||
user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
|
||||
}));
|
||||
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
||||
jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({
|
||||
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
||||
isConfigured: async () => mockChannels.whatsapp,
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
|
||||
const { protect } = require('../src/middleware/authMiddleware');
|
||||
|
||||
const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mockChannels.smtp = true;
|
||||
mockChannels.whatsapp = true;
|
||||
mockUsers.clear();
|
||||
delete process.env.TWO_FACTOR_REQUIRED_ROLES;
|
||||
});
|
||||
|
||||
describe('requiresTwoFactor', () => {
|
||||
test('applies to admins and supervisors only', () => {
|
||||
expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
|
||||
expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
|
||||
expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
|
||||
expect(requiresTwoFactor({ role: 'user' })).toBe(false);
|
||||
});
|
||||
|
||||
test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
|
||||
process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
|
||||
expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforcementMethod', () => {
|
||||
test('prefers email when SMTP is configured', async () => {
|
||||
expect(await enforcementMethod(ADMIN)).toBe('email');
|
||||
});
|
||||
|
||||
test('falls back to WhatsApp without SMTP', async () => {
|
||||
mockChannels.smtp = false;
|
||||
expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
|
||||
});
|
||||
|
||||
test('skips a guest placeholder email', async () => {
|
||||
expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
|
||||
});
|
||||
|
||||
test('is null when no channel can deliver a code', async () => {
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
expect(await enforcementMethod(ADMIN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mustEnrolTwoFactor', () => {
|
||||
test('true for an admin without 2FA', async () => {
|
||||
expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
|
||||
});
|
||||
|
||||
test('false once enrolled, for other roles, or with no channel', async () => {
|
||||
expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
|
||||
expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protect', () => {
|
||||
function run(user, claims = {}) {
|
||||
mockUsers.set(user.id, user);
|
||||
const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
const res = { statusCode: 200, body: null };
|
||||
res.status = (c) => { res.statusCode = c; return res; };
|
||||
res.json = (b) => { res.body = b; return res; };
|
||||
return new Promise((resolve) => {
|
||||
protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
|
||||
if (res.body) resolve({ req, res, passed: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
|
||||
const { res, passed } = await run(ADMIN);
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
|
||||
});
|
||||
|
||||
test('lets an enrolled admin through', async () => {
|
||||
const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('lets a regular user through without 2FA', async () => {
|
||||
const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('gives the setup wizard token a grace period', async () => {
|
||||
const { passed } = await run(ADMIN, { setup: true });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('does not enforce when no channel can deliver a code', async () => {
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
const { passed } = await run(ADMIN);
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ 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); },
|
||||
isSmtpConfigured: () => true,
|
||||
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
|
||||
}));
|
||||
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
|
||||
|
||||
@@ -187,8 +187,13 @@ 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;
|
||||
const resetTwoFactor = async (id: string, name: string, role: string) => {
|
||||
// Admins and supervisors can't go without 2FA: a reset just makes them set it up
|
||||
// again (on a channel that works) at their next login.
|
||||
const message = role === "admin" || role === "supervisor"
|
||||
? `Reset two-factor authentication for ${name}? Their sessions end, and they'll set it up again with a new code at their next login.`
|
||||
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
|
||||
if (!confirm(message)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
@@ -364,7 +369,7 @@ export default function AdminUsersPage() {
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
|
||||
{u.twoFactorEnabled && (
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name)} title="Turn off two-factor authentication for a user who can no longer receive their codes">Reset 2FA</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
|
||||
)}
|
||||
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function UserProfilePage() {
|
||||
// loaded once per page load, so it wouldn't reflect a change made here.
|
||||
const [tfEnabled, setTfEnabled] = useState(false);
|
||||
const [tfMethod, setTfMethod] = useState<TwoFactorMethod>("email");
|
||||
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable">("idle");
|
||||
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable" | "change">("idle");
|
||||
const [tfPassword, setTfPassword] = useState("");
|
||||
const [tfCode, setTfCode] = useState("");
|
||||
const [tfChallenge, setTfChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
@@ -171,6 +171,8 @@ export default function UserProfilePage() {
|
||||
|
||||
// WhatsApp codes need the number that's actually saved, not one still being typed.
|
||||
const canWhatsAppCodes = isValidZAPhone(user?.phoneNumber || "");
|
||||
// Admins and supervisors must keep 2FA on; they can only change the channel.
|
||||
const tfRequired = !!user?.twoFactorRequired;
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -219,10 +221,11 @@ export default function UserProfilePage() {
|
||||
authToken: token,
|
||||
body: { challengeId: tfChallenge.challengeId, code: tfCode },
|
||||
});
|
||||
const wasEnabled = tfEnabled;
|
||||
setTfEnabled(true);
|
||||
setTfMethod(res.twoFactorMethod);
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is on." });
|
||||
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
|
||||
@@ -404,40 +407,10 @@ export default function UserProfilePage() {
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldCheck} title="Two-factor authentication" />
|
||||
|
||||
{tfEnabled ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<span className="font-medium text-green-700">On.</span> 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.
|
||||
</p>
|
||||
{tfStep !== "disable" ? (
|
||||
<button
|
||||
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Turn off…
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={disableTwoFactor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
|
||||
{tfBusy ? "Turning off…" : "Turn off two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : tfStep === "code" && tfChallenge ? (
|
||||
{tfStep === "code" && tfChallenge ? (
|
||||
<form onSubmit={confirmEnableTwoFactor} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. Enter it to finish turning on two-factor authentication.
|
||||
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
@@ -461,10 +434,52 @@ export default function UserProfilePage() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : tfEnabled && tfStep !== "change" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<span className="font-medium text-green-700">On.</span> 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.
|
||||
</p>
|
||||
{tfRequired ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-3">Required for your role, so it can't be turned off.</p>
|
||||
<button
|
||||
onClick={() => { setTfStep("change"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Change how I get codes…
|
||||
</button>
|
||||
</>
|
||||
) : tfStep !== "disable" ? (
|
||||
<button
|
||||
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Turn off…
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={disableTwoFactor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
|
||||
{tfBusy ? "Turning off…" : "Turn off two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={startEnableTwoFactor} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Add a second step to logging in: after your password, we send a 6-digit code that you need to enter.
|
||||
{tfEnabled
|
||||
? "Choose where your login codes go. We'll send a code there first to check it reaches you."
|
||||
: "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
|
||||
@@ -480,9 +495,16 @@ export default function UserProfilePage() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Sending code…" : "Send verification code"}
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
{tfEnabled && (
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Sending code…" : "Send verification code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{tfMsg && <Alert msg={tfMsg} />}
|
||||
|
||||
@@ -735,6 +735,7 @@ export default function SelfServicePage() {
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{setupChallenge.enrolling && "Two-factor authentication is required for your role — this code turns it on. "}
|
||||
We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -103,7 +103,9 @@ export function LoginForm() {
|
||||
if (!challenge || resendIn > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
setChallenge(await resendTwoFactor(challenge.challengeId));
|
||||
// The resend response doesn't repeat `enrolling`, so carry it over.
|
||||
const next = await resendTwoFactor(challenge.challengeId);
|
||||
setChallenge({ ...next, enrolling: challenge.enrolling });
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
} catch (err: any) {
|
||||
@@ -123,6 +125,11 @@ export function LoginForm() {
|
||||
<form onSubmit={onSubmitCode} className="space-y-4 max-w-sm w-full">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Enter your verification code</h2>
|
||||
{challenge.enrolling && (
|
||||
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2 mt-2">
|
||||
Two-factor authentication is required for your role. Entering this code turns it on for your account.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
We sent a 6-digit code to {challenge.destination} by {challenge.method === "whatsapp" ? "WhatsApp" : "email"}. It expires in 10 minutes.
|
||||
</p>
|
||||
@@ -182,6 +189,11 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
|
||||
{searchParams.get("reason") === "2fa" && (
|
||||
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2">
|
||||
Two-factor authentication is now required for your role. Log in again to set it up — we'll send you a code.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email or phone</label>
|
||||
<input
|
||||
|
||||
@@ -30,6 +30,9 @@ export const authHelpContent: HelpContent = {
|
||||
<GuideItem icon={ShieldCheck} title="Asked for a verification code?" tone="amber">
|
||||
If you've turned on two-factor authentication, we send a 6-digit code by email or WhatsApp after your password. Enter it within 10 minutes, or use "Resend code" if it doesn't arrive. Tick "Trust this device" to skip the code on that device for 30 days. You can turn two-factor on or off from Profile & Security.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldCheck} title="Admin or supervisor?" tone="amber">
|
||||
Two-factor authentication is required for admin and supervisor accounts. If it isn't on yet, your next login sends you a code, and entering it turns two-factor on. You can change whether codes come by email or WhatsApp from Profile & Security, but you can't turn it off.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -117,6 +117,15 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
if (json?.message && typeof json.message === "string") message = json.message;
|
||||
data = json;
|
||||
} catch { /* not JSON — use raw text */ }
|
||||
// An admin/supervisor session from before 2FA was required for their role:
|
||||
// end it and send them to log in, where they enrol.
|
||||
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
|
||||
localStorage.removeItem("hope_events_token");
|
||||
if (!window.location.pathname.startsWith("/login")) {
|
||||
const back = window.location.pathname + window.location.search;
|
||||
window.location.href = `/login?reason=2fa&redirect=${encodeURIComponent(back)}`;
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.status, message, data);
|
||||
}
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
|
||||
@@ -7,6 +7,8 @@ export type AuthUser = {
|
||||
notificationPreference?: "email" | "whatsapp" | "both";
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorMethod?: TwoFactorMethod | null;
|
||||
// True for roles that must use 2FA (admin, supervisor) — it can't be turned off.
|
||||
twoFactorRequired?: boolean;
|
||||
};
|
||||
|
||||
export type AuthResponse = AuthUser & {
|
||||
@@ -24,6 +26,8 @@ export type TwoFactorChallenge = {
|
||||
challengeId: string;
|
||||
method: TwoFactorMethod;
|
||||
destination: string;
|
||||
// The role requires 2FA and it wasn't on yet: verifying this code turns it on.
|
||||
enrolling?: boolean;
|
||||
};
|
||||
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
|
||||
|
||||
@@ -58,7 +58,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
|
||||
- **Your branding** — your logo, colours, and name on the website, emails, invoices, receipts, and reports.
|
||||
- **Privacy** — Terms of Use and Privacy Policy pages filled in with your details, and members can close their account or have their personal data removed.
|
||||
- **Security** — optional two-step login codes by email or WhatsApp, protection against password-guessing, a record of each account's recent logins, and the ability to sign out on all devices.
|
||||
- **Security** — two-step login codes by email or WhatsApp (required for admins and supervisors, optional for members), protection against password-guessing, a record of each account's recent logins, and the ability to sign out on all devices.
|
||||
- **Help on every page** — a "Need help?" button explains each page in plain language.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user