Every login attempt, including unknown users (via DUMMY_HASH), forces a full Argon2id verify with no rate limit, letting a flood take down the login endpoint / shared host via hashing-cost amplification. Add a pre-auth per-IP limiter (RateLimiterRepository, default 20/min) as the first check in AuthController::login, before any user lookup or password hashing.
79 lines
4.8 KiB
PHP
79 lines
4.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
use App\Http\Request;
|
|
use App\Security\Password;
|
|
use App\Auth\{AuthController, ArraySessionStore};
|
|
use App\Tests\Support\{InMemoryUserRepository, InMemoryAuditRepository, InMemoryRateLimiter};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class AuthControllerTest extends TestCase
|
|
{
|
|
private function users(): InMemoryUserRepository {
|
|
return new InMemoryUserRepository(['alice' => [
|
|
'id' => 1, 'username' => 'alice', 'password_hash' => Password::hash('pw'),
|
|
'role' => 'operator', 'site_scope' => null, 'failed_attempts' => 0, 'locked_until' => 0,
|
|
]]);
|
|
}
|
|
private function req(string $u, string $p): Request {
|
|
return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]);
|
|
}
|
|
public function testLoginSuccessSetsSession(): void {
|
|
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
|
|
$sess = new ArraySessionStore();
|
|
$res = $c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4');
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertSame(1, $sess->get('user_id'));
|
|
$this->assertSame('operator', $sess->get('role'));
|
|
}
|
|
public function testBadPasswordIs401(): void {
|
|
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
|
|
$this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
|
|
}
|
|
public function testLockoutAfterMaxAttempts(): void {
|
|
$repo = $this->users();
|
|
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
|
|
for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
|
|
// now locked, even correct password is 423
|
|
$this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
|
|
}
|
|
public function testExpiredLockResetsAttempts(): void {
|
|
$repo = $this->users();
|
|
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
|
|
// lock the account with 3 bad tries at now=1000
|
|
for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
|
|
// locked_until is now 1000 + 900 = 1900, verify it's locked
|
|
$this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
|
|
// now at now=2000 (after lock window expires at 1900), login with correct password should succeed
|
|
$sess = new ArraySessionStore();
|
|
$res = $c->login($this->req('alice', 'pw'), $sess, now: 2000, clientIp: '1.2.3.4');
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertSame(1, $sess->get('user_id'));
|
|
}
|
|
public function testUnknownUsernameReturns401GenericBody(): void {
|
|
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
|
|
$res = $c->login($this->req('nobody', 'whatever'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
|
|
$this->assertSame(401, $res->status());
|
|
// identical generic body to the wrong-password case (no user-enumeration leak)
|
|
$wrong = $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
|
|
$this->assertSame($wrong->body(), $res->body());
|
|
}
|
|
public function testLogoutClearsSession(): void {
|
|
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
|
|
$sess = new ArraySessionStore();
|
|
$c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4');
|
|
$this->assertSame(1, $sess->get('user_id'));
|
|
$res = $c->logout($sess);
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertNull($sess->get('user_id')); // session identity cleared
|
|
}
|
|
public function testLoginIpRateLimited(): void {
|
|
// Even with correct credentials, floods from a single IP must be
|
|
// throttled before the (expensive) Argon2id verify runs repeatedly.
|
|
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter(), loginIpLimitPerMin: 2);
|
|
$ip = '9.9.9.9';
|
|
$this->assertSame(200, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
|
|
$this->assertSame(200, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
|
|
$this->assertSame(429, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
|
|
}
|
|
}
|