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
+42
View File
@@ -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.
*
@@ -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);
}
+20
View File
@@ -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.
+1
View File
@@ -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);