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>
75 lines
2.1 KiB
PHP
75 lines
2.1 KiB
PHP
<?php
|
|
|
|
namespace Lib;
|
|
|
|
/**
|
|
* Session-token CSRF protection, standalone from Lib\FormValidator — a
|
|
* sidecar calls Csrf::verify() directly, typically before running any other
|
|
* validation:
|
|
*
|
|
* if (!Csrf::verify(Input::post('csrf_token'))) {
|
|
* return Response::redirect('/contact?error=security');
|
|
* }
|
|
*
|
|
* and the template renders a hidden field for it:
|
|
*
|
|
* <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
|
*
|
|
* This was the first thing in the framework to start a native PHP session
|
|
* — but only lazily, the moment token()/verify() is actually called. A page
|
|
* that never touches Csrf never gets a session cookie. Lib\Session and
|
|
* App\AdminAuth (session-based admin login) now touch the same native
|
|
* session the same lazy way — safe in any order, since ensureSession()
|
|
* no-ops when a session is already active.
|
|
*/
|
|
final class Csrf
|
|
{
|
|
public const FIELD_NAME = 'csrf_token';
|
|
private const SESSION_KEY = '_csrf_token';
|
|
|
|
public static function token(): string
|
|
{
|
|
self::ensureSession();
|
|
|
|
if (empty($_SESSION[self::SESSION_KEY])) {
|
|
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
return $_SESSION[self::SESSION_KEY];
|
|
}
|
|
|
|
public static function verify(?string $submittedToken): bool
|
|
{
|
|
self::ensureSession();
|
|
|
|
$expected = $_SESSION[self::SESSION_KEY] ?? null;
|
|
|
|
if ($submittedToken === null || $expected === null) {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals($expected, $submittedToken);
|
|
}
|
|
|
|
public static function fieldName(): string
|
|
{
|
|
return self::FIELD_NAME;
|
|
}
|
|
|
|
private static function ensureSession(): void
|
|
{
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|