feat(cloud): session store + auth controller with lockout
This commit is contained in:
parent
9983bc8fe3
commit
b345a89788
4 changed files with 119 additions and 1 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
50
cloud/src/Auth/AuthController.php
Normal file
50
cloud/src/Auth/AuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
cloud/src/Auth/Session.php
Normal file
25
cloud/src/Auth/Session.php
Normal 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(); }
|
||||
}
|
||||
40
cloud/tests/AuthControllerTest.php
Normal file
40
cloud/tests/AuthControllerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue