Implements Task 16 (final, P1-B): App\Db\Database::connect(Config): PDO factory and the 9 App\Repository\Pdo\Pdo*Repository classes backing the repository interfaces consumed by controllers since T1-T15, all-prepared statements, plus cloud/schema.sql (MySQL 8 DDL) and cloud/docs/cloud-deploy.md. Security hardening incorporated per accumulated task reviews: - PdoNonceRepository: nonces.nonce is PK; duplicate remember() throws instead of racing seen() (closes T7 TOCTOU). - PdoRateLimiterRepository::hit(): atomic INSERT ... ON DUPLICATE KEY UPDATE upsert on (rate_key, window_start), no read-modify-write race. - PdoSnapshotRepository::pathByUuid(): validates 32-hex uuid before building any filesystem path (closes T12 path-traversal risk). - PdoZoneRepository::save(): update path is ownership-scoped by (zone_id, device_id, camera_id); throws on mismatch instead of silently overwriting an unrelated zone (closes T13 cross-tenant overwrite risk). - Database::connect(): ERRMODE_EXCEPTION, EMULATE_PREPARES=false, FETCH_ASSOC. cloud/tests/PdoIntegrationTest.php round-trips Nonce/Event/Zone against a real sqlite :memory: DB, gated via markTestSkipped when pdo_sqlite is unavailable (this host has pdo_mysql only, no pdo_sqlite) -- suite stays green with 4 tests skipped. Full suite: 59 tests, 114 assertions, 4 skipped, 0 failures, 0 warnings/deprecations. php -l passes on all new files and on cloud/public/index.php (T15), which references all new FQCNs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
49 lines
1.6 KiB
PHP
49 lines
1.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Repository\Pdo;
|
|
|
|
use App\Repository\UserRepository;
|
|
use PDO;
|
|
|
|
final class PdoUserRepository implements UserRepository
|
|
{
|
|
public function __construct(private PDO $pdo) {}
|
|
|
|
public function byUsername(string $username): ?array
|
|
{
|
|
$st = $this->pdo->prepare(
|
|
'SELECT id, username, password_hash, role, site_scope, failed_attempts, locked_until
|
|
FROM users WHERE username = ? LIMIT 1'
|
|
);
|
|
$st->execute([$username]);
|
|
$row = $st->fetch();
|
|
if ($row === false) {
|
|
return null;
|
|
}
|
|
return [
|
|
'id' => (int)$row['id'],
|
|
'username' => $row['username'],
|
|
'password_hash' => $row['password_hash'],
|
|
'role' => $row['role'],
|
|
'site_scope' => $row['site_scope'],
|
|
'failed_attempts' => (int)$row['failed_attempts'],
|
|
'locked_until' => (int)$row['locked_until'],
|
|
];
|
|
}
|
|
|
|
public function recordFailure(int $userId, int $lockUntil): void
|
|
{
|
|
// Increment in SQL (not read-then-write in PHP) so concurrent failed
|
|
// logins for the same account can't clobber each other's count.
|
|
$st = $this->pdo->prepare(
|
|
'UPDATE users SET failed_attempts = failed_attempts + 1, locked_until = ? WHERE id = ?'
|
|
);
|
|
$st->execute([$lockUntil, $userId]);
|
|
}
|
|
|
|
public function clearFailures(int $userId): void
|
|
{
|
|
$st = $this->pdo->prepare('UPDATE users SET failed_attempts = 0, locked_until = 0 WHERE id = ?');
|
|
$st->execute([$userId]);
|
|
}
|
|
}
|