Add stage 7: email verification magic links and a profile page

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 20:04:49 +01:00
co-authored by Claude Sonnet 5
parent c4c947896e
commit f9b65cc4a7
32 changed files with 1374 additions and 127 deletions
+20
View File
@@ -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.
# <APP_URL>/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
+49 -5
View File
@@ -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": "<jwt>",
@@ -166,13 +178,43 @@ Requires `Authorization: Bearer <jwt>`.
"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 — `<APP_URL>/verify-email?token=<opaque>` — 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": "<current>" }`.
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 <jwt>`. 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
+3 -2
View File
@@ -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": {
Generated
+83 -1
View File
@@ -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",
+12
View File
@@ -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
@@ -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;
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Repository\EmailVerificationRepository;
/**
* Builds the JSON representation of a user and the session envelope returned by
* register / login / email verification. Shared so every entry point agrees on
* the shape.
*/
final class SessionPayload
{
public function __construct(
private readonly JwtService $jwt,
private readonly EmailVerificationRepository $tokens,
) {
}
/**
* @param array{id: int, email: string, email_verified_at: string|null, created_at: string} $user
* @return array{user: array<string, mixed>, 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<string, mixed>
*/
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,
];
}
}
+16 -42
View File
@@ -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<string, mixed>
*/
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<string, mixed>
*/
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,
];
}
}
+3 -3
View File
@@ -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;
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Auth\SessionPayload;
use App\Exception\ApiException;
use App\Exception\ValidationException;
use App\Mail\EmailVerifier;
use App\Mail\MailException;
use App\Repository\EmailVerificationRepository;
use App\Repository\UserRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final class EmailVerificationController extends Controller
{
private const RESEND_INTERVAL_SECONDS = 60;
private const EMAIL_MAX = 255;
private const PASSWORD_MAX = 72;
public function __construct(
private readonly UserRepository $users,
private readonly EmailVerificationRepository $tokens,
private readonly EmailVerifier $verifier,
private readonly SessionPayload $session,
) {
}
/**
* POST /api/auth/verify-email (public)
*
* Consumes a magic-link token: verifies the address (or applies a pending
* email change), then returns a session so the caller is logged in.
*/
public function verify(Request $request, Response $response): Response
{
$token = trim((string) ($this->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],
);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Repository\EmailVerificationRepository;
use App\Repository\UserRepository;
/**
* Issues a magic-link token and emails it, for both "verify your address" and
* "confirm your new address" flows.
*/
final class EmailVerifier
{
public const TOKEN_TTL_SECONDS = 900; // 15 minutes
public function __construct(
private readonly EmailVerificationRepository $tokens,
private readonly UserRepository $users,
private readonly Mailer $mailer,
private readonly string $appUrl,
) {
}
/**
* Send a link that verifies the user's current address.
*
* @param array{id: int, email: string} $user
*/
public function sendVerification(array $user): void
{
$link = $this->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;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mail;
/**
* Appends each message as a JSON line to a file instead of sending it. Handy for
* local development and used by the test suite. Enable with MAIL_TRANSPORT=log.
*/
final class LogMailer implements Mailer
{
public function __construct(private readonly string $path)
{
}
public function send(string $to, string $subject, string $body): void
{
$dir = dirname($this->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}.");
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use RuntimeException;
final class MailException extends RuntimeException
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Mail;
interface Mailer
{
/**
* Send a plain-text email.
*
* @throws MailException when delivery fails.
*/
public function send(string $to, string $subject, string $body): void;
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Support\MailConfig;
use PHPMailer\PHPMailer\Exception as PhpMailerException;
use PHPMailer\PHPMailer\PHPMailer;
/**
* Sends via SMTP when MAIL_TRANSPORT=smtp, otherwise via PHP's mail() function.
*/
final class PhpMailerMailer implements Mailer
{
public function __construct(private readonly MailConfig $config)
{
}
public function send(string $to, string $subject, string $body): void
{
$mail = new PHPMailer(true);
try {
if ($this->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;
}
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
/**
* Data access for `email_verifications` (magic-link tokens).
*
* @phpstan-type TokenRow array{
* id: int, user_id: int, token_hash: string, new_email: string|null,
* expires_at: string, consumed_at: string|null, created_at: string
* }
*/
final class EmailVerificationRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* Replace any outstanding (unconsumed) tokens of the same kind for the user,
* then store a new one.
*/
public function issue(int $userId, string $tokenHash, ?string $newEmail, int $ttlSeconds): void
{
// Drop the user's outstanding tokens of the same kind (verify vs. change).
$nullClause = $newEmail === null ? 'new_email IS NULL' : 'new_email IS NOT NULL';
$this->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;
}
}
+30 -1
View File
@@ -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<string, mixed> $row
* @return UserRow
+23 -1
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Mail delivery settings.
*
* `transport`:
* - "mail" (default) — PHP's built-in mail() function
* - "smtp" — an SMTP server (host/port/credentials below)
* - "log" — append messages to `logPath` instead of sending (dev/test)
*/
final class MailConfig
{
public function __construct(
public readonly string $transport,
public readonly string $fromAddress,
public readonly string $fromName,
public readonly string $logPath,
public readonly ?string $smtpHost,
public readonly int $smtpPort,
public readonly ?string $smtpUsername,
public readonly ?string $smtpPassword,
public readonly string $smtpEncryption,
) {
}
}
+21 -1
View File
@@ -4,10 +4,17 @@ declare(strict_types=1);
use App\Auth\AuthMiddleware;
use App\Auth\JwtService;
use App\Auth\SessionPayload;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\EmailVerificationController;
use App\Http\Controllers\TodoItemController;
use App\Http\Controllers\TodoListController;
use App\Http\JsonErrorHandler;
use App\Mail\EmailVerifier;
use App\Mail\LogMailer;
use App\Mail\Mailer;
use App\Mail\PhpMailerMailer;
use App\Repository\EmailVerificationRepository;
use App\Repository\TodoItemRepository;
use App\Repository\TodoListRepository;
use App\Repository\UserRepository;
@@ -36,9 +43,18 @@ $errorMiddleware->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']);
+82 -7
View File
@@ -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<string, string> */
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<string, string> $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<array{to: string, subject: string, body: string}> */
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<string, mixed>
*/
+2 -58
View File
@@ -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<string, mixed>|null $body
* @param array<string, string> $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<string, mixed>
*/
private function decode(ResponseInterface $response): array
{
return (array) json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Tests;
final class EmailVerificationTest extends ApiTestCase
{
public function test_registration_sends_a_verification_email(): void
{
$this->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]);
}
}
+18 -3
View File
@@ -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.
+5 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RouterView, useRouter } from 'vue-router'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useItemsStore } from './stores/items'
import { useListsStore } from './stores/lists'
@@ -23,8 +23,10 @@ async function onLogout() {
<span class="app__brand">Todo List</span>
<div v-if="auth.isAuthenticated" class="app__account">
<span class="app__email">{{ auth.user?.email }}</span>
<span v-if="!auth.emailVerified" class="badge badge--warn">email unverified</span>
<RouterLink v-if="!auth.emailVerified" to="/profile" class="badge badge--warn">
verify email
</RouterLink>
<RouterLink to="/profile" class="app__email">{{ auth.user?.email }}</RouterLink>
<button type="button" class="link" @click="onLogout">Log out</button>
</div>
</header>
+6
View File
@@ -24,6 +24,12 @@ export class ApiError extends Error {
fieldError(field: string): string | undefined {
return this.details[field]?.[0]
}
/** Seconds to wait before retrying, when the server sends one (429 responses). */
get retryAfter(): number | undefined {
const value = (this.details as Record<string, unknown>).retry_after
return typeof value === 'number' ? value : undefined
}
}
interface RequestOptions {
+12
View File
@@ -16,6 +16,18 @@ const router = createRouter({
component: () => import('../views/ListView.vue'),
meta: { requiresAuth: true },
},
{
path: '/profile',
name: 'profile',
component: () => import('../views/ProfileView.vue'),
meta: { requiresAuth: true },
},
{
// Magic-link target. Works signed in or out — verifying returns a session.
path: '/verify-email',
name: 'verify-email',
component: () => import('../views/VerifyEmailView.vue'),
},
{
path: '/login',
name: 'login',
+36
View File
@@ -63,6 +63,39 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null
}
/** Verify an email address from a magic-link token; the response logs the user in. */
async function verifyEmail(magicToken: string): Promise<void> {
adopt(
await apiRequest<AuthResponse>('/auth/verify-email', {
method: 'POST',
body: { token: magicToken },
}),
)
}
/** Resend the verification email. Returns the seconds to wait before the next request. */
async function resendVerification(): Promise<number> {
const { retry_after } = await apiRequest<{ retry_after: number }>('/email/verification', {
method: 'POST',
auth: true,
})
return retry_after
}
/** Request a deferred email change. Returns the pending address and cooldown. */
async function requestEmailChange(
email: string,
password: string,
): Promise<{ pending_email: string; retry_after: number }> {
const result = await apiRequest<{ pending_email: string; retry_after: number }>('/email/change', {
method: 'POST',
auth: true,
body: { email, password },
})
await fetchMe() // pick up user.pending_email
return result
}
/** Resolve the current user from a stored token; clears it if invalid. */
async function fetchMe(): Promise<void> {
if (!token.value) return
@@ -88,5 +121,8 @@ export const useAuthStore = defineStore('auth', () => {
login,
logout,
fetchMe,
verifyEmail,
resendVerification,
requestEmailChange,
}
})
+5
View File
@@ -62,6 +62,11 @@ body {
.app__email {
color: var(--muted);
text-decoration: none;
}
a.badge {
text-decoration: none;
}
.app__main {
+2
View File
@@ -3,6 +3,8 @@ export interface User {
email: string
email_verified: boolean
email_verified_at: string | null
/** A confirmed-but-not-yet-applied email change is waiting on this address. */
pending_email: string | null
created_at: string | null
}
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { ApiError } from '../lib/api'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const cooldown = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
function startCooldown(seconds: number) {
cooldown.value = Math.max(0, Math.ceil(seconds))
clearInterval(timer)
timer = setInterval(() => {
cooldown.value -= 1
if (cooldown.value <= 0) clearInterval(timer)
}, 1000)
}
onBeforeUnmount(() => clearInterval(timer))
// --- resend verification -------------------------------------------------
const resending = ref(false)
const resendMessage = ref('')
const resendError = ref('')
async function onResend() {
resending.value = true
resendMessage.value = ''
resendError.value = ''
try {
const retryAfter = await auth.resendVerification()
resendMessage.value = `Sent. Check ${auth.user?.email}.`
startCooldown(retryAfter)
} catch (e) {
if (e instanceof ApiError) {
resendError.value = e.message
if (e.status === 429) startCooldown(e.retryAfter ?? 60)
} else {
resendError.value = 'Could not send the email.'
}
} finally {
resending.value = false
}
}
// --- change email ------------------------------------------------------
const newEmail = ref('')
const password = ref('')
const changing = ref(false)
const changeMessage = ref('')
const changeError = ref<ApiError | null>(null)
async function onChangeEmail() {
changing.value = true
changeMessage.value = ''
changeError.value = null
try {
const { pending_email, retry_after } = await auth.requestEmailChange(newEmail.value, password.value)
changeMessage.value = `Confirmation link sent to ${pending_email}. Your address changes once you open it.`
newEmail.value = ''
password.value = ''
startCooldown(retry_after)
} catch (e) {
changeError.value = e instanceof ApiError ? e : new ApiError('Could not request the change.', 0)
if (e instanceof ApiError && e.status === 429) startCooldown(e.retryAfter ?? 60)
} finally {
changing.value = false
}
}
const resendLabel = computed(() => {
if (resending.value) return 'Sending…'
if (cooldown.value > 0) return `Resend in ${cooldown.value}s`
return 'Resend verification email'
})
</script>
<template>
<section class="card">
<p><RouterLink to="/">&larr; Back to lists</RouterLink></p>
<h1>Your profile</h1>
<p><strong>Email:</strong> {{ auth.user?.email }}</p>
<p>
<strong>Status:</strong>
<span v-if="auth.emailVerified">verified</span>
<span v-else class="badge badge--warn">not verified</span>
</p>
<div v-if="auth.user?.pending_email" class="notice">
A change to <strong>{{ auth.user.pending_email }}</strong> is pending. Open the
link we sent to that address to complete it. The link expires 15 minutes
after it was sent.
</div>
<section v-if="!auth.emailVerified">
<h2>Verify your email</h2>
<p class="muted">
We sent a link to {{ auth.user?.email }}. It expires 15 minutes after
it's sent. You can resend it once a minute.
</p>
<button type="button" :disabled="resending || cooldown > 0" @click="onResend">
{{ resendLabel }}
</button>
<p v-if="resendMessage" class="muted">{{ resendMessage }}</p>
<p v-if="resendError" class="form-error">{{ resendError }}</p>
</section>
<section>
<h2>Change email address</h2>
<form class="form" @submit.prevent="onChangeEmail">
<label>
<span>New email</span>
<input v-model="newEmail" type="email" maxlength="255" required />
<small v-if="changeError?.fieldError('email')" class="field-error">
{{ changeError.fieldError('email') }}
</small>
</label>
<label>
<span>Current password</span>
<input v-model="password" type="password" autocomplete="current-password" required />
<small v-if="changeError?.fieldError('password')" class="field-error">
{{ changeError.fieldError('password') }}
</small>
</label>
<p v-if="changeError && Object.keys(changeError.details).length === 0" class="form-error">
{{ changeError.message }}
</p>
<p v-if="changeMessage" class="muted">{{ changeMessage }}</p>
<button type="submit" :disabled="changing || cooldown > 0">
{{ changing ? 'Sending' : cooldown > 0 ? `Wait ${cooldown}s` : 'Send confirmation link' }}
</button>
</form>
</section>
</section>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ApiError } from '../lib/api'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const state = ref<'working' | 'done' | 'error'>('working')
const message = ref('')
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
state.value = 'error'
message.value = 'This link is missing its token.'
return
}
try {
await auth.verifyEmail(token)
state.value = 'done'
setTimeout(() => router.push('/'), 1500)
} catch (e) {
state.value = 'error'
message.value = e instanceof ApiError ? e.message : 'Could not verify this link.'
}
})
</script>
<template>
<section class="card">
<h1>Email verification</h1>
<p v-if="state === 'working'" class="muted">Verifying</p>
<template v-else-if="state === 'done'">
<p>Your email address is verified and you're signed in.</p>
<p class="muted">Taking you to your lists</p>
<RouterLink to="/">Go now</RouterLink>
</template>
<template v-else>
<p class="form-error">{{ message }}</p>
<p class="muted">
Request a fresh link from your
<RouterLink to="/profile">profile</RouterLink>, or
<RouterLink to="/login">log in</RouterLink>.
</p>
</template>
</section>
</template>