move_detect/cloud/tests/AuthControllerTest.php
유창욱 d0ba621b42 fix(cloud): verify login CSRF + zone ownership 400 not 500 (P4-8, P4-6)
P4-8: AuthController::login now verifies the CSRF token the login form
already emits (Csrf::check over session vs posted field), returning 403
before the user lookup. Closes a login-CSRF / session-fixation hole where
a forged cross-site POST could silently log a victim into an attacker's
account. Tests seed the session+body token; adds
testLoginRejectsMissingOrWrongCsrf.

P4-6: ZoneConfigController::save wraps the repository write in
try/catch(\RuntimeException) so an ownership/device mismatch is a 400
(invalid_zone) client error, not an uncaught 500. Adds
testSaveOwnershipMismatchIs400NotError with a throwing repo double.

phpunit: 88 passing (4 skipped: PDO-backed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 10:00:04 +09:00

97 lines
6.1 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 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());
}
}