From 3bd9a245593570449e0e573676a6cdfbc6759692 Mon Sep 17 00:00:00 2001
From: Anton Afanasyeu = h($account) ?> Authenticator app is enrolled. Scan this QR code with your authenticator app, then enter a code to confirm. Protect your account with a 6-digit authenticator app (recommended for alpha). Default: admin / admin Register (coming soon)' + 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 = ' ';
+ 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 @@
+
+
+
+
+
+
+ No users in scope. Account security
+ Can’t scan?
+ = h($totpSecret) ?>
+ 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();
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 @@ + + + +
+ + +
+ + + + + +
+