fix: harden CMS security controls
This commit is contained in:
@@ -2,33 +2,70 @@
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Manages session authentication, role capabilities, and CSRF tokens.
|
||||
*
|
||||
* Credentials remain configuration-backed to preserve NeoCMS's database-free design. Only hashes
|
||||
* created by password_hash() are accepted; plaintext credentials are rejected.
|
||||
*/
|
||||
class Authentication
|
||||
{
|
||||
/** Valid bcrypt hash used solely to equalise unknown-user password verification work. */
|
||||
private const DUMMY_PASSWORD_HASH = '$2y$10$NaiFDtsxcQzcxAiQrTmEPuDde8sDsdpjAlSgoiYwAvdnF5Cwytute';
|
||||
|
||||
/** Map of usernames to password hashes. */
|
||||
private array $credentials;
|
||||
|
||||
public function __construct(array $credentials)
|
||||
/** Map of usernames to editor, publisher, or administrator roles. */
|
||||
private array $roles;
|
||||
|
||||
/** Maximum permitted inactivity for an authenticated session. */
|
||||
private int $idleTimeout;
|
||||
|
||||
/** Maximum total lifetime of an authenticated session. */
|
||||
private int $absoluteTimeout;
|
||||
|
||||
/** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */
|
||||
private ?bool $cookieSecure;
|
||||
|
||||
/** Initialise secure session handling and retain the configured account maps. */
|
||||
public function __construct(array $credentials, array $roles = [], array $sessionOptions = [])
|
||||
{
|
||||
$this->startSession();
|
||||
$this->credentials = $credentials;
|
||||
$this->roles = $roles;
|
||||
$this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800));
|
||||
$this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200));
|
||||
$this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null;
|
||||
$this->startSession();
|
||||
$this->enforceSessionLifetime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the PHP session with conservative cookie settings.
|
||||
*
|
||||
* HTTPS cookies are marked Secure when the request is encrypted. SameSite=Lax protects common
|
||||
* cross-site request scenarios without making ordinary login redirects needlessly dramatic.
|
||||
*/
|
||||
private function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443);
|
||||
$isHttps = $this->cookieSecure ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_name('NEOCMSSESSID');
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'path' => '/',
|
||||
'path' => '/cms',
|
||||
'samesite' => 'Lax',
|
||||
'secure' => $isHttps,
|
||||
]);
|
||||
@@ -37,43 +74,84 @@ class Authentication
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a user into the CMS
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return bool
|
||||
* Authenticate a user and establish a fresh privileged session.
|
||||
*
|
||||
* @param mixed $username Submitted account name.
|
||||
* @param mixed $password Submitted plaintext password.
|
||||
*/
|
||||
public function login($username, $password)
|
||||
public function login($username, $password): bool
|
||||
{
|
||||
if ($this->credentialsAreValid((string) $username, (string) $password)) {
|
||||
$username = (string) $username;
|
||||
if ($this->credentialsAreValid($username, (string) $password)) {
|
||||
// Rotate the identifier at the privilege boundary to prevent session fixation.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['loggedIn'] = true;
|
||||
$_SESSION['loggedInUser'] = $username;
|
||||
$configuredRole = $this->roles[$username] ?? 'editor';
|
||||
$_SESSION['role'] = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
|
||||
? $configuredRole
|
||||
: 'editor';
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['authenticatedAt'] = time();
|
||||
$_SESSION['lastActivityAt'] = time();
|
||||
$_SESSION['lastRegeneratedAt'] = time();
|
||||
return true;
|
||||
}
|
||||
|
||||
$_SESSION['loggedIn'] = false;
|
||||
unset(
|
||||
$_SESSION['loggedIn'],
|
||||
$_SESSION['loggedInUser'],
|
||||
$_SESSION['role'],
|
||||
$_SESSION['credentialFingerprint'],
|
||||
$_SESSION['authenticatedAt'],
|
||||
$_SESSION['lastActivityAt'],
|
||||
$_SESSION['lastRegeneratedAt']
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has logged in
|
||||
* @return bool
|
||||
*/
|
||||
/** Return whether the current session belongs to an authenticated user. */
|
||||
public function isLoggedIn(): bool
|
||||
{
|
||||
return $_SESSION['loggedIn'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user logged in
|
||||
* @return string
|
||||
*/
|
||||
/** Return the current username, or a safe label when no user is authenticated. */
|
||||
public function getLoggedInUser(): string
|
||||
{
|
||||
return $_SESSION['loggedInUser'] ?? 'Not logged in';
|
||||
}
|
||||
|
||||
/** Return the session role, defaulting unauthorised sessions to the least powerful role. */
|
||||
public function getRole(): string
|
||||
{
|
||||
$username = $this->getLoggedInUser();
|
||||
$configuredRole = $this->roles[$username] ?? 'editor';
|
||||
$role = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
|
||||
? $configuredRole
|
||||
: 'editor';
|
||||
$_SESSION['role'] = $role;
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current role includes a named capability.
|
||||
*
|
||||
* Capability checks live here so controllers and upload handlers share one policy table.
|
||||
*/
|
||||
public function can(string $capability): bool
|
||||
{
|
||||
$permissions = [
|
||||
'editor' => ['draft', 'upload'],
|
||||
'publisher' => ['draft', 'upload', 'publish', 'schedule'],
|
||||
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
|
||||
];
|
||||
|
||||
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
|
||||
}
|
||||
|
||||
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
if (empty($_SESSION['csrfToken'])) {
|
||||
@@ -83,6 +161,7 @@ class Authentication
|
||||
return $_SESSION['csrfToken'];
|
||||
}
|
||||
|
||||
/** Compare a submitted CSRF token in constant time to avoid leaking useful timing information. */
|
||||
public function isValidCsrfToken(?string $token): bool
|
||||
{
|
||||
if (!is_string($token) || $token === '') {
|
||||
@@ -92,34 +171,85 @@ class Authentication
|
||||
return hash_equals($this->getCsrfToken(), $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the session
|
||||
* @return void
|
||||
*/
|
||||
public function logout()
|
||||
/** Clear session data, expire its cookie, and destroy the server-side session. */
|
||||
public function logout(): 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']);
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => $params['path'],
|
||||
'domain' => $params['domain'],
|
||||
'secure' => $params['secure'],
|
||||
'httponly' => $params['httponly'],
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted credentials against a password_hash() value.
|
||||
*/
|
||||
private function credentialsAreValid(string $username, string $password): bool
|
||||
{
|
||||
$storedCredential = $this->credentials[$username] ?? null;
|
||||
if (!is_string($storedCredential) || $storedCredential === '') {
|
||||
return false;
|
||||
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
|
||||
$configuredHashIsValid = is_string($storedCredential)
|
||||
&& $storedCredential !== ''
|
||||
&& ($passwordInfo['algoName'] ?? 'unknown') !== 'unknown';
|
||||
$verificationHash = $configuredHashIsValid ? $storedCredential : self::DUMMY_PASSWORD_HASH;
|
||||
$verified = password_verify($password, $verificationHash);
|
||||
return $configuredHashIsValid && $verified;
|
||||
}
|
||||
|
||||
/** Expire stale sessions and periodically rotate active session identifiers. */
|
||||
private function enforceSessionLifetime(): void
|
||||
{
|
||||
if (empty($_SESSION['loggedIn'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$passwordInfo = password_get_info($storedCredential);
|
||||
if (($passwordInfo['algo'] ?? null) !== null) {
|
||||
return password_verify($password, $storedCredential);
|
||||
$now = time();
|
||||
$username = is_string($_SESSION['loggedInUser'] ?? null) ? $_SESSION['loggedInUser'] : '';
|
||||
$storedCredential = $this->credentials[$username] ?? null;
|
||||
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
|
||||
if (!is_string($storedCredential) || ($passwordInfo['algoName'] ?? 'unknown') === 'unknown') {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
$fingerprint = hash('sha256', $storedCredential);
|
||||
if (isset($_SESSION['credentialFingerprint']) && !hash_equals((string) $_SESSION['credentialFingerprint'], $fingerprint)) {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
$_SESSION['credentialFingerprint'] = $fingerprint;
|
||||
|
||||
$_SESSION['authenticatedAt'] = (int) ($_SESSION['authenticatedAt'] ?? $now);
|
||||
$_SESSION['lastActivityAt'] = (int) ($_SESSION['lastActivityAt'] ?? $now);
|
||||
$_SESSION['lastRegeneratedAt'] = (int) ($_SESSION['lastRegeneratedAt'] ?? $now);
|
||||
|
||||
$idleExpired = $now - $_SESSION['lastActivityAt'] > $this->idleTimeout;
|
||||
$absoluteExpired = $now - $_SESSION['authenticatedAt'] > $this->absoluteTimeout;
|
||||
if ($idleExpired || $absoluteExpired) {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
return hash_equals($storedCredential, $password);
|
||||
if ($now - $_SESSION['lastRegeneratedAt'] > 900) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['lastRegeneratedAt'] = $now;
|
||||
}
|
||||
$_SESSION['lastActivityAt'] = $now;
|
||||
}
|
||||
|
||||
/** Clear privileged session state and rotate the identifier after expiry or account revocation. */
|
||||
private function invalidateSession(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
+923
-221
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Persists NeoCMS metadata as human-readable JSON and provides managed subdirectories.
|
||||
*
|
||||
* Public pages remain ordinary HTML files. This store contains only the supporting cast: drafts,
|
||||
* revisions, schedules, menus, media metadata, and activity. No database server need feel left out.
|
||||
*/
|
||||
final class FileStore
|
||||
{
|
||||
/** Absolute data-directory path, always ending with the platform separator. */
|
||||
private string $root;
|
||||
|
||||
/**
|
||||
* Prepare the configured data directory.
|
||||
*
|
||||
* @param string $root Absolute or project-relative storage path.
|
||||
*/
|
||||
public function __construct(string $root)
|
||||
{
|
||||
$this->root = rtrim($root, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$this->ensureDirectory($this->root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a named JSON document, returning the supplied default when it does not yet exist.
|
||||
* Invalid JSON is treated as absent data so callers always receive the promised array shape.
|
||||
*
|
||||
* @param string $name Logical document name without the .json suffix.
|
||||
* @param array $default Value returned for a missing or unreadable document.
|
||||
* @return array Decoded document contents.
|
||||
*/
|
||||
public function read(string $name, array $default = []): array
|
||||
{
|
||||
$path = $this->jsonPath($name);
|
||||
if (!is_file($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = json_decode((string) file_get_contents($path), true);
|
||||
return is_array($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace a named JSON document.
|
||||
*
|
||||
* Data is written to a temporary neighbour and renamed only after the write succeeds. Readers
|
||||
* therefore see either the old document or the complete new one, never half a JSON sandwich.
|
||||
*
|
||||
* @param string $name Logical document name without the .json suffix.
|
||||
* @param array $value Serializable document contents.
|
||||
*/
|
||||
public function write(string $name, array $value): void
|
||||
{
|
||||
$path = $this->jsonPath($name);
|
||||
$temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
$json = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || file_put_contents($temporary, $json . "\n", LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write CMS data');
|
||||
}
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $path)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('Unable to replace CMS data');
|
||||
}
|
||||
@chmod($path, 0600);
|
||||
}
|
||||
|
||||
/** Atomically write a private content file beneath a managed subdirectory. */
|
||||
public function writePrivateFile(string $directory, string $filename, string $content): string
|
||||
{
|
||||
$target = $this->directory($directory) . basename($filename);
|
||||
$temporary = $target . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
if (file_put_contents($temporary, $content, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write private CMS file');
|
||||
}
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $target)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('Unable to replace private CMS file');
|
||||
}
|
||||
@chmod($target, 0600);
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a managed subdirectory, creating it when first required.
|
||||
*
|
||||
* @param string $name Relative directory name such as drafts or revisions.
|
||||
*/
|
||||
public function directory(string $name): string
|
||||
{
|
||||
$path = $this->root . trim($name, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$this->ensureDirectory($path);
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Convert a logical document name into a safe JSON path beneath the data root. */
|
||||
private function jsonPath(string $name): string
|
||||
{
|
||||
return $this->root . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.json';
|
||||
}
|
||||
|
||||
/** Create a directory recursively or fail with a useful application-level exception. */
|
||||
private function ensureDirectory(string $path): void
|
||||
{
|
||||
if (!is_dir($path) && !mkdir($path, 0700, true) && !is_dir($path)) {
|
||||
throw new \RuntimeException('Unable to create CMS data directory');
|
||||
}
|
||||
@chmod($path, 0700);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Writes simple, daily, append-only audit logs for operational traceability. */
|
||||
class Logger
|
||||
{
|
||||
/** Whether audit writes are enabled by configuration. */
|
||||
private $auditEnabled;
|
||||
|
||||
/** Absolute directory containing the dated audit files. */
|
||||
private $logsDir;
|
||||
|
||||
public function __construct($auditEnabled)
|
||||
/** Maximum size of one active daily log before it is rotated. */
|
||||
private $maxFileBytes;
|
||||
|
||||
/** Number of days for which audit logs are retained. */
|
||||
private $retentionDays;
|
||||
|
||||
/**
|
||||
* Configure logging and ensure its destination exists.
|
||||
*
|
||||
* @param mixed $auditEnabled Truthy values enable audit output.
|
||||
* @param array<string, mixed> $security Security limits shared with the rest of the CMS.
|
||||
*/
|
||||
public function __construct($auditEnabled, array $security = [])
|
||||
{
|
||||
$this->auditEnabled = $auditEnabled;
|
||||
|
||||
// Conservative defaults prevent a forgotten audit directory from becoming its own archive service.
|
||||
$this->maxFileBytes = max(1024, (int) ($security['auditMaxFileBytes'] ?? 10 * 1024 * 1024));
|
||||
$this->retentionDays = max(1, (int) ($security['auditRetentionDays'] ?? 90));
|
||||
|
||||
$this->logsDir = __DIR__ . '/../../logs';
|
||||
$this->logsDir = rtrim($this->logsDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Ensure the logs directory exists
|
||||
// Create the directory lazily so a fresh installation needs no ceremonial folder setup.
|
||||
if (!is_dir($this->logsDir)) {
|
||||
if (!mkdir($this->logsDir, 0755, true)) {
|
||||
if (!mkdir($this->logsDir, 0700, true)) {
|
||||
throw new \Exception('Failed to create logs directory: ' . $this->logsDir);
|
||||
}
|
||||
}
|
||||
@chmod($this->logsDir, 0700);
|
||||
|
||||
if ($this->auditEnabled) {
|
||||
$this->pruneExpiredLogs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the log
|
||||
* @param $message
|
||||
* @param $user
|
||||
* @return void
|
||||
* Append a timestamped user action to today's audit file.
|
||||
*
|
||||
* @param mixed $message Human-readable action description.
|
||||
* @param mixed $user Username responsible for the action.
|
||||
*/
|
||||
public function write($message, $user): void
|
||||
{
|
||||
@@ -33,7 +59,9 @@ class Logger
|
||||
return;
|
||||
}
|
||||
|
||||
// One file per day keeps inspection and rotation pleasantly unsurprising.
|
||||
$logFile = $this->logsDir . date('Y-m-d') . '-audit.txt';
|
||||
$this->rotateFullLog($logFile);
|
||||
$entry = sprintf(
|
||||
"[%s]\tUser: %s\t%s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
@@ -41,9 +69,49 @@ class Logger
|
||||
$this->normaliseLogField($message)
|
||||
);
|
||||
|
||||
file_put_contents($logFile, $entry, FILE_APPEND);
|
||||
if (file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write audit log');
|
||||
}
|
||||
@chmod($logFile, 0600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a full daily log aside before appending another entry.
|
||||
*
|
||||
* @param string $logFile Absolute path to today's active log.
|
||||
*/
|
||||
private function rotateFullLog(string $logFile): void
|
||||
{
|
||||
clearstatcache(true, $logFile);
|
||||
if (!is_file($logFile) || (int) filesize($logFile) < $this->maxFileBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rotated = $this->logsDir . date('Y-m-d-His') . '-' . bin2hex(random_bytes(3)) . '-audit.txt';
|
||||
if (!rename($logFile, $rotated)) {
|
||||
throw new \RuntimeException('Unable to rotate audit log');
|
||||
}
|
||||
@chmod($rotated, 0600);
|
||||
}
|
||||
|
||||
/** Remove audit files older than the configured retention period. */
|
||||
private function pruneExpiredLogs(): void
|
||||
{
|
||||
$cutoff = time() - ($this->retentionDays * 86400);
|
||||
$logFiles = glob($this->logsDir . '*-audit.txt') ?: [];
|
||||
|
||||
foreach ($logFiles as $logFile) {
|
||||
if (is_file($logFile) && (int) filemtime($logFile) < $cutoff && !unlink($logFile)) {
|
||||
throw new \RuntimeException('Unable to remove expired audit log');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove control characters that could forge extra audit entries or columns.
|
||||
*
|
||||
* @param mixed $value Value destined for one tab-separated log field.
|
||||
*/
|
||||
private function normaliseLogField($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Provides filesystem-backed throttling for failed login attempts.
|
||||
*
|
||||
* Limits are applied to both the source address and the source-address/username pair. This slows
|
||||
* password guessing without allowing one remote address to lock an account for everybody else.
|
||||
*/
|
||||
final class LoginRateLimiter
|
||||
{
|
||||
private string $path;
|
||||
private int $windowSeconds;
|
||||
private int $lockoutSeconds;
|
||||
private int $maxIdentityAttempts;
|
||||
private int $maxAddressAttempts;
|
||||
|
||||
/** Configure the protected state file and throttling thresholds. */
|
||||
public function __construct(string $dataDirectory, array $options = [])
|
||||
{
|
||||
$directory = rtrim($dataDirectory, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException('Unable to create login security directory');
|
||||
}
|
||||
@chmod($directory, 0700);
|
||||
|
||||
$this->path = $directory . 'login-attempts.json';
|
||||
$this->windowSeconds = max(60, (int) ($options['loginWindowSeconds'] ?? 900));
|
||||
$this->lockoutSeconds = max(60, (int) ($options['loginLockoutSeconds'] ?? 900));
|
||||
$this->maxIdentityAttempts = max(1, (int) ($options['loginMaxAttempts'] ?? 5));
|
||||
$this->maxAddressAttempts = max($this->maxIdentityAttempts, (int) ($options['loginMaxAddressAttempts'] ?? 20));
|
||||
}
|
||||
|
||||
/** Return the remaining lockout in seconds, or zero when another attempt is permitted. */
|
||||
public function retryAfter(string $address, string $username): int
|
||||
{
|
||||
return $this->withState(function (array &$state) use ($address, $username): int {
|
||||
$now = time();
|
||||
$this->prune($state, $now);
|
||||
$retryAt = max(
|
||||
(int) ($state[$this->addressKey($address)]['locked_until'] ?? 0),
|
||||
(int) ($state[$this->identityKey($address, $username)]['locked_until'] ?? 0)
|
||||
);
|
||||
return max(0, $retryAt - $now);
|
||||
});
|
||||
}
|
||||
|
||||
/** Record one denied login and lock a bucket once its configured threshold is reached. */
|
||||
public function recordFailure(string $address, string $username): void
|
||||
{
|
||||
$this->withState(function (array &$state) use ($address, $username): void {
|
||||
$now = time();
|
||||
$this->prune($state, $now);
|
||||
$this->increment($state, $this->addressKey($address), $this->maxAddressAttempts, $now);
|
||||
$this->increment($state, $this->identityKey($address, $username), $this->maxIdentityAttempts, $now);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear successful login buckets so typographical mistakes do not linger unnecessarily. */
|
||||
public function clear(string $address, string $username): void
|
||||
{
|
||||
$this->withState(function (array &$state) use ($address, $username): void {
|
||||
unset($state[$this->addressKey($address)], $state[$this->identityKey($address, $username)]);
|
||||
$this->prune($state, time());
|
||||
});
|
||||
}
|
||||
|
||||
/** Update one attempt bucket and begin a fixed lockout when its threshold is reached. */
|
||||
private function increment(array &$state, string $key, int $limit, int $now): void
|
||||
{
|
||||
$record = $state[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$record['attempts'][] = $now;
|
||||
if (count($record['attempts']) >= $limit) {
|
||||
$record['locked_until'] = $now + $this->lockoutSeconds;
|
||||
$record['attempts'] = [];
|
||||
}
|
||||
$state[$key] = $record;
|
||||
}
|
||||
|
||||
/** Remove expired attempts and stale unlocked buckets to keep the state file bounded. */
|
||||
private function prune(array &$state, int $now): void
|
||||
{
|
||||
$cutoff = $now - $this->windowSeconds;
|
||||
foreach ($state as $key => &$record) {
|
||||
$record['attempts'] = array_values(array_filter(
|
||||
is_array($record['attempts'] ?? null) ? $record['attempts'] : [],
|
||||
static fn($timestamp): bool => is_int($timestamp) && $timestamp >= $cutoff
|
||||
));
|
||||
$record['locked_until'] = (int) ($record['locked_until'] ?? 0);
|
||||
if ($record['attempts'] === [] && $record['locked_until'] <= $now) {
|
||||
unset($state[$key]);
|
||||
}
|
||||
}
|
||||
unset($record);
|
||||
}
|
||||
|
||||
/** Serialise state updates beneath an exclusive lock to avoid concurrent lost updates. */
|
||||
private function withState(callable $callback)
|
||||
{
|
||||
$handle = fopen($this->path, 'c+');
|
||||
if ($handle === false || !flock($handle, LOCK_EX)) {
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
throw new \RuntimeException('Unable to access login security state');
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
|
||||
try {
|
||||
rewind($handle);
|
||||
$decoded = json_decode((string) stream_get_contents($handle), true);
|
||||
$state = is_array($decoded) ? $decoded : [];
|
||||
$result = $callback($state);
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Unable to encode login security state');
|
||||
}
|
||||
rewind($handle);
|
||||
if (!ftruncate($handle, 0) || fwrite($handle, $json . "\n") === false || !fflush($handle)) {
|
||||
throw new \RuntimeException('Unable to write login security state');
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
return $result;
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash address data so the throttling file does not become a convenient IP-address register. */
|
||||
private function addressKey(string $address): string
|
||||
{
|
||||
return 'address:' . hash('sha256', $address);
|
||||
}
|
||||
|
||||
/** Hash the combined address and case-normalised username for the tighter identity bucket. */
|
||||
private function identityKey(string $address, string $username): string
|
||||
{
|
||||
return 'identity:' . hash('sha256', $address . "\0" . strtolower($username));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Applies consistent browser security and cache-control headers to CMS responses. */
|
||||
final class SecurityHeaders
|
||||
{
|
||||
/** Protect an HTML administration page, optionally allowing the pinned UI CDNs. */
|
||||
public static function html(bool $allowUiCdns = false, ?bool $secureTransport = null): void
|
||||
{
|
||||
self::common($secureTransport);
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$scriptSources = $allowUiCdns
|
||||
? "'self' https://code.jquery.com"
|
||||
: "'self'";
|
||||
$styleSources = $allowUiCdns
|
||||
? "'self' 'unsafe-inline' https://code.jquery.com"
|
||||
: "'self'";
|
||||
|
||||
header("Content-Security-Policy: default-src 'self'; script-src {$scriptSources}; style-src {$styleSources}; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
}
|
||||
|
||||
/** Protect JSON API responses and prevent authenticated data from being cached. */
|
||||
public static function json(?bool $secureTransport = null): void
|
||||
{
|
||||
self::common($secureTransport);
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
/** Emit headers shared by both document and API responses. */
|
||||
private static function common(?bool $secureTransport): void
|
||||
{
|
||||
header_remove('X-Powered-By');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
$isHttps = $secureTransport ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
if ($isHttps) {
|
||||
header('Strict-Transport-Security: max-age=31536000');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user