Make the magic-link resend cooldown configurable
EmailVerifier::RESEND_INTERVAL_SECONDS was a hardcoded class constant shared (via a copy-of-a-constant) by AuthController and EmailVerificationController. It's now a constructor param (resendIntervalSeconds, default 60, same as before) sourced from Config -- new MAGIC_LINK_RESEND_SECONDS env var, default unchanged. Docker Compose sets it to 0, so magic links resend immediately during local development instead of waiting out the throttle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@ JWT_TTL=86400
|
|||||||
# creating a new account. Closes sign-ups without touching existing users.
|
# creating a new account. Closes sign-ups without touching existing users.
|
||||||
APP_ALLOW_REGISTRATION=true
|
APP_ALLOW_REGISTRATION=true
|
||||||
|
|
||||||
|
# Minimum gap, in seconds, before a magic link can be resent to the same
|
||||||
|
# address (sign-in or email-change). The Docker Compose setup overrides this
|
||||||
|
# to 0 for local development, so links can be resent immediately.
|
||||||
|
MAGIC_LINK_RESEND_SECONDS=60
|
||||||
|
|
||||||
# Base URL the app is reached at. Verification magic links point here, e.g.
|
# Base URL the app is reached at. Verification magic links point here, e.g.
|
||||||
# <APP_URL>/verify-email?token=... The Docker image serves the SPA and the API
|
# <APP_URL>/verify-email?token=... The Docker image serves the SPA and the API
|
||||||
# together on http://localhost:8080; a host `npm run dev` serves it on :5173.
|
# together on http://localhost:8080; a host `npm run dev` serves it on :5173.
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ environment). See [.env.example](.env.example).
|
|||||||
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
||||||
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
||||||
| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created |
|
| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created |
|
||||||
|
| `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development |
|
||||||
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
|
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
|
||||||
| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work |
|
| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work |
|
||||||
| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt |
|
| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt |
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ services:
|
|||||||
# Set to false to stop new accounts being created (existing users can
|
# Set to false to stop new accounts being created (existing users can
|
||||||
# still sign in).
|
# still sign in).
|
||||||
APP_ALLOW_REGISTRATION: "${APP_ALLOW_REGISTRATION:-true}"
|
APP_ALLOW_REGISTRATION: "${APP_ALLOW_REGISTRATION:-true}"
|
||||||
|
# 0 here (unlike the app's own default of 60) so magic links can be
|
||||||
|
# resent immediately while developing -- override if that gets in the way.
|
||||||
|
MAGIC_LINK_RESEND_SECONDS: "${MAGIC_LINK_RESEND_SECONDS:-0}"
|
||||||
# The SPA and the API are both served from this container.
|
# The SPA and the API are both served from this container.
|
||||||
APP_URL: "${APP_URL:-http://localhost:8080}"
|
APP_URL: "${APP_URL:-http://localhost:8080}"
|
||||||
# Passkeys: defaults to APP_URL's host (localhost). Browsers require
|
# Passkeys: defaults to APP_URL's host (localhost). Browsers require
|
||||||
|
|||||||
@@ -83,6 +83,6 @@ final class AuthController extends Controller
|
|||||||
$lastSent = $user['verification_email_sent_at'] ?? null;
|
$lastSent = $user['verification_email_sent_at'] ?? null;
|
||||||
|
|
||||||
return $lastSent !== null
|
return $lastSent !== null
|
||||||
&& (time() - (int) strtotime($lastSent)) < EmailVerifier::RESEND_INTERVAL_SECONDS;
|
&& (time() - (int) strtotime($lastSent)) < $this->verifier->resendIntervalSeconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
|||||||
|
|
||||||
final class EmailVerificationController extends Controller
|
final class EmailVerificationController extends Controller
|
||||||
{
|
{
|
||||||
private const RESEND_INTERVAL_SECONDS = EmailVerifier::RESEND_INTERVAL_SECONDS;
|
|
||||||
private const EMAIL_MAX = 255;
|
private const EMAIL_MAX = 255;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -115,7 +114,7 @@ final class EmailVerificationController extends Controller
|
|||||||
return $this->json($response, [
|
return $this->json($response, [
|
||||||
'message' => 'Confirmation email sent to the new address.',
|
'message' => 'Confirmation email sent to the new address.',
|
||||||
'pending_email' => $newEmail,
|
'pending_email' => $newEmail,
|
||||||
'retry_after' => self::RESEND_INTERVAL_SECONDS,
|
'retry_after' => $this->verifier->resendIntervalSeconds,
|
||||||
], 202);
|
], 202);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +129,11 @@ final class EmailVerificationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$elapsed = time() - (int) strtotime($lastSent);
|
$elapsed = time() - (int) strtotime($lastSent);
|
||||||
if ($elapsed < self::RESEND_INTERVAL_SECONDS) {
|
if ($elapsed < $this->verifier->resendIntervalSeconds) {
|
||||||
throw new ApiException(
|
throw new ApiException(
|
||||||
'Please wait a moment before requesting another email.',
|
'Please wait a moment before requesting another email.',
|
||||||
429,
|
429,
|
||||||
['retry_after' => self::RESEND_INTERVAL_SECONDS - $elapsed],
|
['retry_after' => $this->verifier->resendIntervalSeconds - $elapsed],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ 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,
|
||||||
private readonly UserRepository $users,
|
private readonly UserRepository $users,
|
||||||
private readonly Mailer $mailer,
|
private readonly Mailer $mailer,
|
||||||
private readonly string $appUrl,
|
private readonly string $appUrl,
|
||||||
|
/** How long to wait before a magic link can be resent to the same address. */
|
||||||
|
public readonly int $resendIntervalSeconds = 60,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ final class Config
|
|||||||
public readonly string $webauthnRpId,
|
public readonly string $webauthnRpId,
|
||||||
/** WebAuthn relying party display name, shown by the browser/OS passkey prompt. */
|
/** WebAuthn relying party display name, shown by the browser/OS passkey prompt. */
|
||||||
public readonly string $webauthnRpName,
|
public readonly string $webauthnRpName,
|
||||||
|
/** Minimum gap between magic links sent to the same address. */
|
||||||
|
public readonly int $resendIntervalSeconds,
|
||||||
public readonly MailConfig $mail,
|
public readonly MailConfig $mail,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,8 @@ final class Config
|
|||||||
$webauthnRpId = self::env('WEBAUTHN_RP_ID') ?? (parse_url($appUrl, PHP_URL_HOST) ?: 'localhost');
|
$webauthnRpId = self::env('WEBAUTHN_RP_ID') ?? (parse_url($appUrl, PHP_URL_HOST) ?: 'localhost');
|
||||||
$webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects');
|
$webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects');
|
||||||
|
|
||||||
|
$resendIntervalSeconds = (int) (self::env('MAGIC_LINK_RESEND_SECONDS') ?? '60');
|
||||||
|
|
||||||
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
|
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
|
||||||
if (!self::isAbsolutePath($mailLogPath)) {
|
if (!self::isAbsolutePath($mailLogPath)) {
|
||||||
$mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/');
|
$mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/');
|
||||||
@@ -82,6 +86,7 @@ final class Config
|
|||||||
$appUrl,
|
$appUrl,
|
||||||
$webauthnRpId,
|
$webauthnRpId,
|
||||||
$webauthnRpName,
|
$webauthnRpName,
|
||||||
|
$resendIntervalSeconds,
|
||||||
$mail,
|
$mail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ $session = new SessionPayload($jwt, $verificationTokens, $passkeys);
|
|||||||
$mailer = $config->mail->transport === 'log'
|
$mailer = $config->mail->transport === 'log'
|
||||||
? new LogMailer($config->mail->logPath)
|
? new LogMailer($config->mail->logPath)
|
||||||
: new PhpMailerMailer($config->mail);
|
: new PhpMailerMailer($config->mail);
|
||||||
$verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->appUrl);
|
$verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->appUrl, $config->resendIntervalSeconds);
|
||||||
|
|
||||||
// 'none' attestation: verify the credential is a legitimate WebAuthn response
|
// 'none' attestation: verify the credential is a legitimate WebAuthn response
|
||||||
// without checking authenticator provenance against a root CA -- the usual
|
// without checking authenticator provenance against a root CA -- the usual
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ final class AuthTest extends ApiTestCase
|
|||||||
self::assertCount(1, $this->sentEmails());
|
self::assertCount(1, $this->sentEmails());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_the_resend_interval_is_configurable(): void
|
||||||
|
{
|
||||||
|
$this->reconfigure(['MAGIC_LINK_RESEND_SECONDS' => '0']);
|
||||||
|
|
||||||
|
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||||
|
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||||
|
|
||||||
|
self::assertCount(2, $this->sentEmails());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_me_requires_a_valid_token(): void
|
public function test_me_requires_a_valid_token(): void
|
||||||
{
|
{
|
||||||
$unauthorised = $this->request('GET', '/api/me');
|
$unauthorised = $this->request('GET', '/api/me');
|
||||||
|
|||||||
Reference in New Issue
Block a user