move_detect/cloud/src/Repository/Pdo/PdoUserRepository.php
유창욱 9aa7f44ea4 feat(cloud): PDO repositories + schema + Cafe24 deploy doc
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
2026-07-12 01:58:05 +09:00

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]);
}
}