aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/app/Controllers/AuthController.php229
-rw-r--r--src/app/Csrf.php33
-rw-r--r--src/app/Flash.php25
-rw-r--r--src/app/Mail.php42
-rw-r--r--src/app/Models/User.php118
-rw-r--r--src/app/Views/auth/forgot-password.php13
-rw-r--r--src/app/Views/auth/login.php17
-rw-r--r--src/app/Views/auth/register.php23
-rw-r--r--src/app/Views/auth/reset-password.php17
-rw-r--r--src/app/Views/layouts/main.php2
-rw-r--r--src/app/Views/partials/flash.php8
-rw-r--r--src/config/routes.php12
-rw-r--r--src/public/css/style.css74
13 files changed, 612 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 &copy; <?= 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; ?>
diff --git a/src/config/routes.php b/src/config/routes.php
index d25cfb5..e5f289c 100644
--- a/src/config/routes.php
+++ b/src/config/routes.php
@@ -5,3 +5,15 @@ declare(strict_types=1);
/** @var \App\Router $router */
$router->get('/', 'HomeController', 'index');
+
+// Auth
+$router->get('/register', 'AuthController', 'registerForm');
+$router->post('/register', 'AuthController', 'register');
+$router->get('/verify', 'AuthController', 'verify');
+$router->get('/login', 'AuthController', 'loginForm');
+$router->post('/login', 'AuthController', 'login');
+$router->get('/logout', 'AuthController', 'logout');
+$router->get('/forgot-password', 'AuthController', 'forgotPasswordForm');
+$router->post('/forgot-password', 'AuthController', 'forgotPassword');
+$router->get('/reset-password', 'AuthController', 'resetPasswordForm');
+$router->post('/reset-password', 'AuthController', 'resetPassword');
diff --git a/src/public/css/style.css b/src/public/css/style.css
index 5677e91..ddba25d 100644
--- a/src/public/css/style.css
+++ b/src/public/css/style.css
@@ -56,6 +56,80 @@ footer {
border-top: 1px solid #dbdbdb;
}
+/* Auth pages */
+.auth-page {
+ max-width: 400px;
+ margin: 2rem auto;
+ padding: 2rem;
+ background: #fff;
+ border: 1px solid #dbdbdb;
+ border-radius: 8px;
+ text-align: center;
+}
+
+.auth-page h1 {
+ margin-bottom: 1rem;
+}
+
+.auth-page > p {
+ margin-top: 1rem;
+ font-size: 0.9rem;
+}
+
+/* Auth forms */
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ text-align: left;
+}
+
+.auth-form label {
+ font-weight: 600;
+ margin-top: 0.5rem;
+}
+
+.auth-form input {
+ padding: 0.5rem;
+ border: 1px solid #dbdbdb;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.auth-form button {
+ margin-top: 0.75rem;
+ padding: 0.6rem;
+ background: #0095f6;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ font-size: 1rem;
+ cursor: pointer;
+}
+
+.auth-form button:hover {
+ background: #007ad9;
+}
+
+/* Flash messages */
+.flash {
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+.flash-error {
+ background: #fdecea;
+ color: #b71c1c;
+ border: 1px solid #f5c6cb;
+}
+
+.flash-success {
+ background: #e8f5e9;
+ color: #1b5e20;
+ border: 1px solid #c8e6c9;
+}
+
@media (max-width: 600px) {
header nav {
flex-direction: column;