feat(cloud): session store + auth controller with lockout

This commit is contained in:
유창욱 2026-07-12 00:28:18 +09:00
parent 9983bc8fe3
commit b345a89788
4 changed files with 119 additions and 1 deletions

View file

@ -3,7 +3,10 @@
"type": "project", "type": "project",
"require": { "php": ">=8.2" }, "require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" }, "require-dev": { "phpunit/phpunit": "^11" },
"autoload": { "psr-4": { "App\\": "src/" } }, "autoload": {
"psr-4": { "App\\": "src/" },
"classmap": ["src/Auth/Session.php"]
},
"autoload-dev": { "autoload-dev": {
"psr-4": { "App\\Tests\\": "tests/" }, "psr-4": { "App\\Tests\\": "tests/" },
"files": ["tests/Support/InMemoryRepositories.php"] "files": ["tests/Support/InMemoryRepositories.php"]

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Http\{Request, Response};
use App\Security\Password;
use App\Repository\{UserRepository, AuditRepository};
final class AuthController
{
public function __construct(
private UserRepository $users,
private AuditRepository $audit,
private int $maxAttempts = 5,
private int $lockSeconds = 900,
) {}
public function login(Request $req, SessionStore $sess, int $now): Response
{
$username = (string)$req->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);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Auth;
interface SessionStore {
public function get(string $key): mixed;
public function set(string $key, mixed $value): void;
public function clear(): void;
}
final class ArraySessionStore implements SessionStore {
private array $d = [];
public function get(string $k): mixed { return $this->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(); }
}

View file

@ -0,0 +1,40 @@
<?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};
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());
$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());
}
}