[ 'id' => 1, 'username' => 'alice', 'password_hash' => Password::hash('pw'), 'role' => 'operator', 'site_scope' => null, 'failed_attempts' => 0, 'locked_until' => 0, ]]); } private function sess(): ArraySessionStore { // Model the real flow: GET /login already seeded the session csrf token before POST. $s = new ArraySessionStore(); $s->set('csrf', 'tok'); return $s; } private function req(string $u, string $p): Request { return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p, 'csrf' => 'tok']); } public function testLoginSuccessSetsSession(): void { $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $sess = $this->sess(); $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'), $this->sess(), 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'), $this->sess(), now: 1000, clientIp: '1.2.3.4'); // now locked, even correct password is 423 $this->assertSame(423, $c->login($this->req('alice', 'pw'), $this->sess(), 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'), $this->sess(), 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'), $this->sess(), 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 = $this->sess(); $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'), $this->sess(), 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'), $this->sess(), 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 = $this->sess(); $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'), $this->sess(), now: 1000, clientIp: $ip)->status()); $this->assertSame(200, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: $ip)->status()); $this->assertSame(429, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: $ip)->status()); } public function testLoginRejectsMissingOrWrongCsrf(): void { // Login CSRF: a forged cross-site POST would carry no (or a wrong) csrf field and must be // rejected 403 BEFORE any credential check — even with an otherwise-valid password. $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); // missing csrf field in the body $missing = new Request('POST', '/login', [], '', [], ['username' => 'alice', 'password' => 'pw']); $this->assertSame(403, $c->login($missing, $this->sess(), now: 1000, clientIp: '1.2.3.4')->status()); // wrong csrf token $wrong = new Request('POST', '/login', [], '', [], ['username' => 'alice', 'password' => 'pw', 'csrf' => 'nope']); $this->assertSame(403, $c->login($wrong, $this->sess(), now: 1000, clientIp: '1.2.3.4')->status()); // session that never seeded a token (empty expected) also rejects, even with a matching-looking field $noTok = new ArraySessionStore(); $this->assertSame(403, $c->login($this->req('alice', 'pw'), $noTok, now: 1000, clientIp: '1.2.3.4')->status()); } }