mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-04 12:31:31 +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
322 lines
9.3 KiB
JavaScript
322 lines
9.3 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Card,
|
|
Form,
|
|
Input,
|
|
Button,
|
|
message,
|
|
Typography,
|
|
Row,
|
|
Col,
|
|
Alert,
|
|
} from 'antd';
|
|
import {
|
|
UserOutlined,
|
|
LockOutlined,
|
|
ClusterOutlined,
|
|
SafetyCertificateOutlined,
|
|
} from '@ant-design/icons';
|
|
import axios from 'axios';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { extractApiError } from '../utils/apiError';
|
|
import './Login.css';
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
const PHASE_CREDENTIALS = 'credentials';
|
|
const PHASE_MFA = 'mfa';
|
|
const PHASE_SUBMITTING = 'submitting';
|
|
|
|
const Login = () => {
|
|
const [credentialsForm] = Form.useForm();
|
|
const [mfaForm] = Form.useForm();
|
|
const [phase, setPhase] = useState(PHASE_CREDENTIALS);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const { login } = useAuth();
|
|
|
|
// MFA-specific state — RAM only, never persisted.
|
|
const mfaTokenRef = useRef(null);
|
|
const [mfaExpiresAt, setMfaExpiresAt] = useState(null);
|
|
const [mfaCountdown, setMfaCountdown] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (phase !== PHASE_MFA || !mfaExpiresAt) return undefined;
|
|
const id = setInterval(() => {
|
|
const remaining = Math.max(0, Math.floor((mfaExpiresAt - Date.now()) / 1000));
|
|
setMfaCountdown(remaining);
|
|
if (remaining <= 0) {
|
|
clearInterval(id);
|
|
resetToCredentials('MFA session expired. Please log in again.');
|
|
}
|
|
}, 1000);
|
|
return () => clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [phase, mfaExpiresAt]);
|
|
|
|
const resetToCredentials = (errMessage) => {
|
|
mfaTokenRef.current = null;
|
|
setMfaExpiresAt(null);
|
|
setMfaCountdown(0);
|
|
mfaForm.resetFields();
|
|
setPhase(PHASE_CREDENTIALS);
|
|
if (errMessage) setError(errMessage);
|
|
};
|
|
|
|
const completeAuth = (authData) => {
|
|
// Write storage + axios header ONLY after a full, MFA-cleared response.
|
|
localStorage.setItem('token', authData.access_token);
|
|
localStorage.setItem('authToken', authData.access_token);
|
|
localStorage.setItem('userData', JSON.stringify(authData.user));
|
|
localStorage.setItem('userRoles', JSON.stringify([]));
|
|
localStorage.setItem('userPermissions', JSON.stringify({}));
|
|
const expiryDate = new Date();
|
|
expiryDate.setSeconds(expiryDate.getSeconds() + authData.expires_in);
|
|
localStorage.setItem('tokenExpiry', expiryDate.toISOString());
|
|
const loginSuccess = login(authData);
|
|
if (loginSuccess) {
|
|
message.success(`Welcome back, ${authData.user.username}!`);
|
|
} else {
|
|
throw new Error('Failed to update authentication state');
|
|
}
|
|
};
|
|
|
|
const handleCredentialsSubmit = async (values) => {
|
|
setLoading(true);
|
|
setPhase(PHASE_SUBMITTING);
|
|
setError('');
|
|
try {
|
|
const response = await axios.post('/api/auth/login', {
|
|
username: values.username,
|
|
password: values.password,
|
|
});
|
|
|
|
if (response.data && response.data.mfa_required) {
|
|
// Phase 2 — TOTP / backup code challenge. Keep credentials secret-free.
|
|
mfaTokenRef.current = response.data.mfa_token;
|
|
const ttlSeconds = response.data.expires_in || 300;
|
|
setMfaExpiresAt(Date.now() + ttlSeconds * 1000);
|
|
setMfaCountdown(ttlSeconds);
|
|
setPhase(PHASE_MFA);
|
|
return;
|
|
}
|
|
|
|
completeAuth(response.data);
|
|
} catch (err) {
|
|
const errorMessage = extractApiError(err, 'Login failed. Please try again.');
|
|
setError(errorMessage);
|
|
message.error(errorMessage);
|
|
setPhase(PHASE_CREDENTIALS);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleMfaSubmit = async (values) => {
|
|
if (!mfaTokenRef.current) {
|
|
resetToCredentials('MFA session lost. Please log in again.');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const response = await axios.post(
|
|
'/api/auth/login/mfa-verify',
|
|
{
|
|
mfa_token: mfaTokenRef.current,
|
|
code: (values.code || '').trim(),
|
|
},
|
|
// Explicit opt-out: never attach a stale Authorization header here.
|
|
{ headers: { Authorization: undefined } },
|
|
);
|
|
completeAuth(response.data);
|
|
} catch (err) {
|
|
const status = err && err.response && err.response.status;
|
|
const errorMessage = extractApiError(err, 'Verification failed.');
|
|
if (status === 410) {
|
|
resetToCredentials(errorMessage || 'MFA challenge invalidated. Please log in again.');
|
|
} else {
|
|
setError(errorMessage);
|
|
mfaForm.setFieldsValue({ code: '' });
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const renderCredentialsForm = () => (
|
|
<Form
|
|
form={credentialsForm}
|
|
name="login"
|
|
onFinish={handleCredentialsSubmit}
|
|
layout="vertical"
|
|
autoComplete="off"
|
|
>
|
|
<Form.Item
|
|
name="username"
|
|
rules={[
|
|
{ required: true, message: 'Please enter your username!' },
|
|
{ min: 3, message: 'Username must be at least 3 characters!' },
|
|
]}
|
|
>
|
|
<Input prefix={<UserOutlined />} placeholder="Username" autoComplete="username" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="password"
|
|
rules={[
|
|
{ required: true, message: 'Please enter your password!' },
|
|
{ min: 6, message: 'Password must be at least 6 characters!' },
|
|
]}
|
|
>
|
|
<Input.Password
|
|
prefix={<LockOutlined />}
|
|
placeholder="Password"
|
|
autoComplete="current-password"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item style={{ marginBottom: 0 }}>
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={loading}
|
|
block
|
|
className="login-button"
|
|
>
|
|
{loading ? 'Signing in...' : 'Sign In'}
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
);
|
|
|
|
const renderMfaForm = () => {
|
|
const minutes = Math.floor(mfaCountdown / 60);
|
|
const seconds = String(mfaCountdown % 60).padStart(2, '0');
|
|
return (
|
|
<Form form={mfaForm} name="mfa" onFinish={handleMfaSubmit} layout="vertical" autoComplete="off">
|
|
<Alert
|
|
message="Multi-Factor Authentication"
|
|
description={
|
|
<span>
|
|
Enter the 6-digit code from your authenticator app, or use a backup code
|
|
(format: <code>XXXX-YYYY</code>).
|
|
{mfaCountdown > 0 && (
|
|
<>
|
|
{' '}Session expires in <strong>{minutes}:{seconds}</strong>.
|
|
</>
|
|
)}
|
|
</span>
|
|
}
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<Form.Item
|
|
name="code"
|
|
rules={[
|
|
{ required: true, message: 'Please enter your MFA code.' },
|
|
{ min: 6, message: 'Code must be at least 6 characters.' },
|
|
{ max: 10, message: 'Code is too long.' },
|
|
]}
|
|
>
|
|
<Input
|
|
prefix={<SafetyCertificateOutlined />}
|
|
placeholder="123456 or XXXX-YYYY"
|
|
autoComplete="one-time-code"
|
|
inputMode="text"
|
|
maxLength={10}
|
|
autoFocus
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item style={{ marginBottom: 8 }}>
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={loading}
|
|
block
|
|
className="login-button"
|
|
>
|
|
{loading ? 'Verifying...' : 'Verify'}
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item style={{ marginBottom: 0 }}>
|
|
<Button
|
|
type="default"
|
|
block
|
|
onClick={() => resetToCredentials('')}
|
|
disabled={loading}
|
|
>
|
|
Use a different account
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="login-container">
|
|
<Row
|
|
justify="center"
|
|
align="middle"
|
|
style={{
|
|
minHeight: '100vh',
|
|
minHeight: '100dvh',
|
|
width: '100%',
|
|
margin: 0,
|
|
}}
|
|
>
|
|
<Col
|
|
xs={24}
|
|
sm={20}
|
|
md={16}
|
|
lg={12}
|
|
xl={10}
|
|
xxl={8}
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
padding: '0 8px',
|
|
}}
|
|
>
|
|
<Card className="login-card">
|
|
<div className="login-header">
|
|
<ClusterOutlined className="login-icon" />
|
|
<Title level={2} className="login-title">
|
|
HAProxy OpenManager
|
|
</Title>
|
|
<Text type="secondary" className="login-subtitle">
|
|
Multi-Cluster Load Balancer Management
|
|
</Text>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert
|
|
message={error}
|
|
type="error"
|
|
showIcon
|
|
style={{ marginBottom: 24 }}
|
|
closable
|
|
onClose={() => setError('')}
|
|
/>
|
|
)}
|
|
|
|
{phase === PHASE_MFA ? renderMfaForm() : renderCredentialsForm()}
|
|
|
|
<div className="login-footer">
|
|
<Text
|
|
type="secondary"
|
|
style={{ fontSize: 12, display: 'block', textAlign: 'center' }}
|
|
>
|
|
Centralized management for multiple HAProxy clusters
|
|
</Text>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Login;
|