mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-04 20:41:33 +00:00
bd6a31cb0d
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
307 lines
8.8 KiB
JavaScript
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'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't access your
|
|
authenticator. <strong>They won'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;
|