aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/Controllers
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-03-21 21:35:51 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-03-21 21:35:51 +0100
commitbc54c8c31e7f50a7a365f9b4d22fe8c74a29f61a (patch)
tree73a88384b9e472386d244119a0b4e4aa028c8b32 /src/app/Controllers
parentd1ef15fa39935bfa0420c5ac2b8c269e294c9a6d (diff)
downloadcamagru-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/Controllers')
-rw-r--r--src/app/Controllers/AuthController.php229
1 files changed, 229 insertions, 0 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');
+ }
+}