prepare("SELECT COUNT(*) FROM login_attempts WHERE identifier = :identifier AND created_at > datetime('now', :window)"); $stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']); return (int) $stmt->fetchColumn(); } function recordLoginAttempt(PDO $pdo, string $identifier): void { $pdo->prepare('INSERT INTO login_attempts (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]); } $errors = []; $email = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; if (!csrf_verify($_POST['csrf_token'] ?? null)) { $errors['csrf'] = 'Your session has expired. Please refresh the page and try again.'; } if ($email === '') { $errors['email'] = 'Email is required.'; } if ($password === '') { $errors['password'] = 'Password is required.'; } if (empty($errors)) { $pdo = getDbConnection(); $pdo->exec('CREATE TABLE IF NOT EXISTS login_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, identifier TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_attempts_identifier ON login_attempts (identifier)'); $ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $emailIdentifier = 'email:' . strtolower($email); $attemptIds = []; // Check both limits and reserve attempt rows for both identifiers atomically, so // concurrent requests can't all read the same stale counts before any of them // commits. Reserving happens before password_verify() runs (rather than only on a // confirmed failure) so the check-then-record pair is a single fast transaction; // the reservation is released below if the login actually succeeds, so only failed // attempts count against the limit — matching the previous behavior. $pdo->exec('BEGIN IMMEDIATE'); try { if ( loginAttemptCount($pdo, $ipIdentifier, LOGIN_ATTEMPT_WINDOW_SECONDS) >= MAX_LOGIN_ATTEMPTS_PER_IP || loginAttemptCount($pdo, $emailIdentifier, LOGIN_ATTEMPT_WINDOW_SECONDS) >= MAX_LOGIN_ATTEMPTS_PER_EMAIL ) { $errors['login'] = 'Too many login attempts. Please wait a while before trying again.'; } else { recordLoginAttempt($pdo, $ipIdentifier); $attemptIds[] = (int) $pdo->lastInsertId(); recordLoginAttempt($pdo, $emailIdentifier); $attemptIds[] = (int) $pdo->lastInsertId(); } $pdo->exec('COMMIT'); } catch (Throwable $e) { $pdo->exec('ROLLBACK'); throw $e; } if (empty($errors)) { $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email'); $stmt->execute(['email' => $email]); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user || !password_verify($password, $user['password'])) { $errors['login'] = 'Invalid email or password.'; } else { foreach ($attemptIds as $attemptId) { $pdo->prepare('DELETE FROM login_attempts WHERE id = :id')->execute(['id' => $attemptId]); } start_app_session(); session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['session_version'] = (int) $user['session_version']; header('Location: /'); exit; } } } } $pageTitle = 'Log In'; ?>

Welcome back, explorer.

Your critters have been waiting.

Your password has been reset. You can now log in.

Forgot password?

New to OurApp? Create an account