From 3bd9a245593570449e0e573676a6cdfbc6759692 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Mon, 8 Jun 2026 19:02:12 +0200 Subject: [PATCH] sync BE --- .cursor/rules/dev-help-request-reminder.mdc | 49 +++++++ docs/20260608_ALPHA_PRIORITIES.md | 2 +- .../backend/config/config.example.php | 9 +- .../backend/public/api/rbac.php | 12 ++ .../backend/public/assets/css/app.css | 1 + .../backend/public/assets/i18n/en.json | 28 ++++ .../backend/public/assets/i18n/ru.json | 28 ++++ .../backend/public/assets/js/rbac_admin.js | 41 +++++- .../crash_reporter/backend/public/index.php | 137 +++++++++++++++++- .../backend/scripts/verify_auth_totp.php | 14 ++ examples/crash_reporter/backend/src/Auth.php | 60 +++++++- .../backend/src/AuthAttempts.php | 108 ++++++++++++++ .../crash_reporter/backend/src/AuthCrypto.php | 68 +++++++++ .../backend/src/AuthFactors.php | 56 +++++++ .../backend/src/AuthRegistration.php | 9 +- .../crash_reporter/backend/src/AuthTotp.php | 93 ++++++++++++ .../backend/src/RbacAdminRepository.php | 1 + .../crash_reporter/backend/src/bootstrap.php | 4 + .../backend/views/account_security.php | 69 +++++++++ .../crash_reporter/backend/views/layout.php | 9 ++ .../crash_reporter/backend/views/login.php | 2 +- .../crash_reporter/backend/views/register.php | 51 +++++++ .../backend/views/two_factor_challenge.php | 33 +++++ .../backend/views/verify_email.php | 32 ++++ 24 files changed, 907 insertions(+), 9 deletions(-) create mode 100644 .cursor/rules/dev-help-request-reminder.mdc create mode 100644 examples/crash_reporter/backend/scripts/verify_auth_totp.php create mode 100644 examples/crash_reporter/backend/src/AuthAttempts.php create mode 100644 examples/crash_reporter/backend/src/AuthCrypto.php create mode 100644 examples/crash_reporter/backend/src/AuthFactors.php create mode 100644 examples/crash_reporter/backend/src/AuthTotp.php create mode 100644 examples/crash_reporter/backend/views/account_security.php create mode 100644 examples/crash_reporter/backend/views/register.php create mode 100644 examples/crash_reporter/backend/views/two_factor_challenge.php create mode 100644 examples/crash_reporter/backend/views/verify_email.php diff --git a/.cursor/rules/dev-help-request-reminder.mdc b/.cursor/rules/dev-help-request-reminder.mdc new file mode 100644 index 0000000..736c01c --- /dev/null +++ b/.cursor/rules/dev-help-request-reminder.mdc @@ -0,0 +1,49 @@ +--- +description: Remind developer about help_request.pdf when agent work depends on human input +alwaysApply: true +--- + +# Developer help-request reminder + +When **any** of your work, replies, or session wrap-ups depend on the **developer / project owner** (not agent-only), you **must** remind them about the hands-on checklist. + +## Trigger — remind when + +- Tasks are **blocked** or **slowed** waiting on dev (DNS, MX/SPF, SMTP, device QA, OTA URLs, WireGuard bastion ops, inbound mail test, etc.) +- You end a session where dev-owned items from [docs/20260608_ALPHA_PRIORITIES.md](docs/20260608_ALPHA_PRIORITIES.md) remain open +- You mention “blocked on you”, “developer when back”, or similar +- Auth/mail path needs live email (tasks **2.1–2.4**, **E1–E5** in 2FA doc) +- LAN alpha QA (**6.x**) or backend validation needing a physical device (**5.2**) + +## What to cite + +Primary checklist (markdown + PDF): + +- **`tmp/20260608_help_request.md`** +- **`tmp/20260608_help_request.pdf`** + +If a **new** dev session needs a different checklist, create `tmp/YYYYMMDD_help_request.md` + PDF per [docs/20260608_ALPHA_PRIORITIES.md](docs/20260608_ALPHA_PRIORITIES.md) § bottom line — then cite that file instead. + +## Format (end of reply — separate block, impossible to miss) + +Use when triggers apply (can combine with BE deploy banner if both apply): + +``` +─────────────────────────────────────────────────────────────── +📋 YOUR ACTION — help_request.pdf +─────────────────────────────────────────────────────────────── + +• Open: tmp/20260608_help_request.pdf (or latest tmp/*_help_request.pdf) +• Unblocks: … (1–3 bullets — what agent work is waiting on you) +• Reply when done: … (what to paste back — MX, SPF, inbound test, etc.) + +─────────────────────────────────────────────────────────────── +``` + +Keep bullets short. Do **not** bury this in prose. + +## Agent habit + +At the **start** of a turn, if open work still has dev dependencies from the task graph, briefly note that the help checklist exists — do not wait until session end only. + +Do **not** show the block when there are zero dev dependencies in that reply. diff --git a/docs/20260608_ALPHA_PRIORITIES.md b/docs/20260608_ALPHA_PRIORITIES.md index f8853c7..9040eee 100644 --- a/docs/20260608_ALPHA_PRIORITIES.md +++ b/docs/20260608_ALPHA_PRIORITIES.md @@ -147,7 +147,7 @@ Depends on BE deploy (soft). Not alpha blocker. | ID | Task | Owner | Notes | |----|------|-------|-------| -| **2.1** | DNS: MX + SPF + DKIM + DMARC for `apps.f0xx.org` | **Developer** | Web UI only; examples in `tmp/20260608_help_request.md` | +| **2.1** | DNS: MX + SPF for mail — **use apex `f0xx.org`** if `apps.f0xx.org` is CNAME (cannot add MX) | **Developer** | `tmp/20260608_help_request.md` | | **2.2** | Register forward addresses (`info@`, `admin@`, `root@` → Gmail) | **Developer** | Cloudflare Email Routing or ImprovMX | | **2.3** | Inbound forward verification | **Developer** | Send test mail | | **2.4** | Gmail filters / reply-as (optional) | **Developer** | | diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php index 468be0d..6a8c2ea 100644 --- a/examples/crash_reporter/backend/config/config.example.php +++ b/examples/crash_reporter/backend/config/config.example.php @@ -69,9 +69,16 @@ return [ ], ], // Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md + 'auth' => [ + // 32+ char secret for encrypting TOTP seeds + hashing auth attempts + 'encryption_key' => '', + 'max_attempts' => 8, + 'lockout_window_minutes' => 15, + ], 'mail' => [ 'transport' => 'smtp', // smtp | sendmail - 'from' => 'Android Cast Issues ', + // Alpha: use apex if apps.f0xx.org has CNAME (no MX on subdomain) — see tmp/20260608_help_request.md + 'from' => 'Android Cast Issues ', 'reply_to' => 'info@apps.f0xx.org', 'smtp' => [ 'host' => '127.0.0.1', diff --git a/examples/crash_reporter/backend/public/api/rbac.php b/examples/crash_reporter/backend/public/api/rbac.php index b1283cc..b2ce844 100644 --- a/examples/crash_reporter/backend/public/api/rbac.php +++ b/examples/crash_reporter/backend/public/api/rbac.php @@ -55,4 +55,16 @@ if ($action === 'apply_privilege_set') { json_out($result, empty($result['ok']) ? 400 : 200); } +if ($action === 'clear_auth_lockouts') { + if (!Rbac::isRoot() && !Rbac::isGlobalAdmin()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $userId = (int) ($body['user_id'] ?? 0); + if ($userId <= 0) { + json_out(['ok' => false, 'error' => 'invalid_user'], 400); + } + $deleted = AuthAttempts::clearForUser($userId); + json_out(['ok' => true, 'cleared' => $deleted]); +} + json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 8124419..29e0ff5 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -1141,6 +1141,7 @@ button.report-tag--filter:hover { [data-theme="light"] .star--on { background: #d97706; } .muted { color: var(--muted); } .alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; } +.alert--ok { background: rgba(52,211,153,.15); color: var(--ok, #34d399); } .btn-primary { background: var(--accent); diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index 4b34de2..93a5315 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -116,6 +116,34 @@ "login.register": "Register", "login.register_soon": "(coming soon)", "login.error": "Invalid credentials", + "register.title": "Create account", + "register.hint": "We will email a verification link before you can sign in.", + "register.email": "Email", + "register.username": "Username (optional)", + "register.password": "Password", + "register.submit": "Register", + "register.back_login": "Back to sign in", + "verify.title": "Email verification", + "verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.", + "verify.fail": "Invalid or expired link.", + "verify.sign_in": "Sign in", + "verify.register_again": "Register again", + "twofa.title": "Two-factor authentication", + "twofa.hint": "Enter the 6-digit code from your authenticator app.", + "twofa.code": "Authentication code", + "twofa.submit": "Continue", + "twofa.cancel": "Cancel", + "twofa.error": "Invalid code", + "security.title": "Account security", + "security.totp_intro": "Protect your account with a 6-digit authenticator app (recommended for alpha).", + "security.start_totp": "Set up authenticator", + "security.scan_qr": "Scan this QR code with your authenticator app, then enter a code to confirm.", + "security.manual_secret": "Can't scan?", + "security.confirm_totp": "Confirm enrollment", + "security.totp_enabled": "Authenticator app is enrolled.", + "security.remove_totp": "Remove authenticator", + "security.back_console": "Back to console", + "nav.security": "Security", "footer.copyright": "© Anton Afanaasyeu, {year}", "tickets.title": "Tickets", "tickets.empty": "No tickets yet.", diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index b1e5b29..f05f108 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -118,6 +118,34 @@ "login.register": "Регистрация", "login.register_soon": "(скоро)", "login.error": "Неверные учётные данные", + "register.title": "Создать аккаунт", + "register.hint": "Мы отправим ссылку для подтверждения email перед входом.", + "register.email": "Email", + "register.username": "Имя пользователя (необязательно)", + "register.password": "Пароль", + "register.submit": "Зарегистрироваться", + "register.back_login": "Назад ко входу", + "verify.title": "Подтверждение email", + "verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.", + "verify.fail": "Недействительная или просроченная ссылка.", + "verify.sign_in": "Войти", + "verify.register_again": "Зарегистрироваться снова", + "twofa.title": "Двухфакторная аутентификация", + "twofa.hint": "Введите 6-значный код из приложения-аутентификатора.", + "twofa.code": "Код аутентификации", + "twofa.submit": "Продолжить", + "twofa.cancel": "Отмена", + "twofa.error": "Неверный код", + "security.title": "Безопасность аккаунта", + "security.totp_intro": "Защитите аккаунт приложением-аутентификатором (рекомендуется для альфы).", + "security.start_totp": "Настроить аутентификатор", + "security.scan_qr": "Отсканируйте QR-код и введите код для подтверждения.", + "security.manual_secret": "Не удаётся сканировать?", + "security.confirm_totp": "Подтвердить настройку", + "security.totp_enabled": "Приложение-аутентификатор подключено.", + "security.remove_totp": "Отключить аутентификатор", + "security.back_console": "Назад в консоль", + "nav.security": "Безопасность", "footer.copyright": "© Anton Afanaasyeu, {year}", "nav.tickets": "Тикеты", "ticket.lifecycle": "Жизненный цикл", diff --git a/examples/crash_reporter/backend/public/assets/js/rbac_admin.js b/examples/crash_reporter/backend/public/assets/js/rbac_admin.js index d31ec2c..d85653a 100644 --- a/examples/crash_reporter/backend/public/assets/js/rbac_admin.js +++ b/examples/crash_reporter/backend/public/assets/js/rbac_admin.js @@ -37,6 +37,7 @@ const companyRoles = data.company_roles || []; const globalRoles = data.global_roles || []; const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []); + const seenAuth = new Set(); (data.users || []).forEach((u) => { (u.memberships || []).forEach((m) => { @@ -67,26 +68,47 @@ (m.privilege_set === 'custom' ? '' : '') + ''; + let authCell = ''; + if (!seenAuth.has(u.id)) { + seenAuth.add(u.id); + const fails = Number(u.auth_failures || 0); + if (canEditGlobal() && fails > 0) { + authCell = + '' + fails + ' fail ' + + ''; + } else if (fails > 0) { + authCell = '' + fails + ' recent fail'; + } else { + authCell = ''; + } + } + tr.innerHTML = '' + esc(u.username) + '' + '' + globalSelect + '' + '' + esc(m.slug) + ' ' + esc(m.name) + '' + '' + roleSelect + '' + - '' + setSelect + ''; + '' + setSelect + '' + + '' + authCell + ''; tbody.appendChild(tr); }); if (!(u.memberships || []).length && canEditGlobal()) { const tr = document.createElement('tr'); + const fails = Number(u.auth_failures || 0); + let authCell = fails > 0 + ? '' + : ''; tr.innerHTML = '' + esc(u.username) + '' + '' + esc(u.global_role) + '' + - 'No company membership'; + 'No company membership' + + '' + authCell + ''; tbody.appendChild(tr); } }); if (!tbody.children.length) { - tbody.innerHTML = 'No users in scope.'; + tbody.innerHTML = 'No users in scope.'; } const hint = document.getElementById('rbac-scope-hint'); @@ -108,6 +130,19 @@ } function bindChanges() { + document.addEventListener('click', async (ev) => { + const btn = ev.target && ev.target.closest ? ev.target.closest('.rbac-clear-auth') : null; + if (!btn || !canEditGlobal()) return; + const userId = Number(btn.getAttribute('data-user-id')); + if (!userId) return; + try { + await postAction('clear_auth_lockouts', { user_id: userId }); + setStatus('Auth lockouts cleared.'); + load(); + } catch (e) { + setStatus(String(e.message || e), true); + } + }); document.addEventListener('change', async (ev) => { const t = ev.target; if (!t || !t.classList) return; diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index be0a128..016f62d 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -120,6 +120,11 @@ if ($route === '/api/tag_catalog.php' || str_ends_with($route, '/api/tag_catalog exit; } +if ($route === '/api/auth_register.php' || str_ends_with($route, '/api/auth_register.php')) { + require __DIR__ . '/api/auth_register.php'; + exit; +} + if ($route === '/logout') { Auth::logout(); header('Location: ' . $base . '/login'); @@ -129,11 +134,24 @@ if ($route === '/logout') { if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') { $user = trim($_POST['username'] ?? ''); $pass = $_POST['password'] ?? ''; - if (Auth::login($user, $pass)) { + $result = Auth::login($user, $pass); + if ($result === true) { header('Location: ' . $base . '/'); exit; } + if ($result === 'pending_2fa') { + header('Location: ' . $base . '/two-factor'); + exit; + } $loginError = 'Invalid credentials'; + $st = Database::pdo()->prepare('SELECT status FROM users WHERE username = ? LIMIT 1'); + $st->execute([$user]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (is_array($row) && ($row['status'] ?? '') === 'pending') { + $loginError = 'Verify your email before signing in.'; + } elseif (AuthAttempts::isRateLimited($user)) { + $loginError = 'Too many attempts — try again later or ask an admin to clear lockouts.'; + } require __DIR__ . '/../views/login.php'; exit; } @@ -143,6 +161,123 @@ if ($route === '/login') { exit; } +if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $email = trim($_POST['email'] ?? ''); + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + $registerEmail = $email; + $registerUsername = $username; + $out = AuthRegistration::register($email, $password, $username); + if ($out['ok']) { + $registerSuccess = 'Check your email for a verification link.'; + require __DIR__ . '/../views/register.php'; + exit; + } + $registerError = match ($out['error'] ?? '') { + 'invalid_email' => 'Enter a valid email address.', + 'weak_password' => 'Password must be at least 10 characters.', + 'already_registered' => 'An account with this email or username already exists.', + 'rate_limited' => 'Too many attempts — try again later.', + default => 'Registration failed.', + }; + require __DIR__ . '/../views/register.php'; + exit; +} + +if ($route === '/register') { + require __DIR__ . '/../views/register.php'; + exit; +} + +if ($route === '/verify-email') { + $token = trim($_GET['token'] ?? ''); + $result = AuthRegistration::verifyEmailToken($token); + $verifyOk = $result['ok'] ?? false; + if (!$verifyOk) { + $verifyError = match ($result['error'] ?? '') { + 'expired_token' => 'This link has expired.', + 'invalid_token' => 'Invalid verification link.', + default => 'Verification failed.', + }; + } + require __DIR__ . '/../views/verify_email.php'; + exit; +} + +if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') { + if (Auth::pending2faUserId() <= 0) { + header('Location: ' . $base . '/login'); + exit; + } + $code = trim($_POST['code'] ?? ''); + if (Auth::completeTotpLogin($code)) { + header('Location: ' . $base . '/'); + exit; + } + $twofaError = 'Invalid code'; + require __DIR__ . '/../views/two_factor_challenge.php'; + exit; +} + +if ($route === '/two-factor') { + if (Auth::pending2faUserId() <= 0) { + header('Location: ' . $base . '/login'); + exit; + } + require __DIR__ . '/../views/two_factor_challenge.php'; + exit; +} + +if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') { + Auth::check(); + $user = Auth::user(); + $uid = (int) ($user['id'] ?? 0); + $action = (string) ($_POST['action'] ?? ''); + if ($action === 'start_totp') { + $secret = AuthTotp::generateSecret(); + $_SESSION['totp_enroll_secret'] = $secret; + $_SESSION['totp_enroll_exp'] = time() + 900; + } elseif ($action === 'confirm_totp') { + $secret = (string) ($_SESSION['totp_enroll_secret'] ?? ''); + $exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0); + unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']); + $code = trim($_POST['code'] ?? ''); + if ($secret === '' || $exp < time() || !AuthTotp::verify($secret, $code)) { + $securityError = 'Invalid code — try setup again.'; + } else { + AuthFactors::enrollTotp($uid, $secret); + $securitySuccess = 'Authenticator enrolled.'; + } + } elseif ($action === 'remove_totp') { + AuthFactors::removeTotp($uid); + $securitySuccess = 'Authenticator removed.'; + } + $totpSecret = ''; + if (!AuthFactors::hasTotp($uid)) { + $exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0); + if ($exp >= time()) { + $totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? ''); + } + } + require __DIR__ . '/../views/account_security.php'; + exit; +} + +if ($route === '/account-security') { + Auth::check(); + $user = Auth::user(); + $uid = (int) ($user['id'] ?? 0); + $totpSecret = ''; + if (!AuthFactors::hasTotp($uid)) { + $exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0); + if ($exp >= time()) { + $totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? ''); + } + } + require __DIR__ . '/../views/account_security.php'; + exit; +} + if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) { $_GET['view'] = 'graphs'; $route = '/'; diff --git a/examples/crash_reporter/backend/scripts/verify_auth_totp.php b/examples/crash_reporter/backend/scripts/verify_auth_totp.php new file mode 100644 index 0000000..ed75566 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/verify_auth_totp.php @@ -0,0 +1,14 @@ +prepare('SELECT * FROM users WHERE username = ? LIMIT 1'); $stmt->execute([$username]); $row = $stmt->fetch(); if (!$row || !password_verify($password, $row['password_hash'])) { + AuthAttempts::record('login_fail', $username); + return false; + } + $status = (string) ($row['status'] ?? 'active'); + if ($status === 'pending') { + return false; + } + if ($status === 'locked' || $status === 'disabled') { + AuthAttempts::record('login_fail', $username); + return false; + } + $uid = (int) ($row['id'] ?? 0); + if ($uid > 0 && AuthFactors::hasTotp($uid)) { + $_SESSION['pending_2fa_user_id'] = $uid; + $_SESSION['pending_2fa_exp'] = time() + 300; + AuthAttempts::record('login_ok', $username); + return 'pending_2fa'; + } + $_SESSION['user'] = Rbac::buildSessionUser($row); + AuthAttempts::record('login_ok', $username); + return true; + } + + public static function completeTotpLogin(string $code): bool { + $uid = (int) ($_SESSION['pending_2fa_user_id'] ?? 0); + $exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0); + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']); + if ($uid <= 0 || $exp < time()) { + return false; + } + $secret = AuthFactors::getTotpSecret($uid); + if ($secret === null || !AuthTotp::verify($secret, $code)) { + return false; + } + $stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + if (!$row) { return false; } $_SESSION['user'] = Rbac::buildSessionUser($row); return true; } + public static function pending2faUserId(): int { + $exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0); + if ($exp < time()) { + return 0; + } + return (int) ($_SESSION['pending_2fa_user_id'] ?? 0); + } + + public static function clearPending2fa(): void { + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']); + } + public static function logout(): void { + self::clearPending2fa(); unset($_SESSION['user']); } diff --git a/examples/crash_reporter/backend/src/AuthAttempts.php b/examples/crash_reporter/backend/src/AuthAttempts.php new file mode 100644 index 0000000..c8d5e38 --- /dev/null +++ b/examples/crash_reporter/backend/src/AuthAttempts.php @@ -0,0 +1,108 @@ +prepare( + 'INSERT INTO auth_attempts (ip_hash, username_hash, outcome, created_at) VALUES (?, ?, ?, ?)' + )->execute([$ipHash, $userHash, $outcome, date('Y-m-d H:i:s')]); + } + + public static function isRateLimited(?string $username): bool { + self::ensureSchema(); + $max = max(3, (int) cfg('auth.max_attempts', 8)); + $windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60); + $since = date('Y-m-d H:i:s', time() - $windowSec); + $pdo = Database::pdo(); + $ipHash = self::hashIp(self::clientIp()); + $st = $pdo->prepare( + "SELECT COUNT(*) FROM auth_attempts WHERE ip_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?" + ); + $st->execute([$ipHash, $since]); + if ((int) $st->fetchColumn() >= $max) { + return true; + } + if ($username !== null && $username !== '') { + $userHash = self::hashUsername($username); + $st = $pdo->prepare( + "SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?" + ); + $st->execute([$userHash, $since]); + if ((int) $st->fetchColumn() >= $max) { + return true; + } + } + return false; + } + + public static function recentFailureCount(string $username): int { + self::ensureSchema(); + $windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60); + $since = date('Y-m-d H:i:s', time() - $windowSec); + $st = Database::pdo()->prepare( + "SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?" + ); + $st->execute([self::hashUsername($username), $since]); + return (int) $st->fetchColumn(); + } + + public static function clearForUser(int $userId): int { + self::ensureSchema(); + $st = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1'); + $st->execute([$userId]); + $username = $st->fetchColumn(); + if (!is_string($username) || $username === '') { + return 0; + } + return self::clearForUsername($username); + } + + public static function clearForUsername(string $username): int { + self::ensureSchema(); + $hash = self::hashUsername($username); + $st = Database::pdo()->prepare('DELETE FROM auth_attempts WHERE username_hash = ?'); + $st->execute([$hash]); + $deleted = $st->rowCount(); + $pdo = Database::pdo(); + $pdo->prepare( + "UPDATE users SET status = 'active' WHERE username = ? AND status = 'locked'" + )->execute([$username]); + return $deleted; + } + + public static function clientIp(): string { + $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; + if (is_string($xff) && $xff !== '') { + $parts = explode(',', $xff); + return trim($parts[0]); + } + return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + } + + public static function hashIp(string $ip): string { + return hash_hmac('sha256', trim($ip), self::pepper()); + } + + public static function hashUsername(string $username): string { + return hash_hmac('sha256', strtolower(trim($username)), self::pepper()); + } + + private static function pepper(): string { + $p = (string) cfg('auth.encryption_key', ''); + if ($p === '') { + $p = 'dev-change-me-set-auth.encryption_key-in-config.php'; + } + return $p; + } +} diff --git a/examples/crash_reporter/backend/src/AuthCrypto.php b/examples/crash_reporter/backend/src/AuthCrypto.php new file mode 100644 index 0000000..8d68d7e --- /dev/null +++ b/examples/crash_reporter/backend/src/AuthCrypto.php @@ -0,0 +1,68 @@ +prepare( + "SELECT id FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1" + ); + $st->execute([$userId]); + return (bool) $st->fetchColumn(); + } + + public static function getTotpSecret(int $userId): ?string { + if ($userId <= 0) { + return null; + } + self::ensureSchema(); + $st = Database::pdo()->prepare( + "SELECT secret_encrypted FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1" + ); + $st->execute([$userId]); + $enc = $st->fetchColumn(); + if (!is_string($enc) || $enc === '') { + return null; + } + $plain = AuthCrypto::decrypt($enc); + return $plain !== '' ? $plain : null; + } + + public static function enrollTotp(int $userId, string $secret): void { + self::ensureSchema(); + $enc = AuthCrypto::encrypt($secret); + $pdo = Database::pdo(); + $pdo->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]); + $pdo->prepare( + 'INSERT INTO auth_factors (user_id, type, secret_encrypted, label, created_at) VALUES (?, ?, ?, ?, ?)' + )->execute([$userId, 'totp', $enc, 'Authenticator', date('Y-m-d H:i:s')]); + } + + public static function removeTotp(int $userId): void { + self::ensureSchema(); + Database::pdo()->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]); + } +} diff --git a/examples/crash_reporter/backend/src/AuthRegistration.php b/examples/crash_reporter/backend/src/AuthRegistration.php index 168a25d..8f5e311 100644 --- a/examples/crash_reporter/backend/src/AuthRegistration.php +++ b/examples/crash_reporter/backend/src/AuthRegistration.php @@ -15,11 +15,16 @@ final class AuthRegistration { /** @return array{ok:bool,error?:string} */ public static function register(string $email, string $password, string $username = ''): array { self::ensureSchema(); + if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) { + return ['ok' => false, 'error' => 'rate_limited']; + } $emailNorm = self::normalizeEmail($email); if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { + AuthAttempts::record('register_fail', $username ?: $email); return ['ok' => false, 'error' => 'invalid_email']; } if (strlen($password) < 10) { + AuthAttempts::record('register_fail', $username ?: $email); return ['ok' => false, 'error' => 'weak_password']; } $username = trim($username); @@ -30,6 +35,7 @@ final class AuthRegistration { $st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); $st->execute([$emailNorm, $username]); if ($st->fetchColumn()) { + AuthAttempts::record('register_fail', $username); return ['ok' => false, 'error' => 'already_registered']; } $hash = password_hash($password, PASSWORD_DEFAULT); @@ -51,10 +57,11 @@ final class AuthRegistration { VALUES (?, ?, ?, ?, ?, ?)' )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); $base = rtrim((string) cfg('base_path', ''), '/'); - $verifyUrl = 'https://apps.f0xx.org' . $base . '/?view=verify_email&token=' . urlencode($token); + $verifyUrl = 'https://apps.f0xx.org' . $base . '/verify-email?token=' . urlencode($token); if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { error_log('AuthRegistration: verify mail failed for ' . $emailNorm); } + AuthAttempts::record('register_ok', $username); return ['ok' => true]; } diff --git a/examples/crash_reporter/backend/src/AuthTotp.php b/examples/crash_reporter/backend/src/AuthTotp.php new file mode 100644 index 0000000..dfeeaad --- /dev/null +++ b/examples/crash_reporter/backend/src/AuthTotp.php @@ -0,0 +1,93 @@ + (string) $u['username'], 'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')), 'memberships' => $memberships, + 'auth_failures' => AuthAttempts::recentFailureCount((string) $u['username']), ]; } return $out; diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php index a57e519..75e4010 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -49,6 +49,10 @@ require_once __DIR__ . '/Auth.php'; require_once __DIR__ . '/AuthEmailSchema.php'; require_once __DIR__ . '/AuthMailer.php'; require_once __DIR__ . '/AuthRegistration.php'; +require_once __DIR__ . '/AuthCrypto.php'; +require_once __DIR__ . '/AuthTotp.php'; +require_once __DIR__ . '/AuthFactors.php'; +require_once __DIR__ . '/AuthAttempts.php'; require_once __DIR__ . '/ReportRepository.php'; require_once __DIR__ . '/TagCatalog.php'; require_once __DIR__ . '/TicketTagCatalog.php'; diff --git a/examples/crash_reporter/backend/views/account_security.php b/examples/crash_reporter/backend/views/account_security.php new file mode 100644 index 0000000..0978b9d --- /dev/null +++ b/examples/crash_reporter/backend/views/account_security.php @@ -0,0 +1,69 @@ + + + + + + + Account security — <?= h(cfg('app_name')) ?> + + + + + + + + + diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 7709b65..3d55de8 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -143,6 +143,14 @@ + + + Security + Company Company role Privilege set + Auth lockouts diff --git a/examples/crash_reporter/backend/views/login.php b/examples/crash_reporter/backend/views/login.php index eda061f..72b600c 100644 --- a/examples/crash_reporter/backend/views/login.php +++ b/examples/crash_reporter/backend/views/login.php @@ -49,7 +49,7 @@ $bp = Auth::basePath();

Default: admin / admin

-

Register (coming soon)

+

Register

diff --git a/examples/crash_reporter/backend/views/register.php b/examples/crash_reporter/backend/views/register.php new file mode 100644 index 0000000..c1294a9 --- /dev/null +++ b/examples/crash_reporter/backend/views/register.php @@ -0,0 +1,51 @@ + + + + + + + Register — <?= h(cfg('app_name')) ?> + + + + + + + + + + + diff --git a/examples/crash_reporter/backend/views/two_factor_challenge.php b/examples/crash_reporter/backend/views/two_factor_challenge.php new file mode 100644 index 0000000..5a9dd0a --- /dev/null +++ b/examples/crash_reporter/backend/views/two_factor_challenge.php @@ -0,0 +1,33 @@ + + + + + + + Two-factor — <?= h(cfg('app_name')) ?> + + + + + + + + + diff --git a/examples/crash_reporter/backend/views/verify_email.php b/examples/crash_reporter/backend/views/verify_email.php new file mode 100644 index 0000000..4b5b179 --- /dev/null +++ b/examples/crash_reporter/backend/views/verify_email.php @@ -0,0 +1,32 @@ + + + + + + + Verify email — <?= h(cfg('app_name')) ?> + + + + + + + + +