foxygit / foxygit Log in
A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
commits tags

/inc/auth.php · 10.28 KB

raw
<?php
declare(strict_types=1);

/*
 * Accounts, API keys, and sessions — the one part of foxygit that isn't
 * read-only. Everything lives in one JSON file (AUTH_STORE, see
 * inc/config.php), read-modify-written under an flock() so concurrent
 * requests can't clobber each other.
 *
 * Split in two halves on purpose:
 *   - the data layer (load/save/create_user/verify_login/api keys) has no
 *     dependency on $_SESSION or any web-only PHP feature, so
 *     server/verify-api-key.php can require() this file and call
 *     verify_api_key() from plain CLI — the HTTPS-push auth check reuses
 *     the exact same hashing/lookup code the web app uses.
 *   - the session/CSRF half is web-only and only ever touched by the new
 *     login/account/admin routes in index.php, never by plain repo
 *     browsing — so browsing a repo still sets no cookie beyond the
 *     existing foxygit_theme one.
 */

/** Open AUTH_STORE, lock it, hand the decoded array to $mutator by
 *  reference, then write back whatever $mutator left in it. $mutator's
 *  return value is passed through — this is the only way callers read or
 *  write the store, so every operation is atomic against concurrent requests. */
function with_auth_store(callable $mutator) {
    $dir = dirname(AUTH_STORE);
    if (!is_dir($dir)) @mkdir($dir, 0700, true);

    $fh = fopen(AUTH_STORE, 'c+');
    if ($fh === false) {
        throw new RuntimeException('cannot open auth store: ' . AUTH_STORE);
    }
    flock($fh, LOCK_EX);

    $raw  = stream_get_contents($fh);
    $data = $raw !== false && $raw !== '' ? json_decode($raw, true) : null;
    if (!is_array($data)) $data = [];
    if (!isset($data['users']) || !is_array($data['users'])) $data['users'] = [];
    if (!isset($data['keys'])  || !is_array($data['keys']))  $data['keys']  = [];

    $result = $mutator($data);

    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
    fflush($fh);
    flock($fh, LOCK_UN);
    fclose($fh);
    @chmod(AUTH_STORE, 0600);

    return $result;
}

function has_any_users(): bool {
    return with_auth_store(function (array &$data): bool {
        return $data['users'] !== [];
    });
}

function valid_username(string $name): bool {
    return (bool) preg_match('~^[A-Za-z0-9_-]{2,32}$~', $name);
}

/** @return true|string true on success, an error message otherwise. */
function create_user(string $username, string $password, bool $isAdmin) {
    if (!valid_username($username)) {
        return 'Username must be 2-32 characters: letters, digits, "_" or "-".';
    }
    if (strlen($password) < 8) {
        return 'Password must be at least 8 characters.';
    }
    return with_auth_store(function (array &$data) use ($username, $password, $isAdmin) {
        if (isset($data['users'][$username])) {
            return 'That username is already taken.';
        }
        $data['users'][$username] = [
            'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => PASSWORD_COST]),
            'is_admin'      => $isAdmin,
            'created'       => time(),
        ];
        return true;
    });
}

/* Bcrypt cost, pinned rather than left to PASSWORD_DEFAULT so that the work
 * factor of a real verify and of the dummy verify below can't drift apart —
 * PHP raised the bcrypt default from 10 to 12 in 8.4, and a dummy hash left
 * at the older cost verifies ~4x faster, which is exactly the timing signal
 * it exists to suppress. Raise this to make hashing slower; if you do,
 * regenerate DUMMY_PASSWORD_HASH at the same cost:
 *     php -r 'echo password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT, ["cost" => 12]);'
 * Existing stored hashes keep working either way — password_verify() reads
 * the cost out of the hash itself. */
const PASSWORD_COST = 12;

// A valid bcrypt hash (of a random string) that nothing will ever match — run
// through password_verify() when a username doesn't exist, so a login attempt
// against an unknown user costs the same as one against a real user with a
// wrong password, and timing doesn't reveal which usernames exist.
const DUMMY_PASSWORD_HASH = '$2y$12$JjwvL8rDRDdE9lf2XI548eGjcRu9scv6tNsXmzjcExnQztbg3zGIm';

function verify_login(string $username, string $password): bool {
    return with_auth_store(function (array &$data) use ($username, $password): bool {
        $user = $data['users'][$username] ?? null;
        $hash = $user['password_hash'] ?? DUMMY_PASSWORD_HASH;
        $ok   = password_verify($password, $hash);
        return $user !== null && $ok;
    });
}

function list_users(): array {
    return with_auth_store(function (array &$data): array {
        $out = $data['users'];
        uasort($out, fn(array $a, array $b): int => $a['created'] <=> $b['created']);
        return $out;
    });
}

/** @return true|string true on success, an error message otherwise. */
function delete_user(string $username, string $requester) {
    if ($username === $requester) {
        return "You can't delete your own account while logged in as it.";
    }
    return with_auth_store(function (array &$data) use ($username): bool {
        if (!isset($data['users'][$username])) return false;
        unset($data['users'][$username]);
        foreach ($data['keys'] as $hash => $meta) {
            if ($meta['user'] === $username) unset($data['keys'][$hash]);
        }
        return true;
    }) ? true : 'No such user.';
}

/** Returns the raw key — shown to the user exactly once. Only its SHA-256
 *  hash is ever stored, the same way GitHub/GitLab handle personal access
 *  tokens (a random high-entropy token is looked up by exact hash, unlike a
 *  user-chosen low-entropy password which needs bcrypt's per-hash salt). */
function generate_api_key(string $username, string $label): string {
    $raw  = 'fxg_' . bin2hex(random_bytes(24));
    $hash = hash('sha256', $raw);
    $label = $label !== '' ? $label : 'unnamed key';
    with_auth_store(function (array &$data) use ($username, $label, $hash): void {
        $data['keys'][$hash] = [
            'user'      => $username,
            'label'     => $label,
            'created'   => time(),
            'last_used' => null,
        ];
    });
    return $raw;
}

function list_api_keys(string $username): array {
    return with_auth_store(function (array &$data) use ($username): array {
        $out = array_filter($data['keys'], fn(array $k): bool => $k['user'] === $username);
        uasort($out, fn(array $a, array $b): int => $b['created'] <=> $a['created']);
        return $out;
    });
}

function revoke_api_key(string $hash, string $requester, bool $requesterIsAdmin): bool {
    return with_auth_store(function (array &$data) use ($hash, $requester, $requesterIsAdmin): bool {
        $key = $data['keys'][$hash] ?? null;
        if ($key === null) return false;
        if (!$requesterIsAdmin && $key['user'] !== $requester) return false;
        unset($data['keys'][$hash]);
        return true;
    });
}

/** Validates a raw API key (as sent as an HTTP Basic auth password) and
 *  bumps its last_used timestamp. Used by the web app AND by
 *  server/verify-api-key.php from plain CLI — the one shared source of
 *  truth for "is this key valid". @return the owning username, or null. */
function verify_api_key(string $rawKey): ?string {
    $rawKey = trim($rawKey);
    if ($rawKey === '') return null;
    $hash = hash('sha256', $rawKey);
    return with_auth_store(function (array &$data) use ($hash): ?string {
        $key = $data['keys'][$hash] ?? null;
        if ($key === null) return null;
        $data['keys'][$hash]['last_used'] = time();
        return $key['user'];
    });
}

/* ------------------------------------------------------------- sessions */
/* Everything below is web-only and assumes a normal PHP request/response
 * lifecycle ($_SESSION, headers). Only called from the login/account/admin
 * routes in index.php — never from plain repo browsing. */

const SESSION_COOKIE_NAME = 'foxygit_sess';

/** Called once per request (index.php) with $force = false: resumes a
 *  session if the visitor already has one (so the nav can show "logged in
 *  as ..." everywhere), but never creates one for a plain anonymous
 *  browsing visitor — that's the whole "no cookies beyond the theme one"
 *  guarantee. The login/setup/logout/account/admin routes call it again
 *  with $force = true, which guarantees a session exists (creating one on
 *  a visitor's very first hit to e.g. ?a=login). */
function auth_session_start(bool $force = false): void {
    if (session_status() === PHP_SESSION_ACTIVE) return;
    if (!$force && !isset($_COOKIE[SESSION_COOKIE_NAME])) return;
    session_name(SESSION_COOKIE_NAME);
    session_set_cookie_params([
        'lifetime' => 0, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
    ]);
    session_start();
}

function current_username(): ?string {
    if (session_status() !== PHP_SESSION_ACTIVE) return null;
    return $_SESSION['username'] ?? null;
}

function is_logged_in(): bool {
    return current_username() !== null;
}

function is_admin(): bool {
    if (session_status() !== PHP_SESSION_ACTIVE) return false;
    return (bool) ($_SESSION['is_admin'] ?? false);
}

function log_in_session(string $username, bool $isAdmin): void {
    session_regenerate_id(true);
    $_SESSION['username'] = $username;
    $_SESSION['is_admin']  = $isAdmin;
}

function log_out_session(): void {
    $_SESSION = [];
    if (ini_get('session.use_cookies')) {
        $params = session_get_cookie_params();
        setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
    }
    session_destroy();
}

function require_login(): void {
    if (!is_logged_in()) {
        header('Location: ?a=login');
        exit;
    }
}

function require_admin(): void {
    require_login();
    if (!is_admin()) {
        http_response_code(403);
        exit('Forbidden — admin only.');
    }
}

function csrf_token(): string {
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function check_csrf(): void {
    $token = $_POST['csrf'] ?? '';
    if (!is_string($token) || $token === '' || !hash_equals($_SESSION['csrf'] ?? '', $token)) {
        http_response_code(400);
        exit('Form expired or invalid (CSRF check failed) — go back and try again.');
    }
}