From bc54c8c31e7f50a7a365f9b4d22fe8c74a29f61a Mon Sep 17 00:00:00 2001
From: Thomas Vanbesien
Date: Sat, 21 Mar 2026 21:35:51 +0100
Subject: 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.
---
src/app/Controllers/AuthController.php | 229 +++++++++++++++++++++++++++++++++
src/app/Csrf.php | 33 +++++
src/app/Flash.php | 25 ++++
src/app/Mail.php | 42 ++++++
src/app/Models/User.php | 118 +++++++++++++++++
src/app/Views/auth/forgot-password.php | 13 ++
src/app/Views/auth/login.php | 17 +++
src/app/Views/auth/register.php | 23 ++++
src/app/Views/auth/reset-password.php | 17 +++
src/app/Views/layouts/main.php | 2 +-
src/app/Views/partials/flash.php | 8 ++
11 files changed, 526 insertions(+), 1 deletion(-)
create mode 100644 src/app/Controllers/AuthController.php
create mode 100644 src/app/Csrf.php
create mode 100644 src/app/Flash.php
create mode 100644 src/app/Mail.php
create mode 100644 src/app/Models/User.php
create mode 100644 src/app/Views/auth/forgot-password.php
create mode 100644 src/app/Views/auth/login.php
create mode 100644 src/app/Views/auth/register.php
create mode 100644 src/app/Views/auth/reset-password.php
create mode 100644 src/app/Views/partials/flash.php
(limited to 'src/app')
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 @@
+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 @@
+ to embed in HTML forms
+ $token = htmlspecialchars(self::generate());
+ return '';
+ }
+}
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 @@
+ $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 @@
+Click the link below to verify your email address:
'
+ . '' . htmlspecialchars($url) . '
'
+ . 'If you did not create an account, ignore this email.
';
+
+ 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 = 'Click the link below to reset your password:
'
+ . '' . htmlspecialchars($url) . '
'
+ . 'This link expires in 1 hour.
'
+ . 'If you did not request a password reset, ignore this email.
';
+
+ 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 @@
+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 @@
+
+
+
Forgot password
+
+
+
Back to login
+
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 @@
+
+
+
Login
+
+
+
Forgot your password?
+
Don't have an account? Register
+
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 @@
+
+
+
Register
+
+
+
Already have an account? Log in
+
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 @@
+
+
+
Reset password
+
+
+
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 @@