b882c304b1
Admin login & user management (novaconium/ISSUES.md): session-based
login against a SQLite users table replaces the single-user HTTP Basic
Auth stopgap (admin_username/admin_password_hash and /admin/password-hash
are gone; one admin_auth_enabled flag, off by default with zero DB
footprint). New /admin/login, /admin/logout (POST-only, real page), and
/admin/users pages plus bin/create-admin-user.php.
First user created is the admin; everyone after is registered with a
unique normalized email and an optional group. /admin/* and drafts are
admin-only; Lib\Access gates page content from sidecars
(Access::require('group:members')) with login-redirect/404 responses —
public by default, static pages always public by construction. User
management covers disable/enable, delete, promote/demote, group, email,
and password, with last-active-admin lockout guards.
Also: Session::regenerate() against fixation, friendly missing-PDO-driver
errors in Lib\Db, docs at /admin/docs/access-control and updates across
admin-auth/drafts/sidecars/config/libraries and README/AGENTS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
129 lines
4.4 KiB
PHP
129 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace Lib;
|
|
|
|
/**
|
|
* A thin wrapper around PHP's native session handling — session_start()
|
|
* etc., not a custom session store — so sidecars have a consistent
|
|
* get/set/flash API instead of touching $_SESSION directly. All-static,
|
|
* lazy-start like Lib\Csrf: nothing calls session_start() until the first
|
|
* real call to any method here, so a page that never touches Session (or
|
|
* Csrf, which starts a session the same way) never gets a session cookie.
|
|
*
|
|
* ensureSession()'s body is deliberately duplicated from Csrf::ensureSession()
|
|
* rather than extracted into a shared helper — keeps Csrf standalone with
|
|
* zero new dependencies rather than coupling it to a class that didn't
|
|
* exist when it shipped, consistent with this project's tolerance for
|
|
* small duplication over premature coupling (see the config-load block
|
|
* duplicated across bootstrap.php/bin/clear-cache.php/Lib\Db::config()).
|
|
* Both classes touching the same native session in the same request is
|
|
* safe either way — session_status() guards against a double session_start().
|
|
*
|
|
* Flash data: a value set now via flash() is readable via getFlash() on
|
|
* exactly the next request, then gone — for post/redirect/GET flows like
|
|
* "message sent" banners, without a query-string flag. See
|
|
* /admin/docs/session for the mechanism and a worked example.
|
|
*/
|
|
final class Session
|
|
{
|
|
private const FLASH_KEY = '_flash';
|
|
|
|
private static bool $flashLoaded = false;
|
|
|
|
/** @var array<string, mixed> */
|
|
private static array $currentFlash = [];
|
|
|
|
public static function get(string $key, mixed $default = null): mixed
|
|
{
|
|
self::ensureSession();
|
|
|
|
return $_SESSION[$key] ?? $default;
|
|
}
|
|
|
|
public static function set(string $key, mixed $value): void
|
|
{
|
|
self::ensureSession();
|
|
|
|
$_SESSION[$key] = $value;
|
|
}
|
|
|
|
public static function has(string $key): bool
|
|
{
|
|
self::ensureSession();
|
|
|
|
return isset($_SESSION[$key]);
|
|
}
|
|
|
|
public static function remove(string $key): void
|
|
{
|
|
self::ensureSession();
|
|
|
|
unset($_SESSION[$key]);
|
|
}
|
|
|
|
/**
|
|
* Swaps the session id for a fresh one, keeping the session's data.
|
|
* Call on any privilege change — after a successful login, and on
|
|
* logout — so a session id an attacker planted or observed before the
|
|
* change is worthless after it (session fixation). App\AdminAuth does
|
|
* exactly this.
|
|
*/
|
|
public static function regenerate(): void
|
|
{
|
|
self::ensureSession();
|
|
|
|
session_regenerate_id(true);
|
|
}
|
|
|
|
/**
|
|
* Stores $value so it's readable via getFlash($key) on the next
|
|
* request only, then gone — regardless of whether getFlash() was
|
|
* actually called on that next request.
|
|
*/
|
|
public static function flash(string $key, mixed $value): void
|
|
{
|
|
self::ensureSession();
|
|
|
|
$_SESSION[self::FLASH_KEY][$key] = $value;
|
|
}
|
|
|
|
/**
|
|
* Reads a value flashed on the previous request. Never reflects a
|
|
* value flashed during this same request — that value will be
|
|
* readable on the next request instead.
|
|
*/
|
|
public static function getFlash(string $key, mixed $default = null): mixed
|
|
{
|
|
self::ensureSession();
|
|
|
|
return self::$currentFlash[$key] ?? $default;
|
|
}
|
|
|
|
private static function ensureSession(): void
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
// Must be called before session_start() — after is a silent no-op.
|
|
session_set_cookie_params([
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
|
]);
|
|
|
|
session_start();
|
|
}
|
|
|
|
// Runs once per request, on whichever Session method is called
|
|
// first: snapshot last request's flash bucket for this request's
|
|
// getFlash() reads, then immediately reset the session's bucket so
|
|
// flash() calls made during this request go to a fresh bucket —
|
|
// the one the *next* request will snapshot. This single swap is
|
|
// the entire flash mechanism; no separate expiry/sweep step needed,
|
|
// since static properties don't persist across requests.
|
|
if (!self::$flashLoaded) {
|
|
self::$currentFlash = $_SESSION[self::FLASH_KEY] ?? [];
|
|
$_SESSION[self::FLASH_KEY] = [];
|
|
self::$flashLoaded = true;
|
|
}
|
|
}
|
|
}
|