From f9b65cc4a737c079825cf66cf26f4e703463d40b Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Thu, 3 Sep 2026 20:04:49 +0100 Subject: [PATCH] Add stage 7: email verification magic links and a profile page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail() (the default fallback), and log-to-file transports, selected by MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token and sends the link (APP_URL/verify-email?token=...). - Migration 005: email_verifications table + users.verification_email_sent_at. - Registration now emails a verification link (best effort — a send failure doesn't fail registration). - POST /api/auth/verify-email consumes a token and returns a session, so opening the link verifies the address (or applies a pending email change) and logs the user in. Single-use; distinct 400s for invalid/used/expired. - POST /api/email/verification resends; POST /api/email/change requests a deferred change (current password required; link goes to the new address; users.email only updates when that link is opened). Both throttled to once per 60s, returning 429 + retry_after. - GET /api/me and every session payload now include pending_email. Shared SessionPayload builds the user/session JSON for all entry points. Frontend - /verify-email view: posts the token, adopts the returned session, redirects. - /profile view: shows address + status, a resend button with a live cooldown (driven by retry_after / 429), and a change-email form (new address + current password) that surfaces the pending change. - Header shows a "verify email" badge linking to the profile. Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle, and deferred change; AuthTest folded into ApiTestCase, which now routes mail to a per-test log. Suite: 32 passing. Co-Authored-By: Claude Sonnet 5 --- .env.example | 20 ++ README.md | 54 +++++- composer.json | 5 +- composer.lock | 84 ++++++++- docker-compose.yml | 12 ++ .../005_add_email_verification_tokens.sql | 18 ++ src/Auth/SessionPayload.php | 54 ++++++ src/Http/Controllers/AuthController.php | 58 ++---- src/Http/Controllers/Controller.php | 6 +- .../EmailVerificationController.php | 176 ++++++++++++++++++ src/Mail/EmailVerifier.php | 74 ++++++++ src/Mail/LogMailer.php | 35 ++++ src/Mail/MailException.php | 11 ++ src/Mail/Mailer.php | 15 ++ src/Mail/PhpMailerMailer.php | 67 +++++++ .../EmailVerificationRepository.php | 98 ++++++++++ src/Repository/UserRepository.php | 31 ++- src/Support/Config.php | 24 ++- src/Support/MailConfig.php | 29 +++ src/bootstrap.php | 22 ++- tests/ApiTestCase.php | 89 ++++++++- tests/AuthTest.php | 60 +----- tests/EmailVerificationTest.php | 176 ++++++++++++++++++ web/README.md | 21 ++- web/src/App.vue | 8 +- web/src/lib/api.ts | 6 + web/src/router/index.ts | 12 ++ web/src/stores/auth.ts | 36 ++++ web/src/style.css | 5 + web/src/types.ts | 2 + web/src/views/ProfileView.vue | 139 ++++++++++++++ web/src/views/VerifyEmailView.vue | 54 ++++++ 32 files changed, 1374 insertions(+), 127 deletions(-) create mode 100644 migrations/005_add_email_verification_tokens.sql create mode 100644 src/Auth/SessionPayload.php create mode 100644 src/Http/Controllers/EmailVerificationController.php create mode 100644 src/Mail/EmailVerifier.php create mode 100644 src/Mail/LogMailer.php create mode 100644 src/Mail/MailException.php create mode 100644 src/Mail/Mailer.php create mode 100644 src/Mail/PhpMailerMailer.php create mode 100644 src/Repository/EmailVerificationRepository.php create mode 100644 src/Support/MailConfig.php create mode 100644 tests/EmailVerificationTest.php create mode 100644 web/src/views/ProfileView.vue create mode 100644 web/src/views/VerifyEmailView.vue diff --git a/.env.example b/.env.example index e8fd9ca..ae6980d 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,23 @@ JWT_SECRET= # How long an issued token stays valid, in seconds (default: 86400 = 24h). JWT_TTL=86400 + +# Base URL of the frontend. Verification magic links point here, e.g. +# /verify-email?token=... (default: http://localhost:5173). +APP_URL=http://localhost:5173 + +# Email delivery. +# mail — PHP's built-in mail() function (default) +# smtp — the SMTP server configured below +# log — append messages to MAIL_LOG_PATH instead of sending (dev/test) +MAIL_TRANSPORT=mail +MAIL_FROM=no-reply@localhost +MAIL_FROM_NAME=Todo List +MAIL_LOG_PATH=storage/mail.log + +# Only used when MAIL_TRANSPORT=smtp. +MAIL_SMTP_HOST= +MAIL_SMTP_PORT=587 +MAIL_SMTP_USERNAME= +MAIL_SMTP_PASSWORD= +MAIL_SMTP_ENCRYPTION=tls # tls | ssl | none diff --git a/README.md b/README.md index 61300f0..9e4de45 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/). | 3 | Todo list + item CRUD API | ✅ done | | 4 | Frontend lists view — list index + create form | ✅ done | | 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done | +| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done | +| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done | -Registration signs the user in immediately, with the account's email marked -unverified (`user.email_verified` is `false` until a future stage adds a -verification endpoint). +Registration signs the user in immediately and emails a magic link that verifies +the address; `user.email_verified` stays `false` until the link is opened. See +[Email verification](#email-verification--profile). ## Run with Docker @@ -99,6 +101,15 @@ environment). See [.env.example](.env.example). | `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location | | `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key | | `JWT_TTL` | `86400` | Token lifetime in seconds | +| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links | +| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) | +| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@localhost` / `Todo List` | Envelope sender | +| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | +| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` | + +SMTP is opt-in; without it the API falls back to PHP's `mail()`. The Docker +Compose setup sets `MAIL_TRANSPORT=log` (the container has no MTA) — read the +links with `docker compose exec app cat /var/www/storage/mail.log`. ## API @@ -128,6 +139,7 @@ Request: "email": "ada@example.com", "email_verified": false, "email_verified_at": null, + "pending_email": null, "created_at": "2026-09-03T12:00:00Z" }, "token": "", @@ -166,13 +178,43 @@ Requires `Authorization: Bearer `. "email": "ada@example.com", "email_verified": false, "email_verified_at": null, + "pending_email": null, "created_at": "2026-09-03T12:00:00Z" } } ``` +`pending_email` is the address a still-valid email-change link is waiting on, or +`null`. + `401` if the header is missing, malformed, or the token is invalid/expired. +### Email verification & profile + +Registration emails a magic link — `/verify-email?token=` — that +expires **15 minutes** after it is sent. Only a hash of the token is stored. + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `POST` | `/api/auth/verify-email` | — | consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in | +| `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified | +| `POST` | `/api/email/change` | ✔ | request a **deferred** email change | + +`POST /api/auth/verify-email` body: `{ "token": "..." }`. Success returns the +same `{ user, token, expires_at }` envelope as login. A missing/invalid, already +used, or expired token is `400` (distinct messages). + +`POST /api/email/verification` and `/api/email/change` are throttled to **once +per 60 seconds** per user (shared window). When throttled they return `429` with +`error.details.retry_after` (seconds). On success they return `202` with +`retry_after`, and `/api/email/change` also returns `pending_email`. + +`POST /api/email/change` body: `{ "email": "new@example.com", "password": "" }`. +The current password is required (`422` if wrong). The address must be free +(`409`) and different from the current one (`422`). The change is **not applied +until** the magic link sent to the new address is opened — until then `GET +/api/me` shows the old address with `pending_email` set. + ### Todo lists All routes below require `Authorization: Bearer `. A list belongs to one @@ -308,9 +350,11 @@ src/Support/Config.php Environment-driven configuration src/Support/Database.php PDO/SQLite connection src/Auth/JwtService.php Issue/verify JWTs src/Auth/AuthMiddleware.php Bearer-token authentication +src/Auth/SessionPayload.php Shared user + session JSON shape +src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier src/Http/JsonErrorHandler.php Uniform JSON error envelope -src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem) -src/Repository/ Database access (User, TodoList, TodoItem) +src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem) +src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem) src/Support/Validator.php Request-body validation helper migrations/*.sql Schema, applied by bin/migrate.php Dockerfile PHP 8.3 + Apache image diff --git a/composer.json b/composer.json index b81577a..214e894 100644 --- a/composer.json +++ b/composer.json @@ -9,9 +9,10 @@ "ext-mbstring": "*", "ext-pdo": "*", "ext-pdo_sqlite": "*", - "slim/slim": "^4.12", - "slim/psr7": "^1.6", "firebase/php-jwt": "^7.0", + "phpmailer/phpmailer": "^7.1", + "slim/psr7": "^1.6", + "slim/slim": "^4.12", "vlucas/phpdotenv": "^5.6" }, "require-dev": { diff --git a/composer.lock b/composer.lock index d8d927a..8d9e934 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4cafeb9bb67033ef6767999616374842", + "content-hash": "0ee5526493b6ed24fc6d7f3a68af11cc", "packages": [ { "name": "fig/http-message-util", @@ -240,6 +240,88 @@ }, "time": "2026-07-09T19:38:47+00:00" }, + { + "name": "phpmailer/phpmailer", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "squizlabs/php_codesniffer": "^3.13.5", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example", + "ext-imap": "Needed to support advanced email address parsing according to RFC822", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2026-05-18T08:06:14+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", diff --git a/docker-compose.yml b/docker-compose.yml index a366697..1c96b04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,18 @@ services: # Leave blank to auto-generate a secret into the storage volume on first run. JWT_SECRET: "${JWT_SECRET:-}" JWT_TTL: "${JWT_TTL:-86400}" + # Magic links point at the frontend dev server. + APP_URL: "${APP_URL:-http://localhost:5173}" + # The container has no MTA, so log emails to a file by default. View them with: + # docker compose exec app cat /var/www/storage/mail.log + # Set to "smtp" (with MAIL_SMTP_*) or "mail" to actually send. + MAIL_TRANSPORT: "${MAIL_TRANSPORT:-log}" + MAIL_FROM: "${MAIL_FROM:-no-reply@localhost}" + MAIL_SMTP_HOST: "${MAIL_SMTP_HOST:-}" + MAIL_SMTP_PORT: "${MAIL_SMTP_PORT:-587}" + MAIL_SMTP_USERNAME: "${MAIL_SMTP_USERNAME:-}" + MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}" + MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-tls}" volumes: # Live source: edit on the host, no image rebuild needed. - .:/var/www/html diff --git a/migrations/005_add_email_verification_tokens.sql b/migrations/005_add_email_verification_tokens.sql new file mode 100644 index 0000000..dd0968c --- /dev/null +++ b/migrations/005_add_email_verification_tokens.sql @@ -0,0 +1,18 @@ +-- Magic-link tokens for verifying an email address. `new_email` is null for a +-- plain "verify your current address" link, or the requested address for a +-- deferred email change (applied only when the link is opened). +CREATE TABLE IF NOT EXISTS email_verifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + new_email TEXT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications (user_id); + +-- When the last verification / change email was sent to this user, for the +-- once-per-minute resend throttle. +ALTER TABLE users ADD COLUMN verification_email_sent_at TEXT NULL; diff --git a/src/Auth/SessionPayload.php b/src/Auth/SessionPayload.php new file mode 100644 index 0000000..fcd1a36 --- /dev/null +++ b/src/Auth/SessionPayload.php @@ -0,0 +1,54 @@ +, token: string, expires_at: string} + */ + public function forUser(array $user): array + { + $issued = $this->jwt->issue($user); + + return [ + 'user' => $this->present($user), + 'token' => $issued['token'], + 'expires_at' => $issued['expires_at'], + ]; + } + + /** + * @param array{id: int, email: string, email_verified_at?: string|null, created_at?: string} $user + * @return array + */ + public function present(array $user): array + { + $verifiedAt = $user['email_verified_at'] ?? null; + + return [ + 'id' => (int) $user['id'], + 'email' => $user['email'], + 'email_verified' => $verifiedAt !== null, + 'email_verified_at' => $verifiedAt, + 'pending_email' => $this->tokens->pendingEmailFor((int) $user['id']), + 'created_at' => $user['created_at'] ?? null, + ]; + } +} diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php index 076dbdc..45912b5 100644 --- a/src/Http/Controllers/AuthController.php +++ b/src/Http/Controllers/AuthController.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\Http\Controllers; -use App\Auth\JwtService; +use App\Auth\SessionPayload; use App\Exception\ApiException; use App\Exception\ValidationException; +use App\Mail\EmailVerifier; +use App\Mail\MailException; use App\Repository\UserRepository; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -22,7 +24,8 @@ final class AuthController extends Controller public function __construct( private readonly UserRepository $users, - private readonly JwtService $jwt, + private readonly SessionPayload $session, + private readonly EmailVerifier $verifier, ) { } @@ -39,7 +42,15 @@ final class AuthController extends Controller $user = $this->users->create($email, password_hash($password, PASSWORD_DEFAULT)); - return $this->json($response, $this->session($user), 201); + // Best effort: a failed send must not fail registration — the user can + // resend from their profile. + try { + $this->verifier->sendVerification($user); + } catch (MailException $e) { + error_log('Verification email failed for user ' . $user['id'] . ': ' . $e->getMessage()); + } + + return $this->json($response, $this->session->forUser($user), 201); } /** @@ -56,7 +67,7 @@ final class AuthController extends Controller throw new ApiException('Invalid email or password.', 401); } - return $this->json($response, $this->session($user)); + return $this->json($response, $this->session->forUser($user)); } /** @@ -64,10 +75,7 @@ final class AuthController extends Controller */ public function me(Request $request, Response $response): Response { - /** @var array{id: int, email: string, email_verified_at: string|null, created_at: string} $user */ - $user = $request->getAttribute('user'); - - return $this->json($response, ['user' => $this->presentUser($user)]); + return $this->json($response, ['user' => $this->session->present($this->user($request))]); } /** @@ -106,38 +114,4 @@ final class AuthController extends Controller return [mb_strtolower($email), $password]; } - - /** - * Build the standard authentication payload returned by register and login. - * - * @param array{id: int, email: string, email_verified_at: string|null, created_at: string} $user - * @return array - */ - private function session(array $user): array - { - $token = $this->jwt->issue($user); - - return [ - 'user' => $this->presentUser($user), - 'token' => $token['token'], - 'expires_at' => $token['expires_at'], - ]; - } - - /** - * @param array{id: int, email: string, email_verified_at?: string|null, created_at?: string} $user - * @return array - */ - private function presentUser(array $user): array - { - $verifiedAt = $user['email_verified_at'] ?? null; - - return [ - 'id' => (int) $user['id'], - 'email' => $user['email'], - 'email_verified' => $verifiedAt !== null, - 'email_verified_at' => $verifiedAt, - 'created_at' => $user['created_at'] ?? null, - ]; - } } diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php index 651bb34..41a5081 100644 --- a/src/Http/Controllers/Controller.php +++ b/src/Http/Controllers/Controller.php @@ -41,13 +41,13 @@ abstract class Controller } /** - * The authenticated user attached by AuthMiddleware. + * The authenticated user row attached by AuthMiddleware. * - * @return array{id: int, email: string, email_verified_at: string|null, created_at: string} + * @return array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string} */ protected function user(Request $request): array { - /** @var array{id: int, email: string, email_verified_at: string|null, created_at: string} $user */ + /** @var array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string} $user */ $user = $request->getAttribute('user'); return $user; diff --git a/src/Http/Controllers/EmailVerificationController.php b/src/Http/Controllers/EmailVerificationController.php new file mode 100644 index 0000000..4d8e5a2 --- /dev/null +++ b/src/Http/Controllers/EmailVerificationController.php @@ -0,0 +1,176 @@ +body($request)['token'] ?? '')); + if ($token === '') { + throw new ValidationException(['token' => ['A verification token is required.']]); + } + + $row = $this->tokens->findByHash(hash('sha256', $token)); + + if ($row === null) { + throw new ApiException('This verification link is not valid.', 400); + } + if ($row['consumed_at'] !== null) { + throw new ApiException('This verification link has already been used.', 400); + } + if (strtotime($row['expires_at']) < time()) { + throw new ApiException('This verification link has expired. Request a new one.', 400); + } + + if ($row['new_email'] !== null) { + $clash = $this->users->findByEmail($row['new_email']); + if ($clash !== null && $clash['id'] !== $row['user_id']) { + throw new ApiException('That email address is now in use by another account.', 409); + } + } + + if (!$this->tokens->consume($row['id'])) { + throw new ApiException('This verification link has already been used.', 400); + } + + if ($row['new_email'] !== null) { + $this->users->updateEmail($row['user_id'], $row['new_email']); + } else { + $this->users->markEmailVerified($row['user_id']); + } + + $user = $this->users->findById($row['user_id']); + if ($user === null) { + throw new ApiException('The account for this link no longer exists.', 404); + } + + return $this->json($response, $this->session->forUser($user)); + } + + /** + * POST /api/email/verification (auth) — resend the verification email. + */ + public function resend(Request $request, Response $response): Response + { + $user = $this->user($request); + + if (($user['email_verified_at'] ?? null) !== null) { + throw new ApiException('Your email address is already verified.', 409); + } + + $this->guardResendInterval($user); + + try { + $this->verifier->sendVerification($user); + } catch (MailException $e) { + throw new ApiException('Could not send the email right now. Please try again shortly.', 502); + } + + return $this->json($response, [ + 'message' => 'Verification email sent.', + 'retry_after' => self::RESEND_INTERVAL_SECONDS, + ], 202); + } + + /** + * POST /api/email/change (auth) — request a deferred email change. The new + * address only takes effect once its magic link is opened. + */ + public function requestChange(Request $request, Response $response): Response + { + $user = $this->user($request); + $body = $this->body($request); + + $newEmail = is_string($body['email'] ?? null) ? mb_strtolower(trim($body['email'])) : ''; + $password = is_string($body['password'] ?? null) ? $body['password'] : ''; + + $errors = []; + if ($newEmail === '') { + $errors['email'][] = 'Email is required.'; + } elseif (!filter_var($newEmail, FILTER_VALIDATE_EMAIL) || strlen($newEmail) > self::EMAIL_MAX) { + $errors['email'][] = 'Enter a valid email address.'; + } elseif ($newEmail === mb_strtolower($user['email'])) { + $errors['email'][] = 'That is already your email address.'; + } + if ($password === '' || strlen($password) > self::PASSWORD_MAX) { + $errors['password'][] = 'Your current password is required.'; + } + if ($errors !== []) { + throw new ValidationException($errors); + } + + $full = $this->users->findById($user['id']); + if ($full === null || !password_verify($password, $full['password_hash'])) { + throw new ValidationException(['password' => ['That password is incorrect.']]); + } + + if ($this->users->findByEmail($newEmail) !== null) { + throw new ApiException('That email address is already in use.', 409); + } + + $this->guardResendInterval($user); + + try { + $this->verifier->sendEmailChange($user, $newEmail); + } catch (MailException $e) { + throw new ApiException('Could not send the email right now. Please try again shortly.', 502); + } + + return $this->json($response, [ + 'message' => 'Confirmation email sent to the new address.', + 'pending_email' => $newEmail, + 'retry_after' => self::RESEND_INTERVAL_SECONDS, + ], 202); + } + + /** + * @param array{verification_email_sent_at?: string|null} $user + */ + private function guardResendInterval(array $user): void + { + $lastSent = $user['verification_email_sent_at'] ?? null; + if ($lastSent === null) { + return; + } + + $elapsed = time() - (int) strtotime($lastSent); + if ($elapsed < self::RESEND_INTERVAL_SECONDS) { + throw new ApiException( + 'Please wait a moment before requesting another email.', + 429, + ['retry_after' => self::RESEND_INTERVAL_SECONDS - $elapsed], + ); + } + } +} diff --git a/src/Mail/EmailVerifier.php b/src/Mail/EmailVerifier.php new file mode 100644 index 0000000..5a111bc --- /dev/null +++ b/src/Mail/EmailVerifier.php @@ -0,0 +1,74 @@ +issue((int) $user['id'], null); + + $this->mailer->send( + $user['email'], + 'Verify your email address', + "Welcome!\n\n" + . "Confirm this email address by opening the link below. It expires in 15 minutes.\n\n" + . $link . "\n\n" + . "If you didn't create an account, you can ignore this message.\n", + ); + } + + /** + * Send a link (to the new address) that, once opened, changes the user's + * email to $newEmail. + * + * @param array{id: int, email: string} $user + */ + public function sendEmailChange(array $user, string $newEmail): void + { + $link = $this->issue((int) $user['id'], $newEmail); + + $this->mailer->send( + $newEmail, + 'Confirm your new email address', + "A request was made to change the email address on an account to this one.\n\n" + . "Confirm the change by opening the link below. It expires in 15 minutes.\n\n" + . $link . "\n\n" + . "If this wasn't you, you can ignore this message; nothing will change.\n", + ); + } + + private function issue(int $userId, ?string $newEmail): string + { + $token = bin2hex(random_bytes(32)); + + $this->tokens->issue($userId, hash('sha256', $token), $newEmail, self::TOKEN_TTL_SECONDS); + $this->users->markVerificationEmailSent($userId); + + return $this->appUrl . '/verify-email?token=' . $token; + } +} diff --git a/src/Mail/LogMailer.php b/src/Mail/LogMailer.php new file mode 100644 index 0000000..e7d1c2f --- /dev/null +++ b/src/Mail/LogMailer.php @@ -0,0 +1,35 @@ +path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + $line = json_encode([ + 'sent_at' => gmdate('c'), + 'to' => $to, + 'subject' => $subject, + 'body' => $body, + ], JSON_UNESCAPED_SLASHES) . "\n"; + + if (file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX) === false) { + throw new MailException("Could not write to the mail log at {$this->path}."); + } + } +} diff --git a/src/Mail/MailException.php b/src/Mail/MailException.php new file mode 100644 index 0000000..4421b27 --- /dev/null +++ b/src/Mail/MailException.php @@ -0,0 +1,11 @@ +config->transport === 'smtp') { + $this->configureSmtp($mail); + } else { + $mail->isMail(); + } + + $mail->setFrom($this->config->fromAddress, $this->config->fromName); + $mail->addAddress($to); + $mail->Subject = $subject; + $mail->isHTML(false); + $mail->Body = $body; + + $mail->send(); + } catch (PhpMailerException $e) { + throw new MailException('Failed to send email: ' . $e->getMessage(), 0, $e); + } + } + + private function configureSmtp(PHPMailer $mail): void + { + $mail->isSMTP(); + $mail->Host = (string) $this->config->smtpHost; + $mail->Port = $this->config->smtpPort; + + if ($this->config->smtpUsername !== null) { + $mail->SMTPAuth = true; + $mail->Username = $this->config->smtpUsername; + $mail->Password = (string) $this->config->smtpPassword; + } + + switch ($this->config->smtpEncryption) { + case 'ssl': + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + break; + case 'none': + $mail->SMTPSecure = ''; + $mail->SMTPAutoTLS = false; + break; + default: + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + } +} diff --git a/src/Repository/EmailVerificationRepository.php b/src/Repository/EmailVerificationRepository.php new file mode 100644 index 0000000..fcf520d --- /dev/null +++ b/src/Repository/EmailVerificationRepository.php @@ -0,0 +1,98 @@ +pdo + ->prepare("DELETE FROM email_verifications WHERE user_id = :user AND consumed_at IS NULL AND {$nullClause}") + ->execute(['user' => $userId]); + + $insert = $this->pdo->prepare( + 'INSERT INTO email_verifications (user_id, token_hash, new_email, expires_at) + VALUES (:user, :hash, :new_email, :expires_at)' + ); + $insert->execute([ + 'user' => $userId, + 'hash' => $tokenHash, + 'new_email' => $newEmail, + 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', time() + $ttlSeconds), + ]); + } + + /** + * @return TokenRow|null + */ + public function findByHash(string $tokenHash): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM email_verifications WHERE token_hash = :hash'); + $stmt->execute(['hash' => $tokenHash]); + + $row = $stmt->fetch(); + if ($row === false) { + return null; + } + + $row['id'] = (int) $row['id']; + $row['user_id'] = (int) $row['user_id']; + + /** @var TokenRow $row */ + return $row; + } + + /** + * Mark the token consumed. Returns false if it was already consumed (race). + */ + public function consume(int $id): bool + { + $stmt = $this->pdo->prepare( + "UPDATE email_verifications SET consumed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = :id AND consumed_at IS NULL" + ); + $stmt->execute(['id' => $id]); + + return $stmt->rowCount() === 1; + } + + /** + * The address a still-valid change request is waiting on, if any. + */ + public function pendingEmailFor(int $userId): ?string + { + $stmt = $this->pdo->prepare( + "SELECT new_email FROM email_verifications + WHERE user_id = :user AND new_email IS NOT NULL AND consumed_at IS NULL + AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + ORDER BY id DESC LIMIT 1" + ); + $stmt->execute(['user' => $userId]); + + $value = $stmt->fetchColumn(); + + return $value === false ? null : (string) $value; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 0aafafa..831b8bc 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -9,7 +9,7 @@ use PDO; /** * Data access for the `users` table. Rows are returned as associative arrays. * - * @phpstan-type UserRow array{id: int, email: string, password_hash: string, email_verified_at: string|null, created_at: string, updated_at: string} + * @phpstan-type UserRow array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string} */ final class UserRepository { @@ -62,6 +62,35 @@ final class UserRepository return $user; } + public function markEmailVerified(int $id): void + { + $this->pdo->prepare( + "UPDATE users SET email_verified_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = :id" + )->execute(['id' => $id]); + } + + /** + * Apply a confirmed email change: set the address and mark it verified. + */ + public function updateEmail(int $id, string $email): void + { + $this->pdo->prepare( + "UPDATE users SET email = :email, + email_verified_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = :id" + )->execute(['email' => $email, 'id' => $id]); + } + + public function markVerificationEmailSent(int $id): void + { + $this->pdo->prepare( + "UPDATE users SET verification_email_sent_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = :id" + )->execute(['id' => $id]); + } + /** * @param array $row * @return UserRow diff --git a/src/Support/Config.php b/src/Support/Config.php index 3b17f79..8a9c248 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -15,6 +15,9 @@ final class Config public readonly string $jwtSecret, public readonly int $jwtTtl, public readonly bool $displayErrors, + /** Base URL of the frontend, used to build magic links. */ + public readonly string $appUrl, + public readonly MailConfig $mail, ) { } @@ -38,7 +41,26 @@ final class Config $jwtTtl = (int) (self::env('JWT_TTL') ?? '86400'); $displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL); - return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors); + $appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/'); + + $mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log'); + if (!self::isAbsolutePath($mailLogPath)) { + $mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/'); + } + + $mail = new MailConfig( + transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')), + fromAddress: self::env('MAIL_FROM', 'no-reply@localhost'), + fromName: self::env('MAIL_FROM_NAME', 'Todo List'), + logPath: $mailLogPath, + smtpHost: self::env('MAIL_SMTP_HOST'), + smtpPort: (int) (self::env('MAIL_SMTP_PORT') ?? '587'), + smtpUsername: self::env('MAIL_SMTP_USERNAME'), + smtpPassword: self::env('MAIL_SMTP_PASSWORD'), + smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')), + ); + + return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors, $appUrl, $mail); } private static function env(string $key, ?string $default = null): ?string diff --git a/src/Support/MailConfig.php b/src/Support/MailConfig.php new file mode 100644 index 0000000..c69de31 --- /dev/null +++ b/src/Support/MailConfig.php @@ -0,0 +1,29 @@ +setDefaultErrorHandler( $users = new UserRepository($database->pdo()); $todoLists = new TodoListRepository($database->pdo()); $todoItems = new TodoItemRepository($database->pdo()); +$verificationTokens = new EmailVerificationRepository($database->pdo()); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl); +$session = new SessionPayload($jwt, $verificationTokens); -$authController = new AuthController($users, $jwt); +/** @var Mailer $mailer */ +$mailer = $config->mail->transport === 'log' + ? new LogMailer($config->mail->logPath) + : new PhpMailerMailer($config->mail); +$verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->appUrl); + +$authController = new AuthController($users, $session, $verifier); +$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $listController = new TodoListController($todoLists); $itemController = new TodoItemController($todoLists, $todoItems); $authMiddleware = new AuthMiddleware($jwt, $users); @@ -47,6 +63,7 @@ $authMiddleware = new AuthMiddleware($jwt, $users); $app->group('/api', function (RouteCollectorProxy $group) use ( $authController, + $emailController, $listController, $itemController, $authMiddleware, @@ -58,8 +75,11 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $group->post('/auth/register', [$authController, 'register']); $group->post('/auth/login', [$authController, 'login']); + $group->post('/auth/verify-email', [$emailController, 'verify']); $group->get('/me', [$authController, 'me'])->add($authMiddleware); + $group->post('/email/verification', [$emailController, 'resend'])->add($authMiddleware); + $group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware); $group->group('/lists', function (RouteCollectorProxy $lists) use ($listController, $itemController) { $lists->get('', [$listController, 'index']); diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index e089cc8..94d6839 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -12,20 +12,32 @@ use Slim\Psr7\Factory\ServerRequestFactory; /** * Boots the real Slim app against a throwaway SQLite database with all - * migrations applied. + * migrations applied. Email goes to a per-test log file (MAIL_TRANSPORT=log). */ abstract class ApiTestCase extends TestCase { protected App $app; private string $databasePath; + private string $mailLogPath; + private ?PDO $db = null; + + /** @var array */ + private array $env = []; protected function setUp(): void { - $this->databasePath = sys_get_temp_dir() . '/todo-test-' . uniqid() . '.sqlite'; - putenv('DATABASE_PATH=' . $this->databasePath); - $_ENV['DATABASE_PATH'] = $this->databasePath; + $unique = uniqid('todo-test-', true); + $this->databasePath = sys_get_temp_dir() . "/{$unique}.sqlite"; + $this->mailLogPath = sys_get_temp_dir() . "/{$unique}.mail.log"; - $pdo = new PDO('sqlite:' . $this->databasePath); + $this->setEnv([ + 'DATABASE_PATH' => $this->databasePath, + 'APP_URL' => 'https://app.test', + 'MAIL_TRANSPORT' => 'log', + 'MAIL_LOG_PATH' => $this->mailLogPath, + ]); + + $pdo = $this->db(); $pdo->exec('PRAGMA foreign_keys = ON'); foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $migration) { $pdo->exec((string) file_get_contents($migration)); @@ -36,9 +48,39 @@ abstract class ApiTestCase extends TestCase protected function tearDown(): void { + $this->db = null; @unlink($this->databasePath); - putenv('DATABASE_PATH'); - unset($_ENV['DATABASE_PATH']); + @unlink($this->mailLogPath); + + foreach (array_keys($this->env) as $key) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } + $this->env = []; + } + + /** @param array $vars */ + private function setEnv(array $vars): void + { + foreach ($vars as $key => $value) { + putenv("{$key}={$value}"); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + $this->env[$key] = $value; + } + } + + /** A connection to the test database, for seeding rows directly. */ + protected function db(): PDO + { + if ($this->db === null) { + $this->db = new PDO('sqlite:' . $this->databasePath, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + + return $this->db; } /** @@ -78,6 +120,39 @@ abstract class ApiTestCase extends TestCase return ['Authorization' => 'Bearer ' . $token]; } + /** All emails sent so far, oldest first. @return list */ + protected function sentEmails(): array + { + if (!is_file($this->mailLogPath)) { + return []; + } + + $lines = array_filter(explode("\n", (string) file_get_contents($this->mailLogPath))); + + return array_map( + static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), + array_values($lines), + ); + } + + /** @return array{to: string, subject: string, body: string} */ + protected function lastEmail(): array + { + $emails = $this->sentEmails(); + self::assertNotEmpty($emails, 'Expected an email to have been sent.'); + + return $emails[array_key_last($emails)]; + } + + /** Pull the magic-link token out of an email body. */ + protected function tokenFromEmail(?array $email = null): string + { + $email ??= $this->lastEmail(); + self::assertSame(1, preg_match('/verify-email\?token=([a-f0-9]+)/', $email['body'], $m)); + + return $m[1]; + } + /** * @return array */ diff --git a/tests/AuthTest.php b/tests/AuthTest.php index ebcdf2e..57a85d5 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -4,38 +4,8 @@ declare(strict_types=1); namespace Tests; -use PDO; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseInterface; -use Slim\App; -use Slim\Psr7\Factory\ServerRequestFactory; - -final class AuthTest extends TestCase +final class AuthTest extends ApiTestCase { - private App $app; - private string $databasePath; - - protected function setUp(): void - { - $this->databasePath = sys_get_temp_dir() . '/todo-test-' . uniqid() . '.sqlite'; - putenv('DATABASE_PATH=' . $this->databasePath); - $_ENV['DATABASE_PATH'] = $this->databasePath; - - $pdo = new PDO('sqlite:' . $this->databasePath); - foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $migration) { - $pdo->exec((string) file_get_contents($migration)); - } - - $this->app = require dirname(__DIR__) . '/src/bootstrap.php'; - } - - protected function tearDown(): void - { - @unlink($this->databasePath); - putenv('DATABASE_PATH'); - unset($_ENV['DATABASE_PATH']); - } - public function test_registration_returns_a_user_and_token(): void { $response = $this->request('POST', '/api/auth/register', [ @@ -49,6 +19,7 @@ final class AuthTest extends TestCase self::assertSame('ada@example.com', $body['user']['email']); self::assertFalse($body['user']['email_verified']); self::assertNull($body['user']['email_verified_at']); + self::assertNull($body['user']['pending_email']); self::assertArrayNotHasKey('password_hash', $body['user']); self::assertNotEmpty($body['token']); } @@ -123,31 +94,4 @@ final class AuthTest extends TestCase self::assertSame(200, $response->getStatusCode()); self::assertSame('linus@example.com', $this->decode($response)['user']['email']); } - - /** - * @param array|null $body - * @param array $headers - */ - private function request(string $method, string $path, ?array $body = null, array $headers = []): ResponseInterface - { - $request = (new ServerRequestFactory())->createServerRequest($method, $path); - - foreach ($headers as $name => $value) { - $request = $request->withHeader($name, $value); - } - - if ($body !== null) { - $request = $request->withParsedBody($body)->withHeader('Content-Type', 'application/json'); - } - - return $this->app->handle($request); - } - - /** - * @return array - */ - private function decode(ResponseInterface $response): array - { - return (array) json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } } diff --git a/tests/EmailVerificationTest.php b/tests/EmailVerificationTest.php new file mode 100644 index 0000000..6b0a379 --- /dev/null +++ b/tests/EmailVerificationTest.php @@ -0,0 +1,176 @@ +request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + + $email = $this->lastEmail(); + self::assertSame('ada@example.com', $email['to']); + self::assertStringContainsString('verify-email?token=', $email['body']); + self::assertStringContainsString('https://app.test/verify-email?token=', $email['body']); + } + + public function test_opening_the_magic_link_verifies_and_logs_in(): void + { + $this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + + $response = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]); + + self::assertSame(200, $response->getStatusCode()); + $body = $this->decode($response); + self::assertTrue($body['user']['email_verified']); + self::assertNotEmpty($body['token']); + + $me = $this->decode($this->request('GET', '/api/me', null, ['Authorization' => 'Bearer ' . $body['token']])); + self::assertTrue($me['user']['email_verified']); + } + + public function test_an_invalid_token_is_rejected(): void + { + $response = $this->request('POST', '/api/auth/verify-email', ['token' => 'not-a-real-token']); + self::assertSame(400, $response->getStatusCode()); + } + + public function test_a_token_cannot_be_used_twice(): void + { + $this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + $token = $this->tokenFromEmail(); + + self::assertSame(200, $this->request('POST', '/api/auth/verify-email', ['token' => $token])->getStatusCode()); + + $again = $this->request('POST', '/api/auth/verify-email', ['token' => $token]); + self::assertSame(400, $again->getStatusCode()); + self::assertStringContainsString('already been used', $this->decode($again)['error']['message']); + } + + public function test_an_expired_token_is_rejected(): void + { + $this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + $token = $this->tokenFromEmail(); + + $this->db()->prepare('UPDATE email_verifications SET expires_at = :past WHERE token_hash = :hash')->execute([ + 'past' => gmdate('Y-m-d\TH:i:s\Z', time() - 60), + 'hash' => hash('sha256', $token), + ]); + + $response = $this->request('POST', '/api/auth/verify-email', ['token' => $token]); + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('expired', $this->decode($response)['error']['message']); + } + + public function test_resend_is_throttled_immediately_after_registration(): void + { + $auth = $this->authHeader('ada@example.com'); + + $response = $this->request('POST', '/api/email/verification', null, $auth); + + self::assertSame(429, $response->getStatusCode()); + self::assertArrayHasKey('retry_after', $this->decode($response)['error']['details']); + } + + public function test_resend_works_once_the_interval_has_passed(): void + { + $auth = $this->authHeader('ada@example.com'); + $this->cooldownElapsed('ada@example.com'); + + $response = $this->request('POST', '/api/email/verification', null, $auth); + + self::assertSame(202, $response->getStatusCode()); + self::assertCount(2, $this->sentEmails()); + self::assertSame('ada@example.com', $this->lastEmail()['to']); + } + + public function test_resend_conflicts_when_already_verified(): void + { + $auth = $this->authHeader('ada@example.com'); + $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]); + $this->cooldownElapsed('ada@example.com'); + + $response = $this->request('POST', '/api/email/verification', null, $auth); + self::assertSame(409, $response->getStatusCode()); + } + + public function test_email_change_is_deferred_until_the_new_address_is_confirmed(): void + { + $auth = $this->authHeader('old@example.com'); + $this->cooldownElapsed('old@example.com'); + + $change = $this->request('POST', '/api/email/change', [ + 'email' => 'New@example.com', + 'password' => 'password123', + ], $auth); + + self::assertSame(202, $change->getStatusCode()); + self::assertSame('new@example.com', $this->decode($change)['pending_email']); + self::assertSame('new@example.com', $this->lastEmail()['to']); + + // Not applied yet. + $me = $this->decode($this->request('GET', '/api/me', null, $auth)); + self::assertSame('old@example.com', $me['user']['email']); + self::assertSame('new@example.com', $me['user']['pending_email']); + + // Open the link from the new inbox. + $verified = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]); + self::assertSame(200, $verified->getStatusCode()); + $body = $this->decode($verified); + self::assertSame('new@example.com', $body['user']['email']); + self::assertTrue($body['user']['email_verified']); + self::assertNull($body['user']['pending_email']); + } + + public function test_email_change_requires_the_current_password(): void + { + $auth = $this->authHeader('old@example.com'); + $this->cooldownElapsed('old@example.com'); + + $response = $this->request('POST', '/api/email/change', [ + 'email' => 'new@example.com', + 'password' => 'wrong-password', + ], $auth); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('password', $this->decode($response)['error']['details']); + } + + public function test_email_change_rejects_an_address_already_in_use(): void + { + $this->authHeader('taken@example.com'); + $auth = $this->authHeader('mine@example.com'); + $this->cooldownElapsed('mine@example.com'); + + $response = $this->request('POST', '/api/email/change', [ + 'email' => 'taken@example.com', + 'password' => 'password123', + ], $auth); + + self::assertSame(409, $response->getStatusCode()); + } + + public function test_email_change_rejects_the_current_address(): void + { + $auth = $this->authHeader('same@example.com'); + $this->cooldownElapsed('same@example.com'); + + $response = $this->request('POST', '/api/email/change', [ + 'email' => 'same@example.com', + 'password' => 'password123', + ], $auth); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('email', $this->decode($response)['error']['details']); + } + + /** Push the user's last-sent timestamp far enough back to clear the throttle. */ + private function cooldownElapsed(string $email): void + { + $this->db() + ->prepare('UPDATE users SET verification_email_sent_at = :ts WHERE email = :email') + ->execute(['ts' => gmdate('Y-m-d\TH:i:s\Z', time() - 120), 'email' => $email]); + } +} diff --git a/web/README.md b/web/README.md index a0be5d4..5ae1188 100644 --- a/web/README.md +++ b/web/README.md @@ -42,7 +42,8 @@ src/stores/lists.ts Pinia store: the user's lists (fetch + create) src/stores/items.ts Pinia store: one list's items (CRUD + drag reorder) src/lib/api.ts fetch wrapper, bearer token, typed ApiError src/components/TodoItemRow.vue checkbox + editable text + delete, one item -src/views/ HomeView (lists), ListView (items), LoginView, RegisterView +src/views/ HomeView, ListView, LoginView, RegisterView, + ProfileView, VerifyEmailView ``` ## List detail @@ -67,5 +68,19 @@ server response replaces local state. - Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended path) when there is no authenticated user. - Registration signs the user in immediately; the new account's email is - unverified (`user.email_verified === false`), surfaced in the header and on the - home page. + unverified (`user.email_verified === false`). The header shows a "verify + email" badge linking to `/profile`. + +## Email verification & profile + +- The registration email links to `/verify-email?token=…`. `VerifyEmailView` + POSTs the token to the API, which returns a session — so opening the link both + verifies the address and signs the user in — then redirects to the lists. +- `/profile` (`ProfileView`) shows the address and verification status. When + unverified it offers a **Resend** button; the API throttles to once a minute, + and the button shows a live countdown (driven by `retry_after`, and by `429` + responses). +- The **Change email** form takes the new address and the current password. + On success the API has emailed a confirmation link to the *new* address and + set `user.pending_email`; the change only lands when that link is opened. The + resend and change actions share the one-minute cooldown. diff --git a/web/src/App.vue b/web/src/App.vue index cd07354..d71eef2 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,5 +1,5 @@ + + diff --git a/web/src/views/VerifyEmailView.vue b/web/src/views/VerifyEmailView.vue new file mode 100644 index 0000000..61571e3 --- /dev/null +++ b/web/src/views/VerifyEmailView.vue @@ -0,0 +1,54 @@ + + +