1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2026-10-23 06:49:01 +03:00
Files
bookstack/tests/Auth/AuthTest.php
Dan Brown 0ada5d2dc9 Login: Added extra timing defenses for failed login attempts
- Adds a dummy hash attempt to balance the time of unknown user login
  attempt with known user login attempt to help prevent timing being
  used to indicate existing accounts.
- Adds some random variance to failed login attempts to help prevent
  timing based information discovery.

Thanks to Tanner Marks for their responsible disclosure of this.
2026-07-24 14:38:54 +01:00

222 lines
7.7 KiB
PHP

<?php
namespace Tests\Auth;
use BookStack\Access\Mfa\MfaSession;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
class AuthTest extends TestCase
{
public function test_auth_working()
{
$this->get('/')->assertRedirect('/login');
}
public function test_login()
{
$this->login('admin@admin.com', 'password')->assertRedirect('/');
}
public function test_public_viewing()
{
$this->setSettings(['app-public' => 'true']);
$this->get('/')
->assertOk()
->assertSee('Log in');
}
public function test_sign_up_link_on_login()
{
$this->get('/login')->assertDontSee('Sign up');
$this->setSettings(['registration-enabled' => 'true']);
$this->get('/login')->assertSee('Sign up');
}
public function test_logout()
{
$this->asAdmin()->get('/')->assertOk();
$this->post('/logout')->assertRedirect('/');
$this->get('/')->assertRedirect('/login');
}
public function test_mfa_session_cleared_on_logout()
{
$user = $this->users->editor();
$mfaSession = $this->app->make(MfaSession::class);
$mfaSession->markVerifiedForUser($user);
$this->assertTrue($mfaSession->isVerifiedForUser($user));
$this->asAdmin()->post('/logout');
$this->assertFalse($mfaSession->isVerifiedForUser($user));
}
public function test_login_redirects_to_initially_requested_url_correctly()
{
config()->set('app.url', 'http://localhost');
$page = $this->entities->page();
$this->get($page->getUrl())->assertRedirect(url('/login'));
$this->login('admin@admin.com', 'password')
->assertRedirect($page->getUrl());
}
public function test_login_intended_redirect_does_not_redirect_to_external_pages()
{
config()->set('app.url', 'http://localhost');
$this->setSettings(['app-public' => true]);
$testCases = [
'https://example.com',
'http://localhost.example.com',
'http://localhost:ab@example.com',
];
foreach ($testCases as $testCase) {
$this->get('/login', ['Referer' => $testCase]);
$login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']);
$login->assertRedirect('http://localhost');
auth()->logout();
}
}
public function test_login_intended_redirect_does_not_factor_mfa_routes()
{
$this->get('/books')->assertRedirect('/login');
$this->get('/mfa/setup')->assertRedirect('/login');
$login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']);
$login->assertRedirect('/books');
}
public function test_login_authenticates_admins_on_all_guards()
{
$this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']);
$this->assertTrue(auth()->check());
$this->assertTrue(auth('ldap')->check());
$this->assertTrue(auth('saml2')->check());
$this->assertTrue(auth('oidc')->check());
}
public function test_login_authenticates_nonadmins_on_default_guard_only()
{
$editor = $this->users->editor();
$editor->password = bcrypt('password');
$editor->save();
$this->post('/login', ['email' => $editor->email, 'password' => 'password']);
$this->assertTrue(auth()->check());
$this->assertFalse(auth('ldap')->check());
$this->assertFalse(auth('saml2')->check());
$this->assertFalse(auth('oidc')->check());
}
public function test_failed_logins_are_logged_when_message_configured()
{
$log = $this->withTestLogger();
config()->set(['logging.failed_login.message' => 'Failed login for %u']);
$this->post('/login', ['email' => 'admin@example.com', 'password' => 'cattreedog']);
$this->assertTrue($log->hasWarningThatContains('Failed login for admin@example.com'));
$this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']);
$this->assertFalse($log->hasWarningThatContains('Failed login for admin@admin.com'));
}
public function test_logged_in_user_with_unconfirmed_email_is_logged_out()
{
$this->setSettings(['registration-confirmation' => 'true']);
$user = $this->users->editor();
$user->email_confirmed = false;
$user->save();
auth()->login($user);
$this->assertTrue(auth()->check());
$this->get('/books')->assertRedirect('/');
$this->assertFalse(auth()->check());
}
public function test_login_attempts_are_rate_limited()
{
for ($i = 0; $i < 5; $i++) {
$resp = $this->login('bennynotexisting@example.com', 'pw123');
}
$resp = $this->followRedirects($resp);
$resp->assertSee('These credentials do not match our records.');
// Check the fifth attempt provides a lockout response
$resp = $this->followRedirects($this->login('bennynotexisting@example.com', 'pw123'));
$resp->assertSee('Too many login attempts. Please try again in');
}
public function test_login_specifically_disabled_for_guest_account()
{
$guest = $this->users->guest();
$resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']);
$resp->assertRedirect('/login');
$resp = $this->followRedirects($resp);
$resp->assertSee('These credentials do not match our records.');
// Test login even with password somehow set
$guest->password = Hash::make('password');
$guest->save();
$resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']);
$resp->assertRedirect('/login');
$resp = $this->followRedirects($resp);
$resp->assertSee('These credentials do not match our records.');
}
public function test_failed_login_attempt_has_noise_added_and_have_similar_times_between_known_and_unknown_users()
{
$this->markTestSkipped('Time consuming test');
$user = $this->users->editor();
$user->password = bcrypt('password');
$user->save();
// Warmup
$this->post('/login', ['email' => $user->email, 'password' => 'passwordtesting']);
// For known user attempts
$durations = [];
for ($i = 0; $i < 25; $i++) {
$knownStart = microtime(true);
$this->post('/login', ['email' => $user->email, 'password' => 'passwordtesting']);
$durations[] = (microtime(true) - $knownStart) * 1000;
Cache::clear(); // Clear the cache to avoid hitting rate limits
}
$range = max($durations) - min($durations);
$this->assertGreaterThan(125, $range);
$knownAvg = array_sum($durations) / count($durations);
// For unknown user attempts
$durations = [];
for ($i = 0; $i < 25; $i++) {
$unknownStart = microtime(true);
$this->post('/login', ['email' => 'unknown@example.com', 'password' => 'passwordtesting']);
$durations[] = (microtime(true) - $unknownStart) * 1000;
Cache::clear(); // Clear the cache to avoid hitting rate limits
}
$range = max($durations) - min($durations);
$this->assertGreaterThan(125, $range);
$unknownAvg = array_sum($durations) / count($durations);
$knownDiff = abs($knownAvg - $unknownAvg);
$this->assertLessThan(25, $knownDiff);
}
/**
* Perform a login.
*/
protected function login(string $email, string $password): TestResponse
{
return $this->post('/login', compact('email', 'password'));
}
}