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;
}