Files
haproxy-openmanager/frontend/src/components/MFAEnrollModal.js
T
taylanbakircioglu bd6a31cb0d feat: v1.6.0 — Multi-Factor Authentication (Issue #18)
Adds opt-in TOTP-based Multi-Factor Authentication that is fully
backwards compatible with existing logins. Operators choose to enable
MFA per account; nothing changes for users who do not opt in.

Highlights
==========

* RFC 6238 TOTP (6 digits, 30s period, SHA1) with ±30s skew tolerance,
  compatible with Microsoft / Google Authenticator, Authy, Duo, 1Password.
* Per-step replay protection (`mfa_last_used_totp_step`) so a captured
  code cannot be reused inside the same window.
* Fernet-encrypted TOTP secrets at rest, key resolution via
  `MFA_ENCRYPTION_KEY` env (HKDF-derived from `SECRET_KEY` as fallback).
* 10 single-use, bcrypt-hashed backup codes per user, formatted
  `XXXX-YYYY` from a confusion-free alphabet (no 0/O/1/I/L).
* Two-step login flow: `POST /api/auth/login` returns `mfa_required`
  + `mfa_token`, then `POST /api/auth/login/mfa-verify` accepts a TOTP
  code OR a backup code. JWT is minted only after MFA succeeds.
* Self-service: users enable / disable MFA from their own row in the
  Users page; admins reset (single user or bulk) but never enable on
  behalf of someone else (matches AWS IAM / GitHub / Google Workspace).
* Bulk emergency reset CLI: `scripts/admin-mfa-reset-all.sh`.

Security hardening
==================

* Atomic transactions with `SELECT … FOR UPDATE` on `mfa_pending_logins`
  and `users` rows so concurrent verify / enroll calls cannot race.
* `/api/mfa/enroll/start` refuses re-enrollment when MFA is already on
  (prevents silent secret rotation via a stolen JWT).
* Pydantic `ValidationError` messages are sanitized before reaching the
  audit log so request bodies (TOTP / backup codes in flight) never
  appear in plaintext.
* Slowapi rate limits are per-USER, not per-IP, with a trusted-proxy
  XFF strategy so a single ingress address cannot exhaust the bucket
  for thousands of operators (`MFA_TRUSTED_PROXY_CIDRS`,
  `MFA_RATE_LIMIT_*` env-overridable).
* Login query now scopes to `is_active = TRUE` so a soft-deleted row
  with the same username can no longer occlude the active user
  (also closes a small account-enumeration side channel).

Database
========

Additive migrations (idempotent `ADD COLUMN IF NOT EXISTS`,
`CREATE TABLE IF NOT EXISTS`):

  - users: mfa_enabled, mfa_method, mfa_secret_encrypted,
    mfa_enrolled_at, mfa_last_used_at, mfa_last_used_totp_step
  - mfa_backup_codes (user_id ON DELETE CASCADE)
  - mfa_pending_logins (user_id ON DELETE CASCADE, challenge_token,
    attempts, expires_at)
  - mfa_pending_enrollments (user_id ON DELETE CASCADE)

Frontend
========

* Login page becomes a 3-phase state machine
  (credentials → MFA → submitting); legacy single-step login is
  preserved for users who haven't enrolled.
* New MFAEnrollModal (3-step wizard: QR + secret → verify → backup
  codes) using `qrcode.react`.
* Users page shows MFA column + per-row enable/disable/reset actions.
  Admins viewing other users with MFA off see a non-actionable info
  icon explaining that only the user themselves can enable MFA.

Deployment
==========

* `MFA_ENCRYPTION_KEY` is added to `k8s/manifests/03-secrets.yaml` as
  a placeholder; `SECRET_KEY` is also placeholder-ized so both are
  injected by the existing pipeline pattern (sed-replace + apply).
* No new build-time env vars are required for the frontend. The SPA
  uses `window.location.host` for `/api/*` and is routed by the
  existing nginx ingress configuration.
* `frontend/.dockerignore` ensures host `.env*` files cannot bleed
  into the production bundle.

Tests
=====

* New unit suites:
  - `test_mfa_service.py` (TOTP, encryption, backup codes)
  - `test_mfa_backwards_compat.py` (regression — non-MFA flow unchanged)
  - `test_mfa_rate_limits.py` (env override + dataclass immutability)
  - `test_mfa_rate_limit_key.py` (JWT key, trusted-proxy XFF, fallbacks)
* All existing 1000+ unit tests continue to pass.

Documentation
=============

* README MFA section (overview, day-to-day operations, emergency
  reset CLI, env variables, rate-limit tuning).
* `scripts/README.md` documents the bulk reset script.

Issue: #18
2026-05-19 04:35:16 +03:00

307 lines
8.8 KiB
JavaScript

import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
Steps,
Form,
Input,
Button,
Alert,
Typography,
Space,
Checkbox,
message,
} from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import axios from 'axios';
import { extractApiError } from '../utils/apiError';
const { Text, Paragraph } = Typography;
const STEP_SETUP = 0;
const STEP_VERIFY = 1;
const STEP_BACKUP = 2;
/**
* MFA enrollment wizard. Strictly modal-controlled: the modal cannot be
* dismissed via the X / mask in step 2/3 — backup codes are shown only once
* and the server-side pending row is opaque after enrollment confirms.
*/
const MFAEnrollModal = ({ open, onClose, onEnrolled }) => {
const [verifyForm] = Form.useForm();
const [step, setStep] = useState(STEP_SETUP);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [otpauthUri, setOtpauthUri] = useState('');
const [secret, setSecret] = useState('');
const [backupCodes, setBackupCodes] = useState([]);
const [savedAcknowledged, setSavedAcknowledged] = useState(false);
const startedRef = useRef(false);
useEffect(() => {
if (!open) return undefined;
if (startedRef.current) return undefined;
startedRef.current = true;
startEnrollment();
return () => {
// No-op: cleanup happens via the explicit handleClose path.
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const resetState = () => {
setStep(STEP_SETUP);
setLoading(false);
setError('');
setOtpauthUri('');
setSecret('');
setBackupCodes([]);
setSavedAcknowledged(false);
startedRef.current = false;
verifyForm.resetFields();
};
const startEnrollment = async () => {
setLoading(true);
setError('');
try {
const response = await axios.post('/api/mfa/enroll/start', {});
setOtpauthUri(response.data.otpauth_uri);
setSecret(response.data.secret);
} catch (err) {
setError(extractApiError(err, 'Could not start MFA enrollment.'));
} finally {
setLoading(false);
}
};
const handleVerify = async (values) => {
setLoading(true);
setError('');
try {
const response = await axios.post('/api/mfa/enroll/confirm', {
code: (values.code || '').trim(),
});
setBackupCodes(response.data.backup_codes || []);
setStep(STEP_BACKUP);
} catch (err) {
setError(extractApiError(err, 'Verification failed.'));
verifyForm.setFieldsValue({ code: '' });
} finally {
setLoading(false);
}
};
const handleCopyAll = () => {
const text = backupCodes.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => message.success('Backup codes copied to clipboard'),
() => message.error('Could not copy. Please copy manually.'),
);
} else {
message.warning('Clipboard API unavailable. Please copy manually.');
}
};
const handleDownload = () => {
const blob = new Blob(
[
'HAProxy OpenManager — MFA backup codes\n',
'Generated: ' + new Date().toISOString() + '\n',
'Each code is single-use. Store them somewhere safe and offline.\n\n',
...backupCodes.map((c) => c + '\n'),
],
{ type: 'text/plain;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'haproxy-openmanager-mfa-backup-codes.txt';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleClose = (force = false) => {
if (step === STEP_BACKUP && !savedAcknowledged && !force) return;
resetState();
if (step === STEP_BACKUP) {
if (typeof onEnrolled === 'function') onEnrolled();
} else if (typeof onClose === 'function') {
onClose();
}
};
const renderSetup = () => (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Paragraph>
Open your authenticator app (Google Authenticator, Authy, 1Password, Microsoft
Authenticator) and scan this QR code, or enter the secret manually.
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{otpauthUri ? (
<QRCodeSVG value={otpauthUri} size={220} level="M" includeMargin />
) : (
<Text type="secondary">Generating…</Text>
)}
</div>
{secret && (
<Alert
message="Trouble scanning?"
description={
<Space direction="vertical" size={4}>
<Text>Enter this secret manually in your authenticator app:</Text>
<Text code copyable={{ text: secret }} style={{ fontSize: 16 }}>
{secret}
</Text>
</Space>
}
type="info"
showIcon
/>
)}
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => handleClose(true)}>Cancel</Button>
<Button
type="primary"
onClick={() => setStep(STEP_VERIFY)}
disabled={!otpauthUri}
>
I&apos;ve added the account
</Button>
</Space>
</div>
</Space>
);
const renderVerify = () => (
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>
<Alert
message="Verify your authenticator"
description="Enter the 6-digit code displayed by your authenticator app. You have 5 attempts before the enrollment is invalidated and you'll need to start over."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item
name="code"
label="Authenticator code"
rules={[
{ required: true, message: 'Please enter the 6-digit code.' },
{ len: 6, message: 'Code must be exactly 6 digits.' },
]}
>
<Input
placeholder="123456"
autoComplete="one-time-code"
inputMode="numeric"
maxLength={6}
autoFocus
/>
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => setStep(STEP_SETUP)} disabled={loading}>
Back
</Button>
<Button onClick={() => handleClose(true)} disabled={loading}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
Verify
</Button>
</Space>
</div>
</Form>
);
const renderBackup = () => (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
message="Save your backup codes now"
description={
<>
Each code can be used <strong>once</strong> when you can&apos;t access your
authenticator. <strong>They won&apos;t be shown again.</strong> If you lose
them, ask an administrator to reset your MFA.
</>
}
type="warning"
showIcon
/>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 16px',
padding: '12px',
backgroundColor: 'var(--ant-color-fill-quaternary, #fafafa)',
borderRadius: 6,
}}
>
{backupCodes.map((code) => (
<Text key={code} code style={{ fontSize: 15, letterSpacing: 1 }}>
{code}
</Text>
))}
</div>
<Space>
<Button onClick={handleCopyAll}>Copy all</Button>
<Button onClick={handleDownload}>Download .txt</Button>
</Space>
<Checkbox
checked={savedAcknowledged}
onChange={(e) => setSavedAcknowledged(e.target.checked)}
>
I have saved my backup codes somewhere safe.
</Checkbox>
<div style={{ textAlign: 'right' }}>
<Button
type="primary"
disabled={!savedAcknowledged}
onClick={() => handleClose(false)}
>
Close
</Button>
</div>
</Space>
);
return (
<Modal
open={open}
title="Enable Multi-Factor Authentication"
width={520}
footer={null}
closable={false}
maskClosable={false}
destroyOnClose
keyboard={false}
>
<Steps
size="small"
current={step}
items={[{ title: 'Set up' }, { title: 'Verify' }, { title: 'Backup codes' }]}
style={{ marginBottom: 24 }}
/>
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => setError('')}
style={{ marginBottom: 16 }}
/>
)}
{step === STEP_SETUP && renderSetup()}
{step === STEP_VERIFY && renderVerify()}
{step === STEP_BACKUP && renderBackup()}
</Modal>
);
};
export default MFAEnrollModal;