diff --git a/README.md b/README.md index b04a370..4bf2a9b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ OIDC SSO, TOTP and passkey second-factor authentication for **Bludit 3.22.0**. -Current version: **0.1.6 — experimental**. Test on a copy of your site before deployment. +Current version: **0.1.8 — experimental**. Test on a copy of your site before deployment. ## Features @@ -55,9 +55,9 @@ For `bludit/docker`, copy the extracted plugin into `/usr/share/nginx/html/bl-pl docker cp bludit:/usr/share/nginx/html/bl-plugins/bludit-secure \ "/tmp/bludit-secure-backup-$(date +%Y%m%d-%H%M%S)" -unzip -o /tmp/bludit-secure-0.1.6.zip -d /tmp/bludit-secure-0.1.6 +unzip -o /tmp/bludit-secure-0.1.8.zip -d /tmp/bludit-secure-0.1.8 -docker cp /tmp/bludit-secure-0.1.6/bludit-secure/. \ +docker cp /tmp/bludit-secure-0.1.8/bludit-secure/. \ bludit:/usr/share/nginx/html/bl-plugins/bludit-secure/ docker restart bludit @@ -110,7 +110,7 @@ Back up `secure.key.php`, `secure.data.php` and the Bludit user database togethe The encryption key and encrypted database are created together. If either file subsequently disappears while the other remains, authentication fails closed; restore the matching pair from backup. A legacy installation containing a key but no database also requires manual recovery. The plugin cannot distinguish deletion of both files from a fresh installation. -Incomplete sign-ins expire after five minutes; WebAuthn challenges expire after two minutes. Persistent verification budgets allow ten attempts per account and IP over five minutes, with separate budgets for management and enrollment. The plugin checks authentication before Bludit dispatches admin AJAX requests. +Incomplete sign-ins expire after five minutes; WebAuthn challenges expire after two minutes. Persistent verification budgets allow ten failed or in-flight checks per account and IP over five minutes. Password checks have their own budget; TOTP, recovery codes and passkey assertions share a separate MFA budget. Successful checks refund their own reservation without clearing previous failures. A successful password check never resets an MFA lockout. Passkey challenge requests, enrollment challenge requests, SSO starts and authenticated management each have separate limits of 60 requests per account and IP over five minutes. Passkey enrollment verification has its own ten-attempt budget and also refunds successes. Cancelling a passkey prompt consumes only a challenge request, not an MFA verification attempt. The plugin checks authentication before Bludit dispatches admin AJAX requests. Bludit's native IP blacklist is also respected. If all factors are lost, use a recovery code. Without one, a server administrator can temporarily move `bl-plugins/bludit-secure/` **outside `bl-plugins`** to restore native login. Restrict admin access during this maintenance operation, then restore the plugin and repair registrations. Uninstalling the plugin through Bludit deletes its data; it is not a temporary disable operation. @@ -118,6 +118,20 @@ The plugin protects **web sessions under `/admin`**. It does not change authenti JavaScript URLs include a content hash so updated passkey behavior is not hidden by stale asset caches. +## Changes in 0.1.8 + +- Prevents successful sign-ins and multiple protocol steps from accumulating in one shared authentication quota. +- Separates password checks, MFA checks and passkey challenge issuance; refunds successful credential verification while preserving failures and concurrent reservations. +- Retains a shared MFA failure budget across TOTP, recovery codes and passkey assertions to prevent bypass by switching methods. +- Gives passkey rate limits an explicit HTTP 429 response and retry message. +- Raises the separate SSO-start, challenge and authenticated-management request limits to 60 per five minutes. + +## Changes in 0.1.7 + +- Fixes an Invalid CSRF error when another login tab regenerates Bludit's native token, including during sign-in after password revocation. +- Keeps CSRF validation on every password submission, using the plugin's session token for enhanced forms and the native token for unenhanced custom themes. +- Explains how to retry when a form's session has genuinely expired. + ## Changes in 0.1.6 - Adds SSO link status, provider, username and email to the native administrator Users table. diff --git a/metadata.json b/metadata.json index 4c0ef32..e924655 100644 --- a/metadata.json +++ b/metadata.json @@ -1 +1 @@ -{"author":"Interlope","version":"0.1.6","releaseDate":"2026-11-03","license":"MIT","compatible":"3.22.0","notes":"PHP 8.2+, HTTPS, sodium, curl, mbstring. Preview: validate on staging before production."} +{"author":"Interlope","version":"0.1.8","releaseDate":"2026-11-03","license":"MIT","compatible":"3.22.0","notes":"PHP 8.2+, HTTPS, sodium, curl, mbstring. Preview: validate on staging before production."} diff --git a/src/App.php b/src/App.php index 3eaa38b..bab6175 100644 --- a/src/App.php +++ b/src/App.php @@ -2,6 +2,7 @@ namespace BluditSecure; final class UserError extends \RuntimeException {} +final class RateLimitError extends \RuntimeException {} final class App { @@ -38,6 +39,18 @@ final class App private function csrf(): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !hash_equals($_SESSION['bs']['csrf'], (string)($_POST['secureCsrf'] ?? ''))) { throw new UserError('Invalid CSRF'); } } + private function passwordCsrf(bool $nativeForm): void { + global $security; + $token = $_POST['secureCsrf'] ?? null; + // The core rotates tokenCSRF on every login-page GET, including other tabs. + // Prefer our session-bound token; an explicitly invalid plugin token never falls back. + $valid = is_string($token) && hash_equals($_SESSION['bs']['csrf'], $token); + if ($nativeForm && !array_key_exists('secureCsrf', $_POST)) { + $nativeToken = $_POST['tokenCSRF'] ?? null; + $valid = is_string($nativeToken) && $security->validateTokenCSRF($nativeToken); + } + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !$valid) { throw new UserError('Sign-in form expired. Please reload the page and enter your password again.'); } + } private function user(string $name): array { global $users; $u = $users->getUserDB($name); @@ -107,9 +120,12 @@ final class App if ($this->store->get($p['name'])['revision'] !== $p['revision'] || ($p['userStamp'] ?? '') !== $this->userStamp($user)) { unset($_SESSION['bs']['pending']); throw new UserError('Account or authentication methods have changed. Please sign in again.'); } return $p; } - private function budget(string $name, string $scope = 'auth'): void { + private function budget(string $name, string $scope = 'auth', int $limit = 10): array { global $security; - if ($security->isBlocked() || !$this->store->attempt($scope.':'.$name, $scope.':'.($_SERVER['REMOTE_ADDR'] ?? 'unknown'))) { throw new UserError('Too many attempts. Try again in five minutes.'); } + if ($security->isBlocked()) { throw new RateLimitError('Too many attempts. Try again in five minutes.'); } + $reservation = $this->store->beginAttempt($scope.':'.$name, $scope.':'.($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $limit); + if ($reservation === null) { throw new RateLimitError('Too many attempts. Try again in five minutes.'); } + return $reservation; } public function run(): void { global $login, $security; @@ -129,12 +145,11 @@ final class App try { if ($action === 'oidc') { $this->oidc(); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { - if ($action === '' && !$name) { - if (!$security->validateTokenCSRF((string)($_POST['tokenCSRF'] ?? ''))) { throw new UserError('Invalid CSRF'); } + if (($action === '' && !$name) || $action === 'password') { + $this->passwordCsrf($action === ''); $this->password(); } $this->csrf(); - if ($action === 'password') { $this->password(); } if ($action === 'logout') { unset($_SESSION['bs']); $login->logout(); $this->go(); } if ($action === 'sso' || $action === 'link') { $this->startOidc($action === 'link'); } if ($action === 'mfa') { $this->verifyCode(); } @@ -148,9 +163,12 @@ final class App } catch (\Throwable $e) { // Provider exceptions can contain tokens or response bodies. error_log('bludit-secure: '.get_class($e)); - $msg = $e instanceof UserError ? $e->getMessage() : 'Verification failed. Try again or check the server logs.'; + $msg = $e instanceof UserError || $e instanceof RateLimitError ? $e->getMessage() : 'Verification failed. Try again or check the server logs.'; // Never expose protocol payloads, tokens or secrets to the browser. - if (str_starts_with($action, 'key-')) { $this->json(['error'=>'Verification failed. Sign in again or retry.'], 400); } + if (str_starts_with($action, 'key-')) { + if ($e instanceof RateLimitError) { header('Retry-After: 300'); $this->json(['error'=>$msg], 429); } + $this->json(['error'=>'Verification failed. Sign in again or retry.'], 400); + } $_SESSION['bs']['notice'] = mb_substr($msg, 0, 200); $this->go($this->authenticated() ? 'account' : (isset($_SESSION['bs']['pending']) ? 'mfa' : '')); } @@ -158,9 +176,10 @@ final class App private function password(): void { global $users; $name = trim((string)($_POST['username'] ?? '')); $pw = (string)($_POST['password'] ?? ''); - $this->budget($name); + $attempt = $this->budget($name, 'password'); $u = $users->getUserDB($name); if (!$u || $u['password'] === '!' || !hash_equals($u['password'], $users->generatePasswordHash($pw, $u['salt']))) { throw new UserError('Incorrect username or password.'); } + $this->store->releaseAttempt($attempt); $this->pending($name, 'password', null, $this->userStamp($u)); } /** Enhance the native login form in place; keep the theme, branding and layout. */ @@ -179,7 +198,7 @@ final class App $pattern = '~]*>(?:(?!).)*?name=["\']password["\'](?:(?!).)*?~is'; $result = preg_replace_callback($pattern, function ($match) use ($pending, $extra, $sso) { if ($pending) { return $extra; } - // Non-visual token for plugin endpoints; native password submission still validates tokenCSRF. + // Stable login CSRF token; loading another native login tab must not invalidate this form. $form = preg_replace('~~i', '', $match[0], 1); return $extra.$form.$sso; }, $html, 1, $count); @@ -196,7 +215,7 @@ final class App return '

Two-step verification

'.$body; } private function verifyCode(): never { - $p = $this->pendingUser(); $this->budget($p['name']); + $p = $this->pendingUser(); $attempt = $this->budget($p['name'], 'mfa'); $ok = $this->store->update($p['name'], function (&$a) use ($p) { if ($a['revision'] !== $p['revision']) { throw new UserError('Account changed. Please sign in again.'); } $recovery = trim((string)($_POST['recovery'] ?? '')); @@ -209,6 +228,7 @@ final class App if ($step === null) { return false; } $a['lastStep'] = $step; return true; }); if (!$ok) { throw new UserError('This code is invalid, expired or has already been used.'); } + $this->store->releaseAttempt($attempt); $this->finish($p['name']); } public function accountView(): string { @@ -277,7 +297,7 @@ final class App return $body; } private function manage(): never { - $name = $this->recent(); $this->budget($name, 'manage'); $op = (string)($_POST['op'] ?? ''); + $name = $this->recent(); $this->budget($name, 'manage', 60); $op = (string)($_POST['op'] ?? ''); $expectedRevision = $_SESSION['bs']['auth']['revision']; if ($op === 'totp-start') { $_SESSION['bs']['enroll'] = ['name'=>$name, 'secret'=>Totp::secret(), 'expires'=>time()+300]; $this->go('account'); @@ -326,7 +346,7 @@ final class App } private function startOidc(bool $link): never { $name = $link ? $this->recent() : null; - $this->budget(hash('sha256', session_id()), 'oidc'); + $this->budget(hash('sha256', session_id()), 'oidc', 60); $_SESSION['bs']['oidc'] = ['link'=>$name, 'expires'=>time()+300, 'config'=>$this->oidcConfigStamp(), 'revision'=>$name !== null ? $_SESSION['bs']['auth']['revision'] : null]; unset($_SESSION['bs']['pending']); $this->oidcClient()->authenticateRequest([]); @@ -361,7 +381,7 @@ final class App foreach ($d['accounts'] as $name=>$a) { if ($a['sso'] === $identity) { return ['name'=>$name, 'revision'=>$a['revision']]; } } return null; }); if ($match === null) { throw new UserError('This SSO identity is not linked to a Bludit account. Sign in with your password to link it.'); } - $this->budget($match['name']); $this->pending((string)$match['name'], 'sso', $match['revision'], null, $profile); + $this->pending((string)$match['name'], 'sso', $match['revision'], null, $profile); exit; } private function ssoProfile(OidcClient $oidc): array { @@ -421,11 +441,13 @@ final class App $create = in_array($action, ['key-create-options','key-create-finish'], true); if (!in_array($action, ['key-create-options','key-create-finish','key-get-options','key-get-finish'], true)) { throw new UserError('Unknown action'); } $name = $create ? $this->recent() : $this->pendingUser()['name']; - $this->budget($name, $create ? 'enroll' : 'auth'); $a = $this->store->get($name); + $options = str_ends_with($action, '-options'); + $scope = $options ? ($create ? 'enroll-options' : 'passkey-options') : ($create ? 'enroll' : 'mfa'); + $attempt = $this->budget($name, $scope, $options ? 60 : 10); $a = $this->store->get($name); if ($create && $a['revision'] !== $_SESSION['bs']['auth']['revision']) { throw new UserError('Account changed. Please sign in again.'); } if (!$create && (!$this->allowed($a, 'passkey') || !$a['keys'])) { throw new UserError('Passkey unavailable'); } $wa = new \lbuchs\WebAuthn\WebAuthn('Bludit', parse_url($this->origin, PHP_URL_HOST), ['none'], true); - if (str_ends_with($action, '-options')) { + if ($options) { $ids = array_map(fn($id)=>self::unb64($id), array_keys($a['keys'])); if ($create) { if (count($ids) >= 20) { throw new UserError('Maximum 20 passkeys'); } @@ -450,6 +472,7 @@ final class App ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision']; }); $_SESSION['bs']['auth']['revision'] = $revision; + $this->store->releaseAttempt($attempt); $this->json(['redirect'=>$this->accountUrl()]); } $id = (string)($_POST['id'] ?? ''); @@ -460,6 +483,7 @@ final class App $wa->processGet($client, self::unb64((string)($_POST['authenticatorData'] ?? '')), self::unb64((string)($_POST['signature'] ?? '')), $key['publicKey'], $challenge, $key['counter'], true, true); $a['keys'][$id]['counter'] = $wa->getSignatureCounter() ?? $key['counter']; }); + $this->store->releaseAttempt($attempt); // Finish through an ordinary navigation so the browser receives regenerated session cookies. $this->finishJson($name); } diff --git a/src/Store.php b/src/Store.php index 815a9f8..aaaa9b6 100644 --- a/src/Store.php +++ b/src/Store.php @@ -73,18 +73,35 @@ final class Store $a = self::account($d, $name); $result = $fn($a); $d['accounts'][$name] = $a; return $result; }); } - /** Persistent per-account + per-IP budget, independent of sessions. */ - public function attempt(string $name, string $ip): bool { - return $this->transaction(function (&$d) use ($name, $ip) { + /** Reserve before checking credentials so concurrent failures cannot exceed the budget. */ + public function beginAttempt(string $name, string $ip, int $limit = 10): ?array { + if ($limit < 1) { throw new \InvalidArgumentException('Invalid attempt limit'); } + return $this->transaction(function (&$d) use ($name, $ip, $limit) { $now = time(); foreach ($d['limits'] as $k=>$v) { if ($v['until'] <= $now) { unset($d['limits'][$k]); } } $ids = [hash('sha256', 'account:'.$name), hash('sha256', 'ip:'.$ip)]; - foreach ($ids as $id) { if (($d['limits'][$id]['count'] ?? 0) >= 10) { return false; } } + foreach ($ids as $id) { if (($d['limits'][$id]['count'] ?? 0) >= $limit) { return null; } } + $reservation = []; foreach ($ids as $id) { $d['limits'][$id] ??= ['count'=>0, 'until'=>$now+300]; + $d['limits'][$id]['window'] ??= bin2hex(random_bytes(8)); ++$d['limits'][$id]['count']; + $reservation[$id] = $d['limits'][$id]['window']; } - return true; + return $reservation; }); } + /** Refund only this successful attempt, preserving other accounts' or requests' failures. */ + public function releaseAttempt(array $reservation): void { + $this->transaction(function (&$d) use ($reservation) { + foreach ($reservation as $id=>$window) { + if (($d['limits'][$id]['window'] ?? null) !== $window) { continue; } + if (--$d['limits'][$id]['count'] <= 0) { unset($d['limits'][$id]); } + } + }); + } + /** Throttle non-verification work without refunding successful requests. */ + public function attempt(string $name, string $ip, int $limit = 10): bool { + return $this->beginAttempt($name, $ip, $limit) !== null; + } }