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",
|
"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"]
|
||||||
|
|
|
||||||
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