Update version to 0.1.8 and enhance rate limiting and CSRF protection features
This commit is contained in:
1 parent
2cb6510aeb
commit
97f5f35b1c
4 files changed
+80
-25
No files matched your search
+39
-15
@@ -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 = '~<form\b[^>]*>(?:(?!</form>).)*?name=["\']password["\'](?:(?!</form>).)*?</form>~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('~</form>~i', '<input type="hidden" name="secureCsrf" value="'.$this->e($_SESSION['bs']['csrf']).'"></form>', $match[0], 1);
|
||||
return $extra.$form.$sso;
|
||||
}, $html, 1, $count);
|
||||
@@ -196,7 +215,7 @@ final class App
|
||||
return '<h2 class="h5 mb-3">Two-step verification</h2>'.$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);
|
||||
}
|
||||
|
||||
+22
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user