1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-08-12 18:12:19 +03:00

Lab: remember-me, Grafana proxy fix, builder cache dirs, branded QR alerts.

Grafana auth restore + nginx proxy_cookie_path; alert-notifier branded QR; prepare-be-builder-dirs shared ccache/gradle; bump ac-scripts for Docker DLC maintenance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-08-12 14:08:17 +02:00
parent dc3830dc6b
commit 6a58eb716d
13 changed files with 416 additions and 10 deletions

View File

@@ -14,6 +14,17 @@ require_once dirname(__DIR__, 4) . '/platform/shared_session.php';
platform_start_session('ac_crash_sess', '/app/androidcast_project');
$composedSrc = dirname(__DIR__, 2) . '/src';
if (is_file($composedSrc . '/AuthRemember.php')) {
require_once $composedSrc . '/Database.php';
require_once $composedSrc . '/Rbac.php';
require_once $composedSrc . '/Auth.php';
require_once $composedSrc . '/AuthRemember.php';
require_once $composedSrc . '/AuthEmailSchema.php';
require_once $composedSrc . '/AuthFactors.php';
AuthRemember::tryRestore();
}
$user = $_SESSION['user'] ?? null;
if (empty($user['id'])) {

View File

@@ -165,10 +165,13 @@ if ($route === '/logout') {
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::captureRedirectFromRequest();
AuthRemember::stashPendingFromRequest();
$user = trim($_POST['username'] ?? '');
$pass = $_POST['password'] ?? '';
$result = Auth::login($user, $pass);
if ($result === true) {
$uid = (int) (Auth::user()['id'] ?? 0);
AuthRemember::applyPending($uid);
header('Location: ' . Auth::postLoginRedirect($base . '/'));
exit;
}
@@ -191,6 +194,10 @@ if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($route === '/login') {
Auth::captureRedirectFromRequest();
if (Auth::user()) {
header('Location: ' . Auth::postLoginRedirect($base . '/'));
exit;
}
if (Auth::pending2faUserId() > 0) {
header('Location: ' . Auth::authUrl('/two-factor'));
exit;
@@ -253,6 +260,8 @@ if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
$code = trim($_POST['code'] ?? '');
if (Auth::completeTotpLogin($code)) {
$uid = (int) (Auth::user()['id'] ?? 0);
AuthRemember::applyPending($uid);
header('Location: ' . Auth::postLoginRedirect($base . '/'));
exit;
}
@@ -290,6 +299,8 @@ if (
$status = AuthApproval::pollStatus($rawToken);
if ($status === 'approved') {
if (Auth::completeTotpLoginByApproval($rawToken)) {
$uid = (int) (Auth::user()['id'] ?? 0);
AuthRemember::applyPending($uid);
echo json_encode([
'status' => 'approved',
'redirect' => Auth::postLoginRedirect($base . '/'),

View File

@@ -11,7 +11,9 @@ DOCKER_HOME="${BROADCAST_ROOT}"
DOWNLOADS="${2:-/var/www/localhost/htdocs/apps/app/androidcast_project/downloads}"
OTA_ROOT="${3:-/var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts}"
mkdir -p "${BUILDS}" "${DOCKER_HOME}/.docker" "${DOWNLOADS}" "${OTA_ROOT}/v0/ota/channel"
mkdir -p "${BUILDS}" "${BUILDS}/.cache/ccache" "${BUILDS}/.cache/gradle/wrapper" \
"${BUILDS}/.cache/gradle/caches" "${DOCKER_HOME}/.docker" "${DOWNLOADS}" "${OTA_ROOT}/v0/ota/channel"
chmod 1777 "${BUILDS}/.cache/ccache" "${BUILDS}/.cache/gradle" 2>/dev/null || true
# Do not chown -R broadcast root: that resets builds/* to nginx and breaks PHP-FPM (nobody).
for _d in config bin deploy sql src public; do

View File

@@ -34,11 +34,65 @@ final class Auth {
return $_SESSION['user'];
}
private const SESSION_IDLE_SECONDS = 86400;
public static function check(): void {
if (!self::user()) {
header('Location: ' . self::authUrl('/login'));
$return = (string) ($_SERVER['REQUEST_URI'] ?? '');
$q = $return !== '' ? '?redirect=' . urlencode($return) : '';
header('Location: ' . self::authUrl('/login') . $q);
exit;
}
$last = (int) ($_SESSION['last_activity'] ?? 0);
if ($last > 0 && (time() - $last) > self::SESSION_IDLE_SECONDS) {
self::logout();
$return = (string) ($_SERVER['REQUEST_URI'] ?? '');
$q = $return !== '' ? '?redirect=' . urlencode($return) : '';
header('Location: ' . self::authUrl('/login') . $q);
exit;
}
self::touchSession();
}
public static function touchSession(): void {
if (self::user()) {
$_SESSION['last_activity'] = time();
}
}
/** Accept only in-project relative URLs (open redirect safe). */
public static function safeRedirect(string $url): ?string {
$url = trim($url);
if ($url === '') {
return null;
}
$project = self::projectBasePath();
if (str_starts_with($url, $project . '/') || $url === $project) {
return $url;
}
if (str_starts_with($url, '/') && !str_starts_with($url, '//') && str_starts_with($url, $project)) {
return $url;
}
return null;
}
public static function captureRedirectFromRequest(): void {
$from = trim((string) ($_GET['redirect'] ?? $_POST['redirect'] ?? ''));
$safe = self::safeRedirect($from);
if ($safe !== null) {
$_SESSION['post_login_redirect'] = $safe;
}
}
public static function peekPostLoginRedirect(string $default): string {
$redir = (string) ($_SESSION['post_login_redirect'] ?? '');
return self::safeRedirect($redir) ?? $default;
}
public static function postLoginRedirect(string $default): string {
$target = self::peekPostLoginRedirect($default);
unset($_SESSION['post_login_redirect']);
return $target;
}
public static function can(string $action): bool {
@@ -94,6 +148,7 @@ final class Auth {
return 'pending_2fa';
}
$_SESSION['user'] = Rbac::buildSessionUser($row);
self::touchSession();
AuthAttempts::record('login_ok', $username);
return true;
}
@@ -117,6 +172,7 @@ final class Auth {
return false;
}
$_SESSION['user'] = Rbac::buildSessionUser($row);
self::touchSession();
return true;
}
@@ -129,11 +185,68 @@ final class Auth {
}
public static function clearPending2fa(): void {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp'], $_SESSION['pending_2fa_approval_token']);
}
/**
* Complete login via a cross-device approval token (no TOTP code required).
* The approval token must have been approved by the authenticated mobile user.
*/
public static function completeTotpLoginByApproval(string $rawToken): bool {
$uid = self::pending2faUserId();
if ($uid <= 0) {
return false;
}
$sessionId = session_id();
$consumedUid = AuthApproval::consume($rawToken, $sessionId);
if ($consumedUid <= 0 || $consumedUid !== $uid) {
return false;
}
self::clearPending2fa();
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$consumedUid]);
$row = $stmt->fetch();
if (!$row) {
return false;
}
$_SESSION['user'] = Rbac::buildSessionUser($row);
self::touchSession();
return true;
}
/** Best-effort client IP (respects X-Forwarded-For from trusted proxy). */
public static function clientIp(): string {
$fwd = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '');
if ($fwd !== '') {
// Take the first (original client) IP from the chain
$first = trim(explode(',', $fwd)[0]);
if (filter_var($first, FILTER_VALIDATE_IP)) {
return $first;
}
}
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
/** Origin for outbound links (QR, email) — matches the host the user is browsing. */
public static function requestPublicOrigin(): string {
$host = trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''))[0]);
$host = preg_replace('/:\d+$/', '', $host);
if ($host !== '' && !in_array($host, ['127.0.0.1', 'localhost'], true)) {
$scheme = 'http';
if (function_exists('platform_is_https_request') && platform_is_https_request()) {
$scheme = 'https';
} elseif (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
$scheme = 'https';
}
return $scheme . '://' . $host;
}
$cfg = rtrim((string) cfg('public_origin', ''), '/');
return $cfg !== '' ? $cfg : 'https://apps.f0xx.org';
}
public static function logout(): void {
self::clearPending2fa();
AuthRemember::clear();
unset($_SESSION['user']);
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/** Persistent login via HttpOnly cookie backed by auth_tokens (purpose login_magic). */
final class AuthRemember {
private const COOKIE = 'ac_remember';
private const TTL_SECONDS = 2592000; // 30 days
private function __construct() {
}
public static function tryRestore(): void {
if (PHP_SAPI === 'cli' || !empty($_SESSION['user'])) {
return;
}
$raw = trim((string) ($_COOKIE[self::COOKIE] ?? ''));
if ($raw === '' || !preg_match('/^[a-f0-9]{64}$/', $raw)) {
return;
}
AuthEmailSchema::ensure(Database::pdo());
$hash = hash('sha256', $raw);
$stmt = Database::pdo()->prepare(
"SELECT t.user_id, u.* FROM auth_tokens t
INNER JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.purpose = 'login_magic'
AND t.used_at IS NULL AND t.expires_at > ?
LIMIT 1"
);
$now = gmdate('Y-m-d H:i:s');
$stmt->execute([$hash, $now]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || empty($row['id'])) {
self::clearCookie();
return;
}
$status = (string) ($row['status'] ?? 'active');
if ($status !== 'active') {
self::clearCookie();
return;
}
if (AuthFactors::hasTotp((int) $row['id'])) {
// Remember-me skips password but not 2FA.
return;
}
Database::ensureRbacSchema();
Rbac::seedMembershipsForUser((int) $row['id'], (string) ($row['role'] ?? 'viewer'));
$_SESSION['user'] = Rbac::buildSessionUser($row);
Auth::touchSession();
}
public static function issue(int $userId): void {
if ($userId <= 0) {
return;
}
AuthEmailSchema::ensure(Database::pdo());
$pdo = Database::pdo();
$pdo->prepare(
"DELETE FROM auth_tokens WHERE user_id = ? AND purpose = 'login_magic' AND used_at IS NULL"
)->execute([$userId]);
$raw = bin2hex(random_bytes(32));
$hash = hash('sha256', $raw);
$expires = gmdate('Y-m-d H:i:s', time() + self::TTL_SECONDS);
$pdo->prepare(
'INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)'
)->execute([$userId, 'login_magic', $hash, $expires, gmdate('Y-m-d H:i:s')]);
self::setCookie($raw, self::TTL_SECONDS);
}
public static function clear(): void {
$raw = trim((string) ($_COOKIE[self::COOKIE] ?? ''));
if ($raw !== '' && preg_match('/^[a-f0-9]{64}$/', $raw)) {
AuthEmailSchema::ensure(Database::pdo());
$hash = hash('sha256', $raw);
Database::pdo()->prepare(
"UPDATE auth_tokens SET used_at = ? WHERE token_hash = ? AND purpose = 'login_magic' AND used_at IS NULL"
)->execute([gmdate('Y-m-d H:i:s'), $hash]);
}
self::clearCookie();
}
public static function stashPendingFromRequest(): void {
if (!empty($_POST['remember_me'])) {
$_SESSION['remember_me_after_login'] = 1;
} else {
unset($_SESSION['remember_me_after_login']);
}
}
public static function applyPending(int $userId): void {
if ($userId <= 0 || empty($_SESSION['remember_me_after_login'])) {
unset($_SESSION['remember_me_after_login']);
return;
}
unset($_SESSION['remember_me_after_login']);
self::issue($userId);
}
private static function cookiePath(): string {
$path = trim((string) cfg('session_cookie_path', '/app/androidcast_project'));
return $path !== '' ? rtrim($path, '/') : '/';
}
private static function setCookie(string $value, int $maxAge): void {
$secure = function_exists('platform_is_https_request') && platform_is_https_request();
setcookie(self::COOKIE, $value, [
'expires' => time() + $maxAge,
'path' => self::cookiePath(),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
$_COOKIE[self::COOKIE] = $value;
}
private static function clearCookie(): void {
$secure = function_exists('platform_is_https_request') && platform_is_https_request();
setcookie(self::COOKIE, '', [
'expires' => time() - 3600,
'path' => self::cookiePath(),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
unset($_COOKIE[self::COOKIE]);
}
}

View File

@@ -46,6 +46,7 @@ require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Rbac.php';
require_once __DIR__ . '/DeviceRepository.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/AuthRemember.php';
require_once __DIR__ . '/AuthEmailSchema.php';
require_once __DIR__ . '/AuthMailer.php';
require_once __DIR__ . '/AuthRegistration.php';
@@ -78,6 +79,10 @@ require_once __DIR__ . '/AuthApproval.php';
require_once __DIR__ . '/AuthTwoFactorPage.php';
require_once __DIR__ . '/AnalyticsHead.php';
if (PHP_SAPI !== 'cli') {
AuthRemember::tryRestore();
}
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);

View File

@@ -53,6 +53,7 @@ $loginRedirect = $loginRedirect ?? '';
<?php endif; ?>
<label><span data-i18n="login.username">Username</span><input name="username" autocomplete="username" required></label>
<label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label>
<label class="build-form-checks"><input type="checkbox" name="remember_me" value="1"> <span data-i18n="login.remember_me">Remember me on this device</span></label>
<button type="submit" data-i18n="login.submit">Sign in</button>
<p class="hint"><a href="<?= h($auth) ?>/register" data-i18n="login.register">Create account</a></p>
</form>