diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 21:35:51 +0100 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 21:35:51 +0100 |
| commit | bc54c8c31e7f50a7a365f9b4d22fe8c74a29f61a (patch) | |
| tree | 73a88384b9e472386d244119a0b4e4aa028c8b32 /src/app | |
| parent | d1ef15fa39935bfa0420c5ac2b8c269e294c9a6d (diff) | |
| download | camagru-bc54c8c31e7f50a7a365f9b4d22fe8c74a29f61a.tar.gz camagru-bc54c8c31e7f50a7a365f9b4d22fe8c74a29f61a.zip | |
Add user authentication with email verification and password reset
Implements registration, login/logout, email verification via token,
and password reset flow. Includes CSRF protection, flash messages,
MailPit for dev email testing, and security docs in README.
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/Controllers/AuthController.php | 229 | ||||
| -rw-r--r-- | src/app/Csrf.php | 33 | ||||
| -rw-r--r-- | src/app/Flash.php | 25 | ||||
| -rw-r--r-- | src/app/Mail.php | 42 | ||||
| -rw-r--r-- | src/app/Models/User.php | 118 | ||||
| -rw-r--r-- | src/app/Views/auth/forgot-password.php | 13 | ||||
| -rw-r--r-- | src/app/Views/auth/login.php | 17 | ||||
| -rw-r--r-- | src/app/Views/auth/register.php | 23 | ||||
| -rw-r--r-- | src/app/Views/auth/reset-password.php | 17 | ||||
| -rw-r--r-- | src/app/Views/layouts/main.php | 2 | ||||
| -rw-r--r-- | src/app/Views/partials/flash.php | 8 |
11 files changed, 526 insertions, 1 deletions
diff --git a/src/app/Controllers/AuthController.php b/src/app/Controllers/AuthController.php new file mode 100644 index 0000000..ae94295 --- /dev/null +++ b/src/app/Controllers/AuthController.php @@ -0,0 +1,229 @@ +<?php + +declare(strict_types=1); +// Handles authentication: register, login, logout, email verification, and password reset. + +namespace App\Controllers; + +use App\Csrf; +use App\Flash; +use App\Mail; +use App\Models\User; + +class AuthController +{ + private User $user; + + public function __construct() + { + $this->user = new User(); + } + + // ── Registration ── + + public function registerForm(): void + { + $content = __DIR__ . '/../Views/auth/register.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } + + public function register(): void + { + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /register'); + return; + } + + $username = trim($_POST['username'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $password = $_POST['password'] ?? ''; + $passwordConfirm = $_POST['password_confirm'] ?? ''; + + // Validation + $errors = []; + + if ($username === '' || $email === '' || $password === '') { + $errors[] = 'All fields are required.'; + } + if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) { + $errors[] = 'Username must be 3-20 characters (letters, numbers, underscores).'; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Invalid email address.'; + } + if (\strlen($password) < 8) { + $errors[] = 'Password must be at least 8 characters.'; + } + if ($password !== $passwordConfirm) { + $errors[] = 'Passwords do not match.'; + } + if ($this->user->findByUsername($username)) { + $errors[] = 'Username is already taken.'; + } + if ($this->user->findByEmail($email)) { + $errors[] = 'Email is already registered.'; + } + + if ($errors) { + Flash::set('error', implode(' ', $errors)); + header('Location: /register'); + return; + } + + $userId = $this->user->create($username, $email, $password); + $token = $this->user->getVerificationToken($userId); + Mail::sendVerification($email, $token); + + Flash::set('success', 'Account created! Check your email to verify.'); + header('Location: /login'); + } + + // ── Email verification ── + + public function verify(): void + { + $token = $_GET['token'] ?? ''; + $user = $this->user->findByVerificationToken($token); + + if (!$user) { + Flash::set('error', 'Invalid or expired verification link.'); + header('Location: /login'); + return; + } + + $this->user->verify($user['id']); + Flash::set('success', 'Email verified! You can now log in.'); + header('Location: /login'); + } + + // ── Login / Logout ── + + public function loginForm(): void + { + $content = __DIR__ . '/../Views/auth/login.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } + + public function login(): void + { + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /login'); + return; + } + + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + + $user = $this->user->findByUsername($username); + + // Use the same error message for wrong username or wrong password + // to avoid revealing which usernames exist (user enumeration) + if (!$user || !password_verify($password, $user['password_hash'])) { + Flash::set('error', 'Invalid username or password.'); + header('Location: /login'); + return; + } + + if (!$user['is_verified']) { + Flash::set('error', 'Please verify your email before logging in.'); + header('Location: /login'); + return; + } + + // Regenerate session ID to prevent session fixation attacks + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + + header('Location: /'); + } + + public function logout(): void + { + session_destroy(); + header('Location: /'); + } + + // ── Password reset ── + + public function forgotPasswordForm(): void + { + $content = __DIR__ . '/../Views/auth/forgot-password.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } + + public function forgotPassword(): void + { + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /forgot-password'); + return; + } + + $email = trim($_POST['email'] ?? ''); + + // Always show the same message whether the email exists or not + // to prevent user enumeration + Flash::set('success', 'If that email is registered, a reset link has been sent.'); + + $user = $this->user->findByEmail($email); + if ($user) { + $token = $this->user->setResetToken($user['id']); + Mail::sendPasswordReset($email, $token); + } + + header('Location: /login'); + } + + public function resetPasswordForm(): void + { + $token = $_GET['token'] ?? ''; + $user = $this->user->findByResetToken($token); + + if (!$user) { + Flash::set('error', 'Invalid or expired reset link.'); + header('Location: /login'); + return; + } + + $content = __DIR__ . '/../Views/auth/reset-password.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } + + public function resetPassword(): void + { + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /login'); + return; + } + + $token = $_POST['token'] ?? ''; + $password = $_POST['password'] ?? ''; + $passwordConfirm = $_POST['password_confirm'] ?? ''; + + $user = $this->user->findByResetToken($token); + if (!$user) { + Flash::set('error', 'Invalid or expired reset link.'); + header('Location: /login'); + return; + } + + if (\strlen($password) < 8) { + Flash::set('error', 'Password must be at least 8 characters.'); + header('Location: /reset-password?token=' . urlencode($token)); + return; + } + if ($password !== $passwordConfirm) { + Flash::set('error', 'Passwords do not match.'); + header('Location: /reset-password?token=' . urlencode($token)); + return; + } + + $this->user->updatePassword($user['id'], $password); + Flash::set('success', 'Password updated! You can now log in.'); + header('Location: /login'); + } +} diff --git a/src/app/Csrf.php b/src/app/Csrf.php new file mode 100644 index 0000000..a18c5d9 --- /dev/null +++ b/src/app/Csrf.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); +// CSRF token generation and validation. +// Prevents attackers from tricking logged-in users into submitting unwanted requests. +// See README.md for a full explanation of the CSRF attack and how tokens prevent it. + +namespace App; + +class Csrf +{ + public static function generate(): string + { + // Reuse the token for the whole session so multiple tabs/forms work + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + public static function validate(string $token): bool + { + // hash_equals() prevents timing attacks (constant-time comparison) + return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); + } + + public static function field(): string + { + // Returns a hidden <input> to embed in HTML forms + $token = htmlspecialchars(self::generate()); + return '<input type="hidden" name="csrf_token" value="' . $token . '">'; + } +} diff --git a/src/app/Flash.php b/src/app/Flash.php new file mode 100644 index 0000000..6e8e78a --- /dev/null +++ b/src/app/Flash.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); +// Flash messages: one-time session messages shown after a redirect. +// Set a message before redirecting, display it on the next page load, then it's gone. + +namespace App; + +class Flash +{ + public static function set(string $type, string $message): void + { + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; + } + + public static function get(): ?array + { + if (isset($_SESSION['flash'])) { + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + return $flash; + } + return null; + } +} diff --git a/src/app/Mail.php b/src/app/Mail.php new file mode 100644 index 0000000..054c6e0 --- /dev/null +++ b/src/app/Mail.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); +// Sends emails using PHP's built-in mail() function. +// In Docker, msmtp is configured as the sendmail transport. + +namespace App; + +class Mail +{ + public static function send(string $to, string $subject, string $body): bool + { + $from = getenv('MAIL_FROM') ?: 'noreply@camagru.local'; + $headers = "From: $from\r\n"; + $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; + + return mail($to, $subject, $body, $headers); + } + + public static function sendVerification(string $to, string $token): bool + { + $url = getenv('APP_URL') . '/verify?token=' . urlencode($token); + $subject = 'Camagru — Verify your email'; + $body = '<p>Click the link below to verify your email address:</p>' + . '<p><a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($url) . '</a></p>' + . '<p>If you did not create an account, ignore this email.</p>'; + + return self::send($to, $subject, $body); + } + + public static function sendPasswordReset(string $to, string $token): bool + { + $url = getenv('APP_URL') . '/reset-password?token=' . urlencode($token); + $subject = 'Camagru — Reset your password'; + $body = '<p>Click the link below to reset your password:</p>' + . '<p><a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($url) . '</a></p>' + . '<p>This link expires in 1 hour.</p>' + . '<p>If you did not request a password reset, ignore this email.</p>'; + + return self::send($to, $subject, $body); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php new file mode 100644 index 0000000..d4c5c88 --- /dev/null +++ b/src/app/Models/User.php @@ -0,0 +1,118 @@ +<?php + +declare(strict_types=1); +// User model: database operations for the users table. + +namespace App\Models; + +use App\Database; + +class User +{ + private \PDO $pdo; + + public function __construct() + { + $this->pdo = Database::getInstance()->getPdo(); + } + + public function create(string $username, string $email, string $password): int + { + $hash = password_hash($password, PASSWORD_DEFAULT); + $token = bin2hex(random_bytes(32)); + + $stmt = $this->pdo->prepare( + 'INSERT INTO users (username, email, password_hash, verification_token) + VALUES (:username, :email, :hash, :token)' + ); + $stmt->execute([ + 'username' => $username, + 'email' => $email, + 'hash' => $hash, + 'token' => $token, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + public function findByUsername(string $username): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :username'); + $stmt->execute(['username' => $username]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function findByEmail(string $email): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = :email'); + $stmt->execute(['email' => $email]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function findById(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function findByVerificationToken(string $token): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE verification_token = :token'); + $stmt->execute(['token' => $token]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function verify(int $id): void + { + $stmt = $this->pdo->prepare( + 'UPDATE users SET is_verified = TRUE, verification_token = NULL WHERE id = :id' + ); + $stmt->execute(['id' => $id]); + } + + public function setResetToken(int $id): string + { + $token = bin2hex(random_bytes(32)); + // Token expires in 1 hour + $expires = date('Y-m-d H:i:s', time() + 3600); + + $stmt = $this->pdo->prepare( + 'UPDATE users SET reset_token = :token, reset_token_expires = :expires WHERE id = :id' + ); + $stmt->execute(['token' => $token, 'expires' => $expires, 'id' => $id]); + + return $token; + } + + public function findByResetToken(string $token): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM users WHERE reset_token = :token AND reset_token_expires > NOW()' + ); + $stmt->execute(['token' => $token]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function updatePassword(int $id, string $password): void + { + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $this->pdo->prepare( + 'UPDATE users SET password_hash = :hash, reset_token = NULL, reset_token_expires = NULL WHERE id = :id' + ); + $stmt->execute(['hash' => $hash, 'id' => $id]); + } + + public function getVerificationToken(int $id): ?string + { + $stmt = $this->pdo->prepare('SELECT verification_token FROM users WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ? $row['verification_token'] : null; + } +} diff --git a/src/app/Views/auth/forgot-password.php b/src/app/Views/auth/forgot-password.php new file mode 100644 index 0000000..c0eb25e --- /dev/null +++ b/src/app/Views/auth/forgot-password.php @@ -0,0 +1,13 @@ +<?php // Forgot password form: enter email to receive a reset link.?> +<div class="auth-page"> + <h1>Forgot password</h1> + <?php include __DIR__ . '/../partials/flash.php'; ?> + <form method="POST" action="/forgot-password" class="auth-form"> + <?= \App\Csrf::field() ?> + <label for="email">Email</label> + <input type="email" id="email" name="email" required> + + <button type="submit">Send reset link</button> + </form> + <p><a href="/login">Back to login</a></p> +</div> diff --git a/src/app/Views/auth/login.php b/src/app/Views/auth/login.php new file mode 100644 index 0000000..da9e7e5 --- /dev/null +++ b/src/app/Views/auth/login.php @@ -0,0 +1,17 @@ +<?php // Login form.?> +<div class="auth-page"> + <h1>Login</h1> + <?php include __DIR__ . '/../partials/flash.php'; ?> + <form method="POST" action="/login" class="auth-form"> + <?= \App\Csrf::field() ?> + <label for="username">Username</label> + <input type="text" id="username" name="username" required> + + <label for="password">Password</label> + <input type="password" id="password" name="password" required> + + <button type="submit">Log in</button> + </form> + <p><a href="/forgot-password">Forgot your password?</a></p> + <p>Don't have an account? <a href="/register">Register</a></p> +</div> diff --git a/src/app/Views/auth/register.php b/src/app/Views/auth/register.php new file mode 100644 index 0000000..f98102e --- /dev/null +++ b/src/app/Views/auth/register.php @@ -0,0 +1,23 @@ +<?php // Registration form.?> +<div class="auth-page"> + <h1>Register</h1> + <?php include __DIR__ . '/../partials/flash.php'; ?> + <form method="POST" action="/register" class="auth-form"> + <?= \App\Csrf::field() ?> + <label for="username">Username</label> + <input type="text" id="username" name="username" required + pattern="[a-zA-Z0-9_]{3,20}" title="3-20 characters: letters, numbers, underscores"> + + <label for="email">Email</label> + <input type="email" id="email" name="email" required> + + <label for="password">Password</label> + <input type="password" id="password" name="password" required minlength="8"> + + <label for="password_confirm">Confirm password</label> + <input type="password" id="password_confirm" name="password_confirm" required minlength="8"> + + <button type="submit">Register</button> + </form> + <p>Already have an account? <a href="/login">Log in</a></p> +</div> diff --git a/src/app/Views/auth/reset-password.php b/src/app/Views/auth/reset-password.php new file mode 100644 index 0000000..dfdcab2 --- /dev/null +++ b/src/app/Views/auth/reset-password.php @@ -0,0 +1,17 @@ +<?php // Reset password form: set a new password using a valid reset token.?> +<div class="auth-page"> + <h1>Reset password</h1> + <?php include __DIR__ . '/../partials/flash.php'; ?> + <form method="POST" action="/reset-password" class="auth-form"> + <?= \App\Csrf::field() ?> + <input type="hidden" name="token" value="<?= htmlspecialchars($_GET['token'] ?? '') ?>"> + + <label for="password">New password</label> + <input type="password" id="password" name="password" required minlength="8"> + + <label for="password_confirm">Confirm new password</label> + <input type="password" id="password_confirm" name="password_confirm" required minlength="8"> + + <button type="submit">Reset password</button> + </form> +</div> diff --git a/src/app/Views/layouts/main.php b/src/app/Views/layouts/main.php index b4c7dad..fd9ca40 100644 --- a/src/app/Views/layouts/main.php +++ b/src/app/Views/layouts/main.php @@ -28,7 +28,7 @@ <?php include $content; ?> </main> <footer> - <p>Camagru © <?= date('Y') ?></p> + <p>Camagru <?= date('Y') ?></p> </footer> <script src="/js/app.js"></script> </body> diff --git a/src/app/Views/partials/flash.php b/src/app/Views/partials/flash.php new file mode 100644 index 0000000..e9a87e7 --- /dev/null +++ b/src/app/Views/partials/flash.php @@ -0,0 +1,8 @@ +<?php +// Displays a one-time flash message (success or error) if one is set. +$flash = \App\Flash::get(); +if ($flash): ?> +<div class="flash flash-<?= htmlspecialchars($flash['type']) ?>"> + <?= htmlspecialchars($flash['message']) ?> +</div> +<?php endif; ?> |
