aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md42
-rw-r--r--docker-compose.yml6
-rw-r--r--docker/php/Dockerfile10
-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
16 files changed, 670 insertions, 1 deletions
diff --git a/README.md b/README.md
index 4bea6c5..2019f4e 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,48 @@ The autoloader in `bootstrap.php` uses `spl_autoload_register` to automatically
4. `Router::dispatch()` matches the URL against registered patterns and calls the matching controller method
5. The controller fetches data via models and renders a view inside the layout
+## Security concepts
+
+### CSRF (Cross-Site Request Forgery)
+
+Imagine you're logged into Camagru. A malicious website could contain a hidden form that submits a request to `camagru.local/delete-account` — and your browser would automatically send your session cookie along with it, so the server thinks *you* made that request. This is a CSRF attack: the attacker forges a request that rides on your existing session.
+
+**How tokens prevent it:** every form on Camagru includes a hidden field with a random token that was generated server-side and stored in the user's session. When the form is submitted, the server checks that the token in the request matches the one in the session. The attacker's malicious site can't know this token (it's unique per session and never exposed in a URL), so their forged request will be rejected.
+
+In the code, `App\Csrf` handles this:
+- `Csrf::generate()` creates a random token (via `random_bytes()`) and stores it in `$_SESSION`
+- `Csrf::field()` outputs a hidden `<input>` to embed in forms
+- `Csrf::validate()` checks the submitted token against the session using `hash_equals()` — a constant-time comparison that prevents timing attacks (where an attacker measures response times to guess the token character by character)
+
+### Password hashing
+
+Passwords are never stored in plain text. PHP's `password_hash()` uses bcrypt by default, which:
+1. Generates a random **salt** (so two users with the same password get different hashes)
+2. Runs the password through a deliberately slow hashing algorithm (to make brute-force attacks impractical)
+3. Produces a string that embeds the algorithm, cost factor, salt, and hash — so `password_verify()` can check a password without needing to know the salt separately
+
+### Prepared statements
+
+SQL injection happens when user input is concatenated directly into a query: `"SELECT * FROM users WHERE name = '$name'"` — if `$name` is `' OR 1=1 --`, the attacker gets all users. Prepared statements separate the query structure from the data: the database compiles the query first, then plugs in the values, so user input can never be interpreted as SQL. PDO's `prepare()` + `execute()` handle this.
+
+### XSS (Cross-Site Scripting)
+
+If user-generated content (usernames, comments) is rendered directly into HTML, an attacker could inject `<script>alert('hacked')</script>` and it would execute in other users' browsers. `htmlspecialchars()` escapes characters like `<`, `>`, `&`, `"` into their HTML entities (`&lt;`, `&gt;`, etc.) so the browser displays them as text instead of interpreting them as code. Every piece of user data rendered in a view must be escaped this way.
+
+### Sessions
+
+HTTP is stateless — the server doesn't inherently know that two requests come from the same person. PHP sessions solve this: `session_start()` generates a unique session ID, stores it in a cookie on the client, and creates a server-side data store (`$_SESSION`) keyed by that ID. On every request, the browser sends the cookie, PHP looks up the matching session data, and the server knows who you are. This is how login state, CSRF tokens, and flash messages persist across page loads.
+
+### Session fixation
+
+A session fixation attack works like this: the attacker visits the site and gets a session ID (say `abc123`). They then trick the victim into using that same session ID — for example by sending a link like `http://camagru.local/?PHPSESSID=abc123`. If the victim clicks the link, logs in, and the server keeps using `abc123`, the attacker now has a valid logged-in session because they already know the ID.
+
+`session_regenerate_id(true)` prevents this by generating a brand-new session ID the moment a user logs in. The old ID (`abc123`) is destroyed and becomes useless. Even if the attacker planted it, they can't use it after the victim authenticates — the real session now lives under a new, unknown ID.
+
+### User enumeration
+
+When a login fails, our error message says "Invalid username or password" rather than "Username not found" or "Wrong password". Similarly, the forgot-password form always says "If that email is registered, a reset link has been sent." This is deliberate: if the server told you *which* part was wrong, an attacker could probe the system to build a list of valid usernames or emails, then target those accounts specifically with brute-force or phishing attacks.
+
## Image format choices
- **Overlays** are PNG (alpha channel required for transparency)
diff --git a/docker-compose.yml b/docker-compose.yml
index a173959..1c6968f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,6 +16,12 @@ services:
- .env
depends_on:
- mariadb
+ - mailpit
+
+ mailpit:
+ image: axllent/mailpit:v1.24
+ ports:
+ - "8025:8025"
mariadb:
image: mariadb:10.11.11
diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile
index 8626073..375e3a7 100644
--- a/docker/php/Dockerfile
+++ b/docker/php/Dockerfile
@@ -9,6 +9,16 @@ RUN apt-get update && apt-get install -y \
&& docker-php-ext-install -j$(nproc) gd pdo_mysql \
&& rm -rf /var/lib/apt/lists/*
+# Tell PHP to use msmtp as sendmail, and configure msmtp to relay through mailpit
+RUN echo "sendmail_path = /usr/bin/msmtp -t" > /usr/local/etc/php/conf.d/mail.ini
+
+RUN echo "account default\n\
+host mailpit\n\
+port 1025\n\
+from noreply@camagru.local\n\
+auth off\n\
+tls off" > /etc/msmtprc
+
RUN echo "upload_max_filesize = 10M" > /usr/local/etc/php/conf.d/uploads.ini \
&& echo "post_max_size = 10M" >> /usr/local/etc/php/conf.d/uploads.ini
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;