MFA: Added verify attempt rate limiting

This commit is contained in:
Dan Brown
2026-05-21 12:27:38 +01:00
parent 1b9ec75903
commit ef82119226
7 changed files with 135 additions and 2 deletions

View File

@@ -22,4 +22,10 @@ trait HandlesPartialLogins
return $user;
}
protected function clearLastAttemptedUser(): void
{
$loginService = app()->make(LoginService::class);
$loginService->clearLastLoginAttempted();
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Access\LoginService;
use BookStack\Access\Mfa\BackupCodeService;
use BookStack\Access\Mfa\MfaSession;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\Mfa\MfaVerificationLimiter;
use BookStack\Activity\ActivityType;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
@@ -19,6 +20,11 @@ class MfaBackupCodesController extends Controller
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes';
public function __construct(
protected MfaVerificationLimiter $limiter,
) {
}
/**
* Show a view that generates and displays backup codes.
*/
@@ -71,6 +77,12 @@ class MfaBackupCodesController extends Controller
public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService)
{
$user = $this->currentOrLastAttemptedUser();
$this->limiter->incrementAttempts($user, $request);
if ($this->limiter->hasHitLimit($user, $request)) {
$this->clearLastAttemptedUser();
$this->limiter->throwException();
}
$codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]';
$this->validate($request, [
@@ -89,6 +101,7 @@ class MfaBackupCodesController extends Controller
$mfaSession->markVerifiedForUser($user);
$loginService->reattemptLoginFor($user);
$this->limiter->decrementAttempts($user, $request);
if ($codeService->countCodesInSet($updatedCodes) < 5) {
$this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning'));

View File

@@ -5,6 +5,7 @@ namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Access\Mfa\MfaSession;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\Mfa\MfaVerificationLimiter;
use BookStack\Access\Mfa\TotpService;
use BookStack\Access\Mfa\TotpValidationRule;
use BookStack\Activity\ActivityType;
@@ -20,7 +21,8 @@ class MfaTotpController extends Controller
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
public function __construct(
protected TotpService $totp
protected TotpService $totp,
protected MfaVerificationLimiter $limiter,
) {
}
@@ -86,6 +88,12 @@ class MfaTotpController extends Controller
public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession)
{
$user = $this->currentOrLastAttemptedUser();
$this->limiter->incrementAttempts($user, $request);
if ($this->limiter->hasHitLimit($user, $request)) {
$this->clearLastAttemptedUser();
$this->limiter->throwException();
}
$totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP);
$this->validate($request, [
@@ -98,6 +106,7 @@ class MfaTotpController extends Controller
$mfaSession->markVerifiedForUser($user);
$loginService->reattemptLoginFor($user);
$this->limiter->decrementAttempts($user, $request);
return redirect()->intended();
}

View File

@@ -126,7 +126,7 @@ class LoginService
/**
* Clear the last login attempted session value.
*/
protected function clearLastLoginAttempted(): void
public function clearLastLoginAttempted(): void
{
session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
}

View File

@@ -0,0 +1,62 @@
<?php
namespace BookStack\Access\Mfa;
use BookStack\Exceptions\NotifyException;
use BookStack\Users\Models\User;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* A rate limit specifically for MFA verification.
* Limits across both the attempted user (on a tight limit) and the
* request IP (on a less strict limit).
*/
class MfaVerificationLimiter
{
protected int $maxUserAttemptsPerMinute = 5;
protected int $maxIpAttemptsPerMinute = 60;
public function __construct(
protected RateLimiter $rateLimiter
) {
}
public function throwException(): never
{
throw new NotifyException(
trans('auth.mfa_throttle', ['seconds' => 60]),
'/login',
Response::HTTP_TOO_MANY_REQUESTS
);
}
public function incrementAttempts(User $user, Request $request): void
{
$this->rateLimiter->hit($this->getUserKey($user));
$this->rateLimiter->hit($this->getRequestKey($request));
}
public function decrementAttempts(User $user, Request $request): void
{
$this->rateLimiter->decrement($this->getUserKey($user));
$this->rateLimiter->decrement($this->getRequestKey($request));
}
public function hasHitLimit(User $user, Request $request): bool
{
return $this->rateLimiter->tooManyAttempts($this->getUserKey($user), $this->maxUserAttemptsPerMinute + 1)
|| $this->rateLimiter->tooManyAttempts($this->getRequestKey($request), $this->maxIpAttemptsPerMinute + 1);
}
protected function getUserKey(User $user): string
{
return "mfa-attempt::user::{$user->id}";
}
protected function getRequestKey(Request $request): string
{
return "mfa-attempt::request::{$request->ip()}";
}
}

View File

@@ -8,6 +8,7 @@ return [
'failed' => 'These credentials do not match our records.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.',
// Login & Register
'sign_up' => 'Sign up',

View File

@@ -66,6 +66,27 @@ class MfaVerificationTest extends TestCase
$html->assertElementExists('input[autocomplete="one-time-code"][name="code"]');
}
public function test_totp_verification_is_rate_limited()
{
[$user, $secret, $loginResp] = $this->startTotpLogin();
$loginService = $this->app->make(LoginService::class);
$resp = $this->get('/mfa/verify');
for ($i = 0; $i < 5; $i++) {
$this->post('/mfa/totp/verify', [
'code' => '123456',
])->assertRedirect('/mfa/verify');
$this->assertNotNull($loginService->getLastLoginAttemptUser());
}
$resp = $this->post('/mfa/totp/verify', [
'code' => '123456',
]);
$resp->assertRedirect('/login');
$this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.');
$this->assertNull($loginService->getLastLoginAttemptUser());
}
public function test_backup_code_verification()
{
[$user, $codes, $loginResp] = $this->startBackupCodeLogin();
@@ -147,6 +168,27 @@ class MfaVerificationTest extends TestCase
$resp->assertSeeText('You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.');
}
public function test_backup_code_verification_is_rate_limited()
{
[$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
$loginService = $this->app->make(LoginService::class);
$resp = $this->get('/mfa/verify');
for ($i = 0; $i < 5; $i++) {
$this->post('/mfa/backup_codes/verify', [
'code' => '123456abcd',
])->assertRedirect('/mfa/verify');
$this->assertNotNull($loginService->getLastLoginAttemptUser());
}
$resp = $this->post('/mfa/backup_codes/verify', [
'code' => '123456abcd',
]);
$resp->assertRedirect('/login');
$this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.');
$this->assertNull($loginService->getLastLoginAttemptUser());
}
public function test_backup_code_form_has_autofill_configured()
{
[$user, $codes, $loginResp] = $this->startBackupCodeLogin();