Files
haproxy-openmanager/frontend/src/components/Login.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

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;