Files
novaconium/novaconium/lib/Session.php
T
code b882c304b1 Replace Basic Auth with multi-user login, roles, groups, and Lib\Access
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>
2026-07-15 00:17:54 +00:00

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