diff --git a/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php b/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php index af2bcae..8ccf010 100644 --- a/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php +++ b/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php @@ -62,4 +62,8 @@ if ($type === 'ra') { $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra); } +if (Auth::user()) { + Auth::touchSession(); +} + echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/sim/cluster0/lab-seeds/backend/public/index.php b/sim/cluster0/lab-seeds/backend/public/index.php index 2a54f0d..27ac0d4 100644 --- a/sim/cluster0/lab-seeds/backend/public/index.php +++ b/sim/cluster0/lab-seeds/backend/public/index.php @@ -132,6 +132,11 @@ if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) { exit; } +if ($route === '/api/media_pipelines.php' || str_ends_with($route, '/api/media_pipelines.php')) { + require __DIR__ . '/api/media_pipelines.php'; + exit; +} + if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) { require __DIR__ . '/api/ticket_attachment.php'; exit; @@ -159,11 +164,12 @@ if ($route === '/logout') { } if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') { + Auth::captureRedirectFromRequest(); $user = trim($_POST['username'] ?? ''); $pass = $_POST['password'] ?? ''; $result = Auth::login($user, $pass); if ($result === true) { - header('Location: ' . $base . '/'); + header('Location: ' . Auth::postLoginRedirect($base . '/')); exit; } if ($result === 'pending_2fa') { @@ -184,10 +190,12 @@ if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') { } if ($route === '/login') { + Auth::captureRedirectFromRequest(); if (Auth::pending2faUserId() > 0) { header('Location: ' . Auth::authUrl('/two-factor')); exit; } + $loginRedirect = Auth::peekPostLoginRedirect(''); require __DIR__ . '/../views/login.php'; exit; } @@ -242,7 +250,7 @@ if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') { } $code = trim($_POST['code'] ?? ''); if (Auth::completeTotpLogin($code)) { - header('Location: ' . $base . '/'); + header('Location: ' . Auth::postLoginRedirect($base . '/')); exit; } $twofaError = 'Invalid code'; @@ -278,9 +286,11 @@ if ( $_SESSION['pending_2fa_exp'] = time() + 300; $status = AuthApproval::pollStatus($rawToken); if ($status === 'approved') { - // Attempt to complete the login server-side within this same request if (Auth::completeTotpLoginByApproval($rawToken)) { - echo json_encode(['status' => 'approved', 'redirect' => $base . '/']); + echo json_encode([ + 'status' => 'approved', + 'redirect' => Auth::postLoginRedirect($base . '/'), + ]); } else { echo json_encode(['status' => 'expired']); } @@ -292,13 +302,19 @@ if ( // Approval confirmation page shown to the already-logged-in mobile user. if ($route === '/two-factor/approve' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $rawToken = trim($_POST['token'] ?? ''); + $totpCode = trim($_POST['code'] ?? ''); $mobileUser = Auth::user(); - if (!$mobileUser) { - header('Location: ' . Auth::authUrl('/login') . '?redirect=' . urlencode(Auth::authUrl('/two-factor/approve') . '?token=' . urlencode($_POST['token'] ?? ''))); + $approved = false; + if ($mobileUser && $rawToken !== '') { + $approved = AuthApproval::approve($rawToken, (int) ($mobileUser['id'] ?? 0), Auth::clientIp()); + } elseif ($rawToken !== '' && $totpCode !== '') { + $approved = AuthApproval::approveWithTotp($rawToken, $totpCode, Auth::clientIp()); + } else { + $returnUrl = Auth::authUrl('/two-factor/approve') . '?token=' . urlencode($rawToken); + header('Location: ' . Auth::authUrl('/login') . '?redirect=' . urlencode($returnUrl)); exit; } - $rawToken = trim($_POST['token'] ?? ''); - $approved = $rawToken !== '' && AuthApproval::approve($rawToken, (int) ($mobileUser['id'] ?? 0), Auth::clientIp()); $approveResult = $approved ? 'ok' : 'error'; require __DIR__ . '/../views/two_factor_approve.php'; exit; @@ -398,6 +414,15 @@ if ($view === 'short_links' && !Rbac::can('short_links_view')) { exit; } +if ($view === 'media_pipelines') { + require_once __DIR__ . '/../src/MediaPipelinesRepository.php'; + if (!MediaPipelinesRepository::gateAllowed()) { + http_response_code(403); + echo 'Forbidden'; + exit; + } +} + if ($view === 'report' && isset($_GET['id'])) { try { $report = ReportRepository::getById((int) $_GET['id']); @@ -455,6 +480,7 @@ $pageTitle = match ($view) { 'live_sessions' => 'Live sessions', 'remote_access' => 'Remote access', 'short_links' => 'Short links', + 'media_pipelines' => 'Media pipelines', 'rbac' => 'Access control', 'reports', 'report' => 'Issues', default => 'Console', diff --git a/sim/cluster0/lab-seeds/backend/scripts/reset_user_totp.php b/sim/cluster0/lab-seeds/backend/scripts/reset_user_totp.php new file mode 100644 index 0000000..1173868 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/reset_user_totp.php @@ -0,0 +1,38 @@ +#!/usr/bin/env php +\n"); + exit(1); +} + +$st = Database::pdo()->prepare('SELECT id, username, email_normalized FROM users WHERE username = ? LIMIT 1'); +$st->execute([$username]); +$row = $st->fetch(PDO::FETCH_ASSOC); +if (!$row) { + fwrite(STDERR, "User not found: {$username}\n"); + exit(1); +} + +$uid = (int) $row['id']; +$secret = AuthTotp::generateSecret(); +AuthFactors::enrollTotp($uid, $secret); + +$account = (string) ($row['email_normalized'] ?: $row['username']); +$issuer = (string) cfg('app_name', 'AndroidCast'); +$uri = AuthTotp::provisioningUri($secret, $account, $issuer); +$qr = AuthTotp::qrImageUrl($uri); + +echo "user={$username} id={$uid}\n"; +echo "otpauth_uri={$uri}\n"; +echo "qr_png={$qr}\n"; +echo "Scan the QR URL in a browser or add manually in Google Authenticator.\n"; diff --git a/sim/cluster0/lab-seeds/backend/src/bootstrap.php b/sim/cluster0/lab-seeds/backend/src/bootstrap.php index 5bb2cbe..372f178 100644 --- a/sim/cluster0/lab-seeds/backend/src/bootstrap.php +++ b/sim/cluster0/lab-seeds/backend/src/bootstrap.php @@ -119,7 +119,7 @@ function resolve_console_route(string $uri): string { if ($projectRoot !== '' && str_starts_with($uri, $projectRoot)) { $suffix = substr($uri, strlen($projectRoot)) ?: '/'; $candidate = rtrim(strtok($suffix, '?') ?: '/', '/') ?: '/'; - if (preg_match('#^/(login|logout|register|two-factor|verify-email)(?:/|$)#', $candidate)) { + if (preg_match('#^/(login|logout|register|verify-email|account-security|two-factor(?:/approve)?|api/two-factor(?:/poll)?)(/|$)#', $candidate)) { return $candidate; } }