Files
Critters/login.php
T
pauljd 9805d5056d
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 1m5s
issues identified by Davidson CI
2026-09-09 22:49:46 +01:00

189 lines
8.7 KiB
PHP

<?php
require __DIR__ . '/session.php';
require __DIR__ . '/db.php';
start_app_session();
if (isset($_SESSION['username'])) {
header('Location: /');
exit;
}
const LOGIN_ATTEMPT_WINDOW_SECONDS = 900;
const MAX_LOGIN_ATTEMPTS_PER_IP = 20;
const MAX_LOGIN_ATTEMPTS_PER_EMAIL = 8;
function loginAttemptCount(PDO $pdo, string $identifier, int $windowSeconds): int
{
$stmt = $pdo->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);
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 {
$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'])) {
recordLoginAttempt($pdo, $ipIdentifier);
recordLoginAttempt($pdo, $emailIdentifier);
$errors['login'] = 'Invalid email or password.';
} else {
start_app_session();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
header('Location: /');
exit;
}
}
}
}
$pageTitle = 'Log In';
?>
<!DOCTYPE html>
<html lang="en">
<?php require __DIR__ . '/lib/head.php'; ?>
<body class="bg-amber-50 min-h-screen flex flex-col">
<?php require __DIR__ . '/lib/header.php'; ?>
<div class="flex-1 flex items-center justify-center bg-gradient-to-b from-amber-50 via-pink-50 to-white p-4 py-12">
<div class="w-full max-w-4xl bg-white rounded-3xl shadow-xl ring-1 ring-gray-100 overflow-hidden grid md:grid-cols-2">
<div class="relative hidden md:block">
<img src="assets/recently2.webp" alt="" aria-hidden="true" class="absolute inset-0 h-full w-full object-cover">
<div class="absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/40"></div>
<div class="relative h-full flex flex-col justify-between p-8 text-white">
<p class="italic font-semibold text-xl leading-snug drop-shadow">
Good creatures.<br>Brighter days.
</p>
<div class="rounded-2xl bg-white/90 backdrop-blur text-gray-800 px-4 py-3 flex items-center gap-3 shadow-md">
<i data-lucide="paw-print" class="h-4 w-4"></i>
<span class="text-sm font-medium">Log in to continue your collection.</span>
</div>
</div>
</div>
<div class="p-8 sm:p-10">
<h1 class="text-2xl font-extrabold text-gray-900">Welcome back, explorer.</h1>
<p class="mt-1 text-gray-500 text-sm">Your critters have been waiting.</p>
<?php if (isset($errors['login']) || isset($errors['csrf'])): ?>
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['login'] ?? $errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<?php if (isset($_GET['reset']) && $_GET['reset'] === 'success'): ?>
<div class="mt-4 rounded-lg bg-green-50 text-green-700 text-sm px-4 py-3">
Your password has been reset. You can now log in.
</div>
<?php endif; ?>
<form action="login.php" method="post" novalidate class="mt-6 space-y-4">
<?php echo csrf_field(); ?>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div class="relative">
<i data-lucide="mail" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
<input
type="email" id="email" name="email"
value="<?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); ?>"
placeholder="you@example.com"
class="w-full rounded-xl border border-gray-300 pl-10 pr-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
>
</div>
<?php if (isset($errors['email'])): ?>
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['email'], ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<a href="forgot-password.php" class="text-sm text-purple-600 font-medium hover:underline">Forgot password?</a>
</div>
<div class="relative">
<i data-lucide="lock" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
<input
type="password" id="password" name="password"
placeholder="Enter your password"
class="w-full rounded-xl border border-gray-300 pl-10 pr-10 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
>
<button
type="button"
onclick="togglePasswordVisibility('password', this)"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Show password"
>
<i data-lucide="eye" class="h-4 w-4"></i>
<i data-lucide="eye-off" class="h-4 w-4 hidden"></i>
</button>
</div>
<?php if (isset($errors['password'])): ?>
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['password'], ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>
</div>
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gray-900 text-white font-semibold py-3 hover:bg-gray-800">
Log In <i data-lucide="arrow-right" class="h-4 w-4"></i>
</button>
</form>
<p class="mt-6 text-center text-sm text-gray-600">
New to OurApp? <a href="register.php" class="text-purple-600 font-semibold hover:underline">Create an account</a>
</p>
</div>
</div>
</div>
<script>
function togglePasswordVisibility(inputId, button) {
const input = document.getElementById(inputId);
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
button.querySelectorAll('[data-lucide]').forEach((icon) => icon.classList.toggle('hidden'));
}
</script>
<?php require __DIR__ . '/lib/footer.php'; ?>
</body>
</html>