diff --git a/.env.example b/.env.example index e7104fb..2f6283c 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,11 @@ JWT_TTL=86400 # creating a new account. Closes sign-ups without touching existing users. 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. # /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. diff --git a/README.md b/README.md index 006ee06..5857f05 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ environment). See [.env.example](.env.example). | `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key | | `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 | +| `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`) | | `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 | diff --git a/docker-compose.yml b/docker-compose.yml index 74fe0b5..d927643 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: # Set to false to stop new accounts being created (existing users can # still sign in). 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. APP_URL: "${APP_URL:-http://localhost:8080}" # Passkeys: defaults to APP_URL's host (localhost). Browsers require diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php index 3a85f28..c3a7be6 100644 --- a/src/Http/Controllers/AuthController.php +++ b/src/Http/Controllers/AuthController.php @@ -83,6 +83,6 @@ final class AuthController extends Controller $lastSent = $user['verification_email_sent_at'] ?? null; return $lastSent !== null - && (time() - (int) strtotime($lastSent)) < EmailVerifier::RESEND_INTERVAL_SECONDS; + && (time() - (int) strtotime($lastSent)) < $this->verifier->resendIntervalSeconds; } } diff --git a/src/Http/Controllers/EmailVerificationController.php b/src/Http/Controllers/EmailVerificationController.php index 7f2451d..ca3416a 100644 --- a/src/Http/Controllers/EmailVerificationController.php +++ b/src/Http/Controllers/EmailVerificationController.php @@ -16,7 +16,6 @@ use Psr\Http\Message\ServerRequestInterface as Request; final class EmailVerificationController extends Controller { - private const RESEND_INTERVAL_SECONDS = EmailVerifier::RESEND_INTERVAL_SECONDS; private const EMAIL_MAX = 255; public function __construct( @@ -115,7 +114,7 @@ final class EmailVerificationController extends Controller return $this->json($response, [ 'message' => 'Confirmation email sent to the new address.', 'pending_email' => $newEmail, - 'retry_after' => self::RESEND_INTERVAL_SECONDS, + 'retry_after' => $this->verifier->resendIntervalSeconds, ], 202); } @@ -130,11 +129,11 @@ final class EmailVerificationController extends Controller } $elapsed = time() - (int) strtotime($lastSent); - if ($elapsed < self::RESEND_INTERVAL_SECONDS) { + if ($elapsed < $this->verifier->resendIntervalSeconds) { throw new ApiException( 'Please wait a moment before requesting another email.', 429, - ['retry_after' => self::RESEND_INTERVAL_SECONDS - $elapsed], + ['retry_after' => $this->verifier->resendIntervalSeconds - $elapsed], ); } } diff --git a/src/Mail/EmailVerifier.php b/src/Mail/EmailVerifier.php index 6704fda..a410007 100644 --- a/src/Mail/EmailVerifier.php +++ b/src/Mail/EmailVerifier.php @@ -15,13 +15,14 @@ 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, private readonly UserRepository $users, private readonly Mailer $mailer, private readonly string $appUrl, + /** How long to wait before a magic link can be resent to the same address. */ + public readonly int $resendIntervalSeconds = 60, ) { } diff --git a/src/Support/Config.php b/src/Support/Config.php index 263357f..8a406d4 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -23,6 +23,8 @@ final class Config public readonly string $webauthnRpId, /** WebAuthn relying party display name, shown by the browser/OS passkey prompt. */ public readonly string $webauthnRpName, + /** Minimum gap between magic links sent to the same address. */ + public readonly int $resendIntervalSeconds, public readonly MailConfig $mail, ) { } @@ -56,6 +58,8 @@ final class Config $webauthnRpId = self::env('WEBAUTHN_RP_ID') ?? (parse_url($appUrl, PHP_URL_HOST) ?: 'localhost'); $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'); if (!self::isAbsolutePath($mailLogPath)) { $mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/'); @@ -82,6 +86,7 @@ final class Config $appUrl, $webauthnRpId, $webauthnRpName, + $resendIntervalSeconds, $mail, ); } diff --git a/src/bootstrap.php b/src/bootstrap.php index 2214c5c..6bcc347 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -60,7 +60,7 @@ $session = new SessionPayload($jwt, $verificationTokens, $passkeys); $mailer = $config->mail->transport === 'log' ? new LogMailer($config->mail->logPath) : 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 // without checking authenticator provenance against a root CA -- the usual diff --git a/tests/AuthTest.php b/tests/AuthTest.php index b6d79d9..d28ec65 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -69,6 +69,16 @@ final class AuthTest extends ApiTestCase 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 { $unauthorised = $this->request('GET', '/api/me');