Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ jest.mock('../../middlewares/rbac.js', () => ({
isolateOrganization: (_req: any, _res: any, next: any) => next(),
}));

// Pass-through the admin 2FA gate here so these tests stay focused on the
// controller. The gate itself is covered in require2faForAdmin.test.ts.
jest.mock('../../middlewares/require2faForAdmin.js', () => ({
__esModule: true,
require2FAForAdmin: (_req: any, _res: any, next: any) => next(),
default: (_req: any, _res: any, next: any) => next(),
}));

// ── Database mock ─────────────────────────────────────────────────────────────
const mockQuery = jest.fn();
jest.mock('../../config/database.js', () => ({
Expand Down
38 changes: 38 additions & 0 deletions backend/src/db/migrations/052_api_database_scaling_part23.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- =============================================================================
-- Migration 052: API & Database Scaling – Part 23
-- Purpose : Add composite / partial indexes that accelerate the highest-traffic
-- dashboard query paths:
-- * Payroll run lists filtered by organization + status, newest-first
-- (BulkPaymentStatusTracker, payroll dashboards).
-- * Fast lookup of failed payroll items for the retry flow.
-- These are read-path optimizations only — no schema or data changes.
-- Closes Issue #713 – API & Database Scaling Part 23 (ref #268).
-- =============================================================================

-- ---------------------------------------------------------------------------
-- 1. Payroll runs: org + status + recency
-- Backs the default "runs for my org, newest first, filtered by status"
-- query used by the bulk-payment status tracker and payroll dashboards.
-- ---------------------------------------------------------------------------

CREATE INDEX IF NOT EXISTS idx_payroll_runs_org_status_created
ON payroll_runs (organization_id, status, created_at DESC);

-- ---------------------------------------------------------------------------
-- 2. Payroll items: failed-item fast path
-- Partial index so the "retry failed payments" flow can locate failed items
-- for a run without scanning completed/pending rows.
-- ---------------------------------------------------------------------------

CREATE INDEX IF NOT EXISTS idx_payroll_items_failed
ON payroll_items (payroll_run_id)
WHERE status = 'failed';

-- ---------------------------------------------------------------------------
-- 3. Comments
-- ---------------------------------------------------------------------------

COMMENT ON INDEX idx_payroll_runs_org_status_created IS
'Covers org-scoped, status-filtered, newest-first payroll run listings.';
COMMENT ON INDEX idx_payroll_items_failed IS
'Partial index over failed payroll items, used by the payment retry flow.';
4 changes: 4 additions & 0 deletions backend/src/db/rollbacks/052_api_database_scaling_part23.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Rollback for Migration 052: API & Database Scaling – Part 23

DROP INDEX IF EXISTS idx_payroll_items_failed;
DROP INDEX IF EXISTS idx_payroll_runs_org_status_created;
142 changes: 142 additions & 0 deletions backend/src/middlewares/__tests__/require2faForAdmin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Request, Response, NextFunction } from 'express';

// ── Database mock ─────────────────────────────────────────────────────────────
const mockQuery = jest.fn();
jest.mock('../../config/database.js', () => ({
pool: { query: (...args: unknown[]) => mockQuery(...args) },
}));

// ── otplib mock ───────────────────────────────────────────────────────────────
const mockCheck = jest.fn();
jest.mock('@otplib/preset-default', () => ({
authenticator: { check: (...args: unknown[]) => mockCheck(...args) },
}));

import { require2FAForAdmin } from '../require2faForAdmin.js';

// ── Helpers ───────────────────────────────────────────────────────────────────
function mockRes() {
return {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as unknown as Response;
}

function mockReq(overrides: Partial<Request> = {}): Request {
return {
user: { id: 1, organizationId: 42, role: 'EMPLOYER', email: 'admin@acme.com' },
headers: {},
body: {},
...overrides,
} as unknown as Request;
}

describe('require2FAForAdmin', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns 401 when no authenticated user is present', async () => {
const req = mockReq({ user: undefined });
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

it('returns 403 when the admin has not enabled 2FA', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ is_2fa_enabled: false, totp_secret: null, recovery_codes: null }],
});
const req = mockReq({ headers: { 'x-2fa-token': '123456' } });
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});

it('returns 401 when 2FA is enabled but no token is supplied', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ is_2fa_enabled: true, totp_secret: 'SECRET', recovery_codes: [] }],
});
const req = mockReq();
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

it('calls next() for a valid TOTP token', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ is_2fa_enabled: true, totp_secret: 'SECRET', recovery_codes: [] }],
});
mockCheck.mockReturnValue(true);
const req = mockReq({ headers: { 'x-2fa-token': '123456' } });
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(mockCheck).toHaveBeenCalledWith('123456', 'SECRET');
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});

it('returns 401 for an invalid TOTP token', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ is_2fa_enabled: true, totp_secret: 'SECRET', recovery_codes: [] }],
});
mockCheck.mockReturnValue(false);
const req = mockReq({ body: { twoFactorToken: '000000' } });
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

it('accepts a recovery code and consumes it (single-use)', async () => {
mockQuery
.mockResolvedValueOnce({
rows: [{ is_2fa_enabled: true, totp_secret: 'SECRET', recovery_codes: ['ABC123', 'XYZ789'] }],
})
.mockResolvedValueOnce({ rows: [] }); // UPDATE consuming the code
const req = mockReq({ headers: { 'x-2fa-token': 'abc123' } });
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
// The consumed code is removed, leaving only the unused one.
expect(mockQuery).toHaveBeenLastCalledWith(
'UPDATE users SET recovery_codes = $1 WHERE id = $2',
[['XYZ789'], 1]
);
// TOTP check should not run when a recovery code matched.
expect(mockCheck).not.toHaveBeenCalled();
});

it('returns 500 when the database lookup fails', async () => {
mockQuery.mockRejectedValueOnce(new Error('db down'));
const req = mockReq({ headers: { 'x-2fa-token': '123456' } });
const res = mockRes();
const next = jest.fn() as NextFunction;

await require2FAForAdmin(req, res, next);

expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
});
101 changes: 101 additions & 0 deletions backend/src/middlewares/require2faForAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* require2FAForAdmin
*
* Enforces verified TOTP / recovery-code two-factor authentication on sensitive
* admin organization-management endpoints (e.g. renaming an organization or
* rotating its Stellar issuer account).
*
* Unlike `require2FA`, which is a *soft* gate that lets a request through when
* the user has not enabled 2FA, this middleware is a *hard* gate:
*
* 1. The request must carry an authenticated user (JWT) — run after
* `authenticateJWT`.
* 2. The user MUST have 2FA enabled. Admins managing an organization are
* required to protect their account, so a missing/disabled 2FA setup is
* rejected with `403 FORBIDDEN`.
* 3. A valid TOTP token (or single-use recovery code) MUST be supplied via the
* `x-2fa-token` header or the `twoFactorToken` body field.
*
* Recovery codes are single-use and consumed on a successful match.
*/

import { Request, Response, NextFunction } from 'express';
import { authenticator } from '@otplib/preset-default';
import { pool } from '../config/database.js';
import { apiErrorResponse, ErrorCodes } from '../utils/apiError.js';

interface UserTwoFactorRow {
is_2fa_enabled: boolean;
totp_secret: string | null;
recovery_codes: string[] | null;
}

export const require2FAForAdmin = async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.id;

if (!userId) {
return res
.status(401)
.json(apiErrorResponse(ErrorCodes.UNAUTHORIZED, 'Authentication required for admin actions'));
}

const token = ((req.headers['x-2fa-token'] as string) || req.body?.twoFactorToken || '').trim();

try {
const result = await pool.query<UserTwoFactorRow>(
'SELECT is_2fa_enabled, totp_secret, recovery_codes FROM users WHERE id = $1',
[userId]
);

const user = result.rows[0];

// Hard gate: 2FA must be set up before performing admin org operations.
if (!user || !user.is_2fa_enabled || !user.totp_secret) {
return res
.status(403)
.json(
apiErrorResponse(
ErrorCodes.FORBIDDEN,
'Two-factor authentication must be enabled to manage organization settings'
)
);
}

if (!token) {
return res
.status(401)
.json(
apiErrorResponse(
ErrorCodes.UNAUTHORIZED,
'A 2FA token or recovery code is required for this action'
)
);
}

// 1. Try single-use recovery codes (case-insensitive match).
const codes: string[] = Array.isArray(user.recovery_codes) ? user.recovery_codes : [];
const codeIdx = codes.findIndex((c) => String(c).toLowerCase() === token.toLowerCase());

if (codeIdx >= 0) {
const remaining = codes.filter((_, i) => i !== codeIdx);
await pool.query('UPDATE users SET recovery_codes = $1 WHERE id = $2', [remaining, userId]);
return next();
}

// 2. Fall back to a time-based one-time password.
if (authenticator.check(token, user.totp_secret)) {
return next();
}

return res
.status(401)
.json(apiErrorResponse(ErrorCodes.UNAUTHORIZED, 'Invalid 2FA token or recovery code'));
} catch (err) {
console.error('[require2FAForAdmin]', err);
return res
.status(500)
.json(apiErrorResponse(ErrorCodes.INTERNAL_ERROR, 'Failed to verify two-factor authentication'));
}
};

export default require2FAForAdmin;
35 changes: 33 additions & 2 deletions backend/src/routes/organizationRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'express';
import authenticateJWT from '../middlewares/auth.js';
import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js';
import { require2FAForAdmin } from '../middlewares/require2faForAdmin.js';
import { OrganizationController } from '../controllers/organizationController.js';

const router = Router();
Expand Down Expand Up @@ -30,8 +31,16 @@ router.get('/me', OrganizationController.getMe);
* patch:
* tags: [Organizations]
* summary: Update organization name
* description: Requires a valid 2FA token (TOTP or recovery code) supplied via the `x-2fa-token` header or `twoFactorToken` body field.
* security:
* - bearerAuth: []
* parameters:
* - in: header
* name: x-2fa-token
* required: true
* schema:
* type: string
* description: TOTP token or single-use recovery code for the authenticated admin.
* requestBody:
* required: true
* content:
Expand All @@ -41,20 +50,35 @@ router.get('/me', OrganizationController.getMe);
* properties:
* name:
* type: string
* twoFactorToken:
* type: string
* description: Alternative to the x-2fa-token header.
* responses:
* 200:
* description: Updated organization name
* 401:
* description: Missing or invalid 2FA token
* 403:
* description: 2FA is not enabled for the admin account
*/
router.patch('/me/name', OrganizationController.updateName);
router.patch('/me/name', require2FAForAdmin, OrganizationController.updateName);

/**
* @openapi
* /api/v1/organizations/me/issuer:
* patch:
* tags: [Organizations]
* summary: Update organization Stellar issuer account
* description: Requires a valid 2FA token (TOTP or recovery code) supplied via the `x-2fa-token` header or `twoFactorToken` body field.
* security:
* - bearerAuth: []
* parameters:
* - in: header
* name: x-2fa-token
* required: true
* schema:
* type: string
* description: TOTP token or single-use recovery code for the authenticated admin.
* requestBody:
* required: true
* content:
Expand All @@ -65,10 +89,17 @@ router.patch('/me/name', OrganizationController.updateName);
* issuerAccount:
* type: string
* description: Stellar public key (G...)
* twoFactorToken:
* type: string
* description: Alternative to the x-2fa-token header.
* responses:
* 200:
* description: Updated issuer account
* 401:
* description: Missing or invalid 2FA token
* 403:
* description: 2FA is not enabled for the admin account
*/
router.patch('/me/issuer', OrganizationController.updateIssuer);
router.patch('/me/issuer', require2FAForAdmin, OrganizationController.updateIssuer);

export default router;
Loading
Loading