diff --git a/cloud/composer.json b/cloud/composer.json index 36537a3..bd8621c 100644 --- a/cloud/composer.json +++ b/cloud/composer.json @@ -3,7 +3,10 @@ "type": "project", "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11" }, - "autoload": { "psr-4": { "App\\": "src/" } }, + "autoload": { + "psr-4": { "App\\": "src/" }, + "classmap": ["src/Auth/Session.php"] + }, "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" }, "files": ["tests/Support/InMemoryRepositories.php"] diff --git a/cloud/src/Auth/AuthController.php b/cloud/src/Auth/AuthController.php new file mode 100644 index 0000000..88fb997 --- /dev/null +++ b/cloud/src/Auth/AuthController.php @@ -0,0 +1,50 @@ +post('username'); + $password = (string)$req->post('password'); + $u = $this->users->byUsername($username); + if ($u === null) return Response::json(['error' => 'invalid_credentials'], 401); + + if ((int)($u['locked_until'] ?? 0) > $now) { + $this->audit->append($username, 'login_locked', 'auth', [], []); + return Response::json(['error' => 'locked'], 423); + } + if (!Password::verify($password, (string)$u['password_hash'])) { + $attempts = (int)($u['failed_attempts'] ?? 0) + 1; + $lock = $attempts >= $this->maxAttempts ? $now + $this->lockSeconds : 0; + $this->users->recordFailure((int)$u['id'], $lock); + $this->audit->append($username, 'login_failed', 'auth', [], ['attempts' => $attempts]); + return Response::json(['error' => 'invalid_credentials'], 401); + } + $this->users->clearFailures((int)$u['id']); + $sess->set('user_id', (int)$u['id']); + $sess->set('username', $u['username']); + $sess->set('role', $u['role']); + $sess->set('site_scope', $u['site_scope'] ?? null); + $this->audit->append($username, 'login_ok', 'auth', [], []); + return Response::json(['ok' => true], 200); + } + + public function logout(SessionStore $sess): Response + { + $sess->clear(); + return Response::json(['ok' => true], 200); + } +} diff --git a/cloud/src/Auth/Session.php b/cloud/src/Auth/Session.php new file mode 100644 index 0000000..995a7e9 --- /dev/null +++ b/cloud/src/Auth/Session.php @@ -0,0 +1,25 @@ +d[$k] ?? null; } + public function set(string $k, mixed $v): void { $this->d[$k] = $v; } + public function clear(): void { $this->d = []; } +} + +final class PhpSessionStore implements SessionStore { + public function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) session_start([ + 'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax', + ]); } + public function get(string $k): mixed { return $_SESSION[$k] ?? null; } + public function set(string $k, mixed $v): void { $_SESSION[$k] = $v; } + public function clear(): void { $_SESSION = []; session_destroy(); } +} diff --git a/cloud/tests/AuthControllerTest.php b/cloud/tests/AuthControllerTest.php new file mode 100644 index 0000000..54e96c3 --- /dev/null +++ b/cloud/tests/AuthControllerTest.php @@ -0,0 +1,40 @@ + [ + '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()); + $sess = new ArraySessionStore(); + $res = $c->login($this->req('alice', 'pw'), $sess, now: 1000); + $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()); + $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000)->status()); + } + public function testLockoutAfterMaxAttempts(): void { + $repo = $this->users(); + $c = new AuthController($repo, new InMemoryAuditRepository(), maxAttempts: 3, lockSeconds: 900); + for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000); + // now locked, even correct password is 423 + $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000)->status()); + } +}