includes/modules/auth.php

<?php
declare(strict_types=1);

require_once __DIR__ . '/two_factor.php';

/*
|--------------------------------------------------------------------------
| Admin authentication
|--------------------------------------------------------------------------
|
| Brivacia uses a single local administrator account.
| The password hash is stored in settings.json.
| The browser receives a signed HttpOnly cookie.
| No server-side session files are created.
|
*/

function brivaciaAuthCookieName(): string
{
    return 'brivacia_admin';
}

function brivaciaAuthUser(): string
{
    return trim((string)brivacia_setting('admin.username', ''));
}

function brivaciaAuthPasswordHash(): string
{
    return trim((string)brivacia_setting('admin.password_hash', ''));
}

function brivaciaIsAuthConfigured(): bool
{
    return brivaciaAuthUser() !== '' && brivaciaAuthPasswordHash() !== '';
}

function brivaciaAuthBase64UrlEncode(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function brivaciaAuthBase64UrlDecode(string $data): string|false
{
    $data = strtr($data, '-_', '+/');
    $pad = strlen($data) % 4;

    if ($pad > 0) {
        $data .= str_repeat('=', 4 - $pad);
    }

    return base64_decode($data, true);
}

function brivaciaAuthSecret(): string
{
    $keyFile = dataDir() . '/brivacia.key';
    $key = is_file($keyFile)
        ? trim((string)file_get_contents($keyFile))
        : '';

    if ($key === '') {
        http_response_code(500);
        header('Content-Type: text/plain; charset=utf-8');
        echo "Brivacia key is missing.\n";
        echo "Restore data/brivacia.key from your backup.\n";
        echo "Do not reinstall Brivacia and do not generate a new key.\n";
        exit;
    }

    return hash('sha256', $key . "\n" . brivaciaAuthPasswordHash(), true);
}

function brivaciaAuthSign(string $payload): string
{
    return brivaciaAuthBase64UrlEncode(
        hash_hmac('sha256', $payload, brivaciaAuthSecret(), true)
    );
}

function brivaciaAuthCookieSecure(): bool
{
    return
        (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
        || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
}

function brivaciaAuthClearCookie(): void
{
    setcookie(brivaciaAuthCookieName(), '', [
        'expires' => time() - 3600,
        'path' => '/',
        'secure' => brivaciaAuthCookieSecure(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

function brivaciaCreateAdminSession(bool $remember = false): void
{
    $now = time();

    $cookieExpires = $remember
        ? $now + (90 * 86400)
        : 0;

    $sessionExpires = $remember
        ? $cookieExpires
        : $now + 86400;

    $payload = brivaciaAuthBase64UrlEncode(json_encode([
        'iat' => $now,
        'exp' => $sessionExpires,
        'rnd' => bin2hex(random_bytes(16)),
    ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}');

    $token = $payload . '.' . brivaciaAuthSign($payload);

    setcookie(brivaciaAuthCookieName(), $token, [
        'expires' => $cookieExpires,
        'path' => '/',
        'secure' => brivaciaAuthCookieSecure(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

function brivaciaDestroyAdminSession(): void
{
    brivaciaAuthClearCookie();
}

function brivaciaIsAuthenticated(): bool
{
    if (!brivaciaIsAuthConfigured()) {
        return false;
    }

    $token = (string)($_COOKIE[brivaciaAuthCookieName()] ?? '');

    if ($token === '' || !str_contains($token, '.')) {
        return false;
    }

    [$payload, $signature] = explode('.', $token, 2);

    if (
        $payload === '' ||
        $signature === '' ||
        !hash_equals(brivaciaAuthSign($payload), $signature)
    ) {
        brivaciaAuthClearCookie();
        return false;
    }

    $json = brivaciaAuthBase64UrlDecode($payload);

    if ($json === false) {
        brivaciaAuthClearCookie();
        return false;
    }

    $data = json_decode($json, true);

    if (!is_array($data)) {
        brivaciaAuthClearCookie();
        return false;
    }

    $expires = (int)($data['exp'] ?? 0);

    if ($expires < time()) {
        brivaciaAuthClearCookie();
        return false;
    }

    return true;
}

/*
|--------------------------------------------------------------------------
| HTML authentication guard
|--------------------------------------------------------------------------
|
| Dashboard pages render the login page when the administrator is not
| authenticated. The optional error message is passed through to the login
| modal after a failed login attempt.
|
*/

function brivaciaRequireAuth(?string $authError = null): void
{
    if (brivaciaIsAuthenticated()) {
        return;
    }

    require __DIR__ . '/login.php';
    exit;
}

/*
|--------------------------------------------------------------------------
| JSON API authentication guard
|--------------------------------------------------------------------------
|
| API endpoints return a JSON 401 response instead of rendering the login
| page. Public endpoints such as pixel.php and privacy.php do not call this.
|
*/

function brivaciaRequireApiAuth(): void
{
    if (brivaciaIsAuthenticated()) {
        return;
    }

    http_response_code(401);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode([
        'ok' => false,
        'error' => 'Authentication required.',
    ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

/*
|--------------------------------------------------------------------------
| CSRF protection
|--------------------------------------------------------------------------
|
| Derived from the signed admin cookie itself (same secret, same payload),
| namespaced with a "csrf|" prefix so it's a different value than the
| cookie's own signature. Exposed to the dashboard as a JS global and sent
| back as a header on state-changing requests (see main.js's fetch
| wrapper). An attacker's site can't read the admin's cookie or that JS
| global cross-origin, so it can't forge a valid token.
|
*/

function brivaciaCsrfToken(): string
{
    $token = (string)($_COOKIE[brivaciaAuthCookieName()] ?? '');

    if ($token === '' || !str_contains($token, '.')) {
        return '';
    }

    [$payload] = explode('.', $token, 2);

    if ($payload === '') {
        return '';
    }

    return brivaciaAuthSign('csrf|' . $payload);
}

function brivaciaCheckCsrf(): bool
{
    $expected = brivaciaCsrfToken();

    if ($expected === '') {
        return false;
    }

    $provided = (string)(
        $_SERVER['HTTP_X_BRIVACIA_CSRF']
        ?? $_POST['csrf_token']
        ?? ''
    );

    return $provided !== '' && hash_equals($expected, $provided);
}

function brivaciaRequireCsrf(): void
{
    if (brivaciaCheckCsrf()) {
        return;
    }

    http_response_code(403);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode([
        'ok' => false,
        'error' => 'Invalid or missing CSRF token.',
    ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

/*
|--------------------------------------------------------------------------
| Brute-force protection
|--------------------------------------------------------------------------
|
| Failed login attempts are tracked by truncated IP prefix (same 2-3 octet
| precision used everywhere else for visitor privacy, reused here for a
| security purpose). After 4 failed attempts, each further failure locks
| the prefix out for a growing delay (1 min, then 2, 4, 8... capped at
| 30 min). The counter resets on its own after 1h of calm, or immediately
| on a successful login.
*/

function brivaciaLoginAttemptKey(): string
{
    $prefix = brivaciaIpPrefix(brivaciaClientIp());
    return $prefix !== '' ? $prefix : 'unknown';
}

function brivaciaLoginAttemptRow(PDO $db, string $key): array
{
    $stmt = $db->prepare('SELECT failed_count, last_failed_at, locked_until FROM login_attempts WHERE ip_prefix = ?');
    $stmt->execute([$key]);

    return $stmt->fetch() ?: ['failed_count' => 0, 'last_failed_at' => 0, 'locked_until' => 0];
}

function brivaciaLoginLockSecondsRemaining(PDO $db, string $key): int
{
    $row = brivaciaLoginAttemptRow($db, $key);
    $remaining = (int)$row['locked_until'] - time();

    return $remaining > 0 ? $remaining : 0;
}

function brivaciaLoginRegisterFailure(PDO $db, string $key): void
{
    $now = time();
    $row = brivaciaLoginAttemptRow($db, $key);

    // A full hour without any failure = clean slate, not an accumulating grudge.
    $failedCount = ((int)$row['last_failed_at'] > 0 && ($now - (int)$row['last_failed_at']) > 3600)
        ? 0
        : (int)$row['failed_count'];

    $failedCount++;

    $lockSeconds = $failedCount > 4
        ? min(1800, (int)(30 * (2 ** ($failedCount - 4))))
        : 0;

    $db->prepare('
        INSERT INTO login_attempts (ip_prefix, failed_count, last_failed_at, locked_until)
        VALUES (?, ?, ?, ?)
        ON CONFLICT(ip_prefix) DO UPDATE SET
            failed_count = excluded.failed_count,
            last_failed_at = excluded.last_failed_at,
            locked_until = excluded.locked_until
    ')->execute([
        $key,
        $failedCount,
        $now,
        $lockSeconds > 0 ? $now + $lockSeconds : 0,
    ]);
}

function brivaciaLoginRegisterSuccess(PDO $db, string $key): void
{
    $db->prepare('DELETE FROM login_attempts WHERE ip_prefix = ?')->execute([$key]);
}

function brivaciaHandleAuthPost(): ?string
{
    $action = (string)($_POST['auth_action'] ?? '');

    if ($action === 'logout') {
        brivaciaDestroyAdminSession();
        header('Location: /');
        exit;
    }

    if ($action !== 'login') {
        return null;
    }

    $db = brivaciaDb();
    $attemptKey = brivaciaLoginAttemptKey();
    $lockedFor = brivaciaLoginLockSecondsRemaining($db, $attemptKey);

    if ($lockedFor > 0) {
        return t('auth.errors.login.locked', [
            'minutes' => (string)(int)ceil($lockedFor / 60),
        ]);
    }

    $username = trim((string)($_POST['username'] ?? ''));
    $password = (string)($_POST['password'] ?? '');

    if (
        !hash_equals(brivaciaAuthUser(), $username) ||
        !password_verify($password, brivaciaAuthPasswordHash())
    ) {
        brivaciaLoginRegisterFailure($db, $attemptKey);
        return t('auth.errors.login.failed');
    }

    if (brivaciaTwoFactorEnabled()) {
        $twoFactorCode = (string)(
            $_POST['totp']
            ?? $_POST['otp']
            ?? $_POST['one_time_code']
            ?? $_POST['two_factor_code']
            ?? ''
        );

        // Deliberately the same generic message as the credential check
        // above: a distinct "2FA failed" message would tell an attacker
        // their guessed/stolen username+password was actually correct,
        // even without ever getting past the 2FA step.
        if (!brivaciaTwoFactorVerifyLoginCode($twoFactorCode)) {
            brivaciaLoginRegisterFailure($db, $attemptKey);
            return t('auth.errors.login.failed');
        }
    }

    brivaciaLoginRegisterSuccess($db, $attemptKey);
    brivaciaCreateAdminSession(!empty($_POST['remember']));

    header('Location: /');
    exit;
}

Contribute