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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:29:13 +01:00
co-authored by Claude Sonnet 5
parent 64ba21795b
commit be592f38fc
10 changed files with 195 additions and 21 deletions
+17 -2
View File
@@ -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 | | 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 | | 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ 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 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 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 `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). 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 (`<APP_URL>/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` ### `GET /api/me`
Requires `Authorization: Bearer <jwt>`. Requires `Authorization: Bearer <jwt>`.
@@ -196,12 +208,15 @@ Requires `Authorization: Bearer <jwt>`.
### Email verification & profile ### Email verification & profile
Registration emails a magic link — `<APP_URL>/verify-email?token=<opaque>`that Magic links`<APP_URL>/verify-email?token=<opaque>`expire **15 minutes**
expires **15 minutes** after it is sent. Only a hash of the token is stored. 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 | | 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/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/verification` | ✔ | resend the verification email; `409` if already verified |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change | | `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
+42
View File
@@ -70,6 +70,37 @@ final class AuthController extends Controller
return $this->json($response, $this->session->forUser($user)); 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) * 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))]); 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. * Extract and validate the email/password pair from the request body.
* *
@@ -16,7 +16,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
final class EmailVerificationController extends Controller 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 EMAIL_MAX = 255;
private const PASSWORD_MAX = 72; private const PASSWORD_MAX = 72;
@@ -93,7 +93,7 @@ final class EmailVerificationController extends Controller
try { try {
$this->verifier->sendVerification($user); $this->verifier->sendVerification($user);
} catch (MailException $e) { } catch (MailException) {
throw new ApiException('Could not send the email right now. Please try again shortly.', 502); 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 { try {
$this->verifier->sendEmailChange($user, $newEmail); $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); throw new ApiException('Could not send the email right now. Please try again shortly.', 502);
} }
+20
View File
@@ -14,6 +14,7 @@ use App\Repository\UserRepository;
final class EmailVerifier final class EmailVerifier
{ {
public const TOKEN_TTL_SECONDS = 900; // 15 minutes public const TOKEN_TTL_SECONDS = 900; // 15 minutes
public const RESEND_INTERVAL_SECONDS = 60;
public function __construct( public function __construct(
private readonly EmailVerificationRepository $tokens, 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 * Send a link (to the new address) that, once opened, changes the user's
* email to $newEmail. * email to $newEmail.
+1
View File
@@ -75,6 +75,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$group->post('/auth/register', [$authController, 'register']); $group->post('/auth/register', [$authController, 'register']);
$group->post('/auth/login', [$authController, 'login']); $group->post('/auth/login', [$authController, 'login']);
$group->post('/auth/magic-link', [$authController, 'requestLoginLink']);
$group->post('/auth/verify-email', [$emailController, 'verify']); $group->post('/auth/verify-email', [$emailController, 'verify']);
$group->get('/me', [$authController, 'me'])->add($authMiddleware); $group->get('/me', [$authController, 'me'])->add($authMiddleware);
+58
View File
@@ -96,6 +96,64 @@ final class EmailVerificationTest extends ApiTestCase
self::assertSame(409, $response->getStatusCode()); 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 public function test_email_change_is_deferred_until_the_new_address_is_confirmed(): void
{ {
$auth = $this->authHeader('old@example.com'); $auth = $this->authHeader('old@example.com');
+11 -5
View File
@@ -61,8 +61,8 @@ server response replaces local state.
## Auth flow ## Auth flow
- The token from `POST /api/auth/register` or `/login` is kept in `localStorage` - The token from register / login / opening a magic link is kept in
and sent as `Authorization: Bearer …`. `localStorage` and sent as `Authorization: Bearer …`.
- On load, `fetchMe()` validates the stored token via `GET /api/me`; a failure - On load, `fetchMe()` validates the stored token via `GET /api/me`; a failure
clears it. clears it.
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended - 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 - Registration signs the user in immediately; the new account's email is
unverified (`user.email_verified === false`). The header shows a "verify unverified (`user.email_verified === false`). The header shows a "verify
email" badge linking to `/profile`. 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 ## Email verification & profile
- The registration email links to `/verify-email?token=…`. `VerifyEmailView` - `/verify-email?token=…` is the target for every magic link (verification,
POSTs the token to the API, which returns a session — so opening the link both passwordless login, email change). `VerifyEmailView` POSTs the token to the
verifies the address and signs the user in — then redirects to the lists. 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 - `/profile` (`ProfileView`) shows the address and verification status. When
unverified it offers a **Resend** button; the API throttles to once a minute, 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` and the button shows a live countdown (driven by `retry_after`, and by `429`
+6
View File
@@ -63,6 +63,11 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null user.value = null
} }
/** Ask for a passwordless login link to be emailed. */
async function requestLoginLink(email: string): Promise<void> {
await apiRequest('/auth/magic-link', { method: 'POST', body: { email } })
}
/** Verify an email address from a magic-link token; the response logs the user in. */ /** Verify an email address from a magic-link token; the response logs the user in. */
async function verifyEmail(magicToken: string): Promise<void> { async function verifyEmail(magicToken: string): Promise<void> {
adopt( adopt(
@@ -121,6 +126,7 @@ export const useAuthStore = defineStore('auth', () => {
login, login,
logout, logout,
fetchMe, fetchMe,
requestLoginLink,
verifyEmail, verifyEmail,
resendVerification, resendVerification,
requestEmailChange, requestEmailChange,
+31 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -8,18 +8,39 @@ const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const mode = ref<'magic' | 'password'>('magic')
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
const error = ref<ApiError | null>(null) const error = ref<ApiError | null>(null)
const sentMessage = ref('')
const submitting = ref(false) const submitting = ref(false)
const buttonLabel = computed(() => {
if (submitting.value) return mode.value === 'magic' ? 'Sending…' : 'Logging in…'
return mode.value === 'magic' ? 'Log in with email' : 'Log in'
})
function toggleMode() {
mode.value = mode.value === 'magic' ? 'password' : 'magic'
error.value = null
sentMessage.value = ''
password.value = ''
}
async function onSubmit() { async function onSubmit() {
submitting.value = true submitting.value = true
error.value = null error.value = null
sentMessage.value = ''
try { try {
if (mode.value === 'password') {
await auth.login(email.value, password.value) await auth.login(email.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/' const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.push(redirect) await router.push(redirect)
} else {
await auth.requestLoginLink(email.value)
sentMessage.value =
'If that address has an account, a login link is on its way. It expires in 15 minutes.'
}
} catch (e) { } catch (e) {
error.value = e instanceof ApiError ? e : new ApiError('Something went wrong.', 0) error.value = e instanceof ApiError ? e : new ApiError('Something went wrong.', 0)
} finally { } finally {
@@ -41,7 +62,7 @@ async function onSubmit() {
</small> </small>
</label> </label>
<label> <label v-if="mode === 'password'">
<span>Password</span> <span>Password</span>
<input v-model="password" type="password" autocomplete="current-password" required /> <input v-model="password" type="password" autocomplete="current-password" required />
<small v-if="error?.fieldError('password')" class="field-error"> <small v-if="error?.fieldError('password')" class="field-error">
@@ -52,12 +73,17 @@ async function onSubmit() {
<p v-if="error && Object.keys(error.details).length === 0" class="form-error"> <p v-if="error && Object.keys(error.details).length === 0" class="form-error">
{{ error.message }} {{ error.message }}
</p> </p>
<p v-if="sentMessage" class="muted">{{ sentMessage }}</p>
<button type="submit" :disabled="submitting"> <button type="submit" :disabled="submitting">{{ buttonLabel }}</button>
{{ submitting ? 'Logging in…' : 'Log in' }}
</button>
</form> </form>
<p class="muted">
<button type="button" class="link" @click="toggleMode">
{{ mode === 'magic' ? 'Log in with password' : 'Get a magic link' }}
</button>
</p>
<p class="muted"> <p class="muted">
No account? <RouterLink to="/register">Create one</RouterLink>. No account? <RouterLink to="/register">Create one</RouterLink>.
</p> </p>
+3 -3
View File
@@ -32,12 +32,12 @@ onMounted(async () => {
<template> <template>
<section class="card"> <section class="card">
<h1>Email verification</h1> <h1>Signing you in</h1>
<p v-if="state === 'working'" class="muted">Verifying</p> <p v-if="state === 'working'" class="muted">One moment</p>
<template v-else-if="state === 'done'"> <template v-else-if="state === 'done'">
<p>Your email address is verified and you're signed in.</p> <p>You're signed in. Your email address is verified.</p>
<p class="muted">Taking you to your lists</p> <p class="muted">Taking you to your lists</p>
<RouterLink to="/">Go now</RouterLink> <RouterLink to="/">Go now</RouterLink>
</template> </template>