From be592f38fc9196c7be41a3d16e2f30401bba05f7 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Fri, 4 Sep 2026 10:29:13 +0100 Subject: [PATCH] Add stage 8: passwordless magic-link login Backend - POST /api/auth/magic-link (public): emails a one-time login link for an address. Always 202 with the same body so accounts can't be enumerated; a link is sent only when the account exists and wasn't emailed in the last 60s. Opening it (existing verify-email endpoint) returns a session and, as a side effect, verifies the address. New EmailVerifier::sendLoginLink; the 60s interval is now EmailVerifier::RESEND_INTERVAL_SECONDS, shared. Frontend - LoginView defaults to magic-link mode: email only, "Log in with email". A "Log in with password" link reveals the password field, changes the button to "Log in", and itself becomes "Get a magic link" to switch back. - VerifyEmailView copy is now login-neutral ("Signing you in"). Tests: 5 new (magic-link login, implicit verification, enumeration-safety, throttle, validation). Suite: 37 passing. Co-Authored-By: Claude Sonnet 5 --- README.md | 19 +++++- src/Http/Controllers/AuthController.php | 42 ++++++++++++++ .../EmailVerificationController.php | 6 +- src/Mail/EmailVerifier.php | 20 +++++++ src/bootstrap.php | 1 + tests/EmailVerificationTest.php | 58 +++++++++++++++++++ web/README.md | 16 +++-- web/src/stores/auth.ts | 6 ++ web/src/views/LoginView.vue | 42 +++++++++++--- web/src/views/VerifyEmailView.vue | 6 +- 10 files changed, 195 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 46498e3..b01fa24 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/). | 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 | +| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | 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 @@ -170,6 +171,17 @@ Request: `200 OK`: same shape as register. `401` on bad credentials (the message does not say whether it was the email or the password that was wrong). +### `POST /api/auth/magic-link` + +Request: `{ "email": "ada@example.com" }`. + +Emails a one-time login link (`/verify-email?token=…`, 15-minute +expiry). Always returns `202` with the same message regardless of whether the +address is registered, so accounts can't be enumerated; a link is only actually +sent when the account exists and hasn't been emailed in the last 60 seconds. +Opening the link (`POST /api/auth/verify-email`) signs the user in and verifies +the address if it wasn't already. `422` if the address is malformed. + ### `GET /api/me` Requires `Authorization: Bearer `. @@ -196,12 +208,15 @@ Requires `Authorization: Bearer `. ### 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. +Magic links — `/verify-email?token=` — expire **15 minutes** +after they are sent; only a hash of the token is stored. The same link/route +backs three things: verifying a new account, [passwordless +login](#post-apiauthmagic-link), and confirming an email change. | 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/auth/magic-link` | — | email a passwordless login link (see above) | | `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified | | `POST` | `/api/email/change` | ✔ | request a **deferred** email change | diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php index 45912b5..180aa3e 100644 --- a/src/Http/Controllers/AuthController.php +++ b/src/Http/Controllers/AuthController.php @@ -70,6 +70,37 @@ final class AuthController extends Controller return $this->json($response, $this->session->forUser($user)); } + /** + * POST /api/auth/magic-link (public) + * + * Emails a one-time login link for the given address. Always responds the + * same way so registered addresses can't be enumerated; a link is only sent + * when the account exists and hasn't been sent one in the last minute. + * Opening the link signs the user in and verifies the address. + */ + public function requestLoginLink(Request $request, Response $response): Response + { + $body = (array) ($request->getParsedBody() ?? []); + $email = is_string($body['email'] ?? null) ? mb_strtolower(trim($body['email'])) : ''; + + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::EMAIL_MAX) { + throw new ValidationException(['email' => ['Enter a valid email address.']]); + } + + $user = $this->users->findByEmail($email); + if ($user !== null && !$this->recentlyEmailed($user)) { + try { + $this->verifier->sendLoginLink($user); + } catch (MailException $e) { + error_log('Login link failed for user ' . $user['id'] . ': ' . $e->getMessage()); + } + } + + return $this->json($response, [ + 'message' => 'If that address has an account, a login link is on its way.', + ], 202); + } + /** * GET /api/me (requires AuthMiddleware) */ @@ -78,6 +109,17 @@ final class AuthController extends Controller return $this->json($response, ['user' => $this->session->present($this->user($request))]); } + /** + * @param array{verification_email_sent_at?: string|null} $user + */ + private function recentlyEmailed(array $user): bool + { + $lastSent = $user['verification_email_sent_at'] ?? null; + + return $lastSent !== null + && (time() - (int) strtotime($lastSent)) < EmailVerifier::RESEND_INTERVAL_SECONDS; + } + /** * Extract and validate the email/password pair from the request body. * diff --git a/src/Http/Controllers/EmailVerificationController.php b/src/Http/Controllers/EmailVerificationController.php index 4d8e5a2..adc7f2c 100644 --- a/src/Http/Controllers/EmailVerificationController.php +++ b/src/Http/Controllers/EmailVerificationController.php @@ -16,7 +16,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; final class EmailVerificationController extends Controller { - private const RESEND_INTERVAL_SECONDS = 60; + private const RESEND_INTERVAL_SECONDS = EmailVerifier::RESEND_INTERVAL_SECONDS; private const EMAIL_MAX = 255; private const PASSWORD_MAX = 72; @@ -93,7 +93,7 @@ final class EmailVerificationController extends Controller try { $this->verifier->sendVerification($user); - } catch (MailException $e) { + } catch (MailException) { throw new ApiException('Could not send the email right now. Please try again shortly.', 502); } @@ -143,7 +143,7 @@ final class EmailVerificationController extends Controller try { $this->verifier->sendEmailChange($user, $newEmail); - } catch (MailException $e) { + } catch (MailException) { throw new ApiException('Could not send the email right now. Please try again shortly.', 502); } diff --git a/src/Mail/EmailVerifier.php b/src/Mail/EmailVerifier.php index 5a111bc..fb86b9d 100644 --- a/src/Mail/EmailVerifier.php +++ b/src/Mail/EmailVerifier.php @@ -14,6 +14,7 @@ use App\Repository\UserRepository; final class EmailVerifier { public const TOKEN_TTL_SECONDS = 900; // 15 minutes + public const RESEND_INTERVAL_SECONDS = 60; public function __construct( private readonly EmailVerificationRepository $tokens, @@ -42,6 +43,25 @@ final class EmailVerifier ); } + /** + * Send a passwordless login link. Opening it signs the user in and, as a + * side effect, verifies the address if it wasn't already. + * + * @param array{id: int, email: string} $user + */ + public function sendLoginLink(array $user): void + { + $link = $this->issue((int) $user['id'], null); + + $this->mailer->send( + $user['email'], + 'Your login link', + "Open the link below to sign in. It expires in 15 minutes.\n\n" + . $link . "\n\n" + . "If you didn't request this, you can ignore this message.\n", + ); + } + /** * Send a link (to the new address) that, once opened, changes the user's * email to $newEmail. diff --git a/src/bootstrap.php b/src/bootstrap.php index e91c2b1..2333206 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -75,6 +75,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $group->post('/auth/register', [$authController, 'register']); $group->post('/auth/login', [$authController, 'login']); + $group->post('/auth/magic-link', [$authController, 'requestLoginLink']); $group->post('/auth/verify-email', [$emailController, 'verify']); $group->get('/me', [$authController, 'me'])->add($authMiddleware); diff --git a/tests/EmailVerificationTest.php b/tests/EmailVerificationTest.php index 6b0a379..25a2cfc 100644 --- a/tests/EmailVerificationTest.php +++ b/tests/EmailVerificationTest.php @@ -96,6 +96,64 @@ final class EmailVerificationTest extends ApiTestCase self::assertSame(409, $response->getStatusCode()); } + public function test_magic_link_login_emails_a_link_that_signs_the_user_in(): void + { + $this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + $this->cooldownElapsed('ada@example.com'); + + $requested = $this->request('POST', '/api/auth/magic-link', ['email' => 'ADA@example.com']); + self::assertSame(202, $requested->getStatusCode()); + + $login = $this->lastEmail(); + self::assertSame('ada@example.com', $login['to']); + self::assertSame('Your login link', $login['subject']); + + $session = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail($login)]); + self::assertSame(200, $session->getStatusCode()); + $body = $this->decode($session); + self::assertSame('ada@example.com', $body['user']['email']); + self::assertTrue($body['user']['email_verified']); + self::assertNotEmpty($body['token']); + } + + public function test_magic_link_login_verifies_an_unverified_account(): void + { + $this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + $this->cooldownElapsed('ada@example.com'); + + $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); + $body = $this->decode( + $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]), + ); + + self::assertTrue($body['user']['email_verified']); + } + + public function test_magic_link_login_is_silent_for_an_unknown_address(): void + { + $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'nobody@example.com']); + + self::assertSame(202, $response->getStatusCode()); + self::assertSame([], $this->sentEmails()); + } + + public function test_magic_link_login_does_not_resend_within_the_interval(): void + { + // Registration already sent a verification email moments ago. + $this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']); + + $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); + + self::assertSame(202, $response->getStatusCode()); + self::assertCount(1, $this->sentEmails()); // still just the registration email + } + + public function test_magic_link_login_validates_the_address(): void + { + $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'not-an-email']); + self::assertSame(422, $response->getStatusCode()); + } + public function test_email_change_is_deferred_until_the_new_address_is_confirmed(): void { $auth = $this->authHeader('old@example.com'); diff --git a/web/README.md b/web/README.md index 5ae1188..86334d4 100644 --- a/web/README.md +++ b/web/README.md @@ -61,8 +61,8 @@ server response replaces local state. ## Auth flow -- The token from `POST /api/auth/register` or `/login` is kept in `localStorage` - and sent as `Authorization: Bearer …`. +- The token from register / login / opening a magic link is kept in + `localStorage` and sent as `Authorization: Bearer …`. - On load, `fetchMe()` validates the stored token via `GET /api/me`; a failure clears it. - Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended @@ -70,12 +70,18 @@ server response replaces local state. - Registration signs the user in immediately; the new account's email is unverified (`user.email_verified === false`). The header shows a "verify email" badge linking to `/profile`. +- `LoginView` defaults to **magic link**: an email field and a "Log in with + email" button that calls `POST /api/auth/magic-link`. A "Log in with password" + link reveals the password field and switches the button to a plain "Log in" + (`POST /api/auth/login`); the link then reads "Get a magic link" to switch + back. ## 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. +- `/verify-email?token=…` is the target for every magic link (verification, + passwordless login, email change). `VerifyEmailView` POSTs the token to the + API, which returns a session — so opening any 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` diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index b92512b..d771b06 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -63,6 +63,11 @@ export const useAuthStore = defineStore('auth', () => { user.value = null } + /** Ask for a passwordless login link to be emailed. */ + async function requestLoginLink(email: string): Promise { + await apiRequest('/auth/magic-link', { method: 'POST', body: { email } }) + } + /** Verify an email address from a magic-link token; the response logs the user in. */ async function verifyEmail(magicToken: string): Promise { adopt( @@ -121,6 +126,7 @@ export const useAuthStore = defineStore('auth', () => { login, logout, fetchMe, + requestLoginLink, verifyEmail, resendVerification, requestEmailChange, diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index da461e4..01421dd 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,5 +1,5 @@