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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user