From 8732e0e5f5d4decde34e3b48240c1436ca5c4897 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Sun, 6 Sep 2026 21:26:33 +0100 Subject: [PATCH] Add APP_EMAIL_ALLOWLIST gate on account creation An optional comma-separated list of glob patterns restricting which addresses may register, applied on top of APP_ALLOW_REGISTRATION. A non-matching new address is silently ignored exactly like registration being off; an address that already has an account can still sign in. Co-Authored-By: Claude Sonnet 5 --- .env.example | 7 ++++ docker-compose.yml | 3 ++ docs/api.md | 2 +- docs/setup.md | 1 + src/Http/Controllers/AuthController.php | 14 ++++--- src/Support/Config.php | 4 ++ src/Support/EmailAllowlist.php | 54 +++++++++++++++++++++++++ src/bootstrap.php | 2 +- tests/AuthTest.php | 40 ++++++++++++++++++ 9 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 src/Support/EmailAllowlist.php diff --git a/.env.example b/.env.example index 793cf6d..20f77ee 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,13 @@ JWT_TTL=86400 # creating a new account. Closes sign-ups without touching existing users. APP_ALLOW_REGISTRATION=true +# Optional allowlist restricting which addresses may create an account, applied +# on top of APP_ALLOW_REGISTRATION. Comma-separated glob patterns; leave blank +# to allow any address. Matching is case-insensitive. An address that already +# has an account can still sign in even if it no longer matches. +# APP_EMAIL_ALLOWLIST=*@example.com, *@*.example.org, someone@gmail.com +APP_EMAIL_ALLOWLIST= + # 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. diff --git a/docker-compose.yml b/docker-compose.yml index d927643..3a87154 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}" + # Optional comma-separated glob allowlist for addresses that may register, + # e.g. "*@example.com, *@*.example.org". Blank means any address. + APP_EMAIL_ALLOWLIST: "${APP_EMAIL_ALLOWLIST:-}" # 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}" diff --git a/docs/api.md b/docs/api.md index e703d9c..bae8829 100644 --- a/docs/api.md +++ b/docs/api.md @@ -25,7 +25,7 @@ There is no password and no separate registration endpoint. Entering an email ad Request: `{ "email": "ada@example.com" }`. -Emails a one-time sign-in link (`/verify-email?token=…`, 15-minute expiry) and always returns `202` with the same message. If the address has no account yet, one is created (unverified) right here — that's the only "sign up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown address is silently ignored (still `202`, nothing sent) and only an address that already has an account can sign in. A link is only actually (re-)sent if this address hasn't been emailed one in the last 60 seconds. `422` if the address is malformed. +Emails a one-time sign-in link (`/verify-email?token=…`, 15-minute expiry) and always returns `202` with the same message. If the address has no account yet, one is created (unverified) right here — that's the only "sign up" there is — unless `APP_ALLOW_REGISTRATION=false`, or `APP_EMAIL_ALLOWLIST` is set and the address doesn't match one of its comma-separated glob patterns. In either case an unknown address is silently ignored (still `202`, nothing sent) and only an address that already has an account can sign in. A link is only actually (re-)sent if this address hasn't been emailed one in the last 60 seconds. `422` if the address is malformed. ```json { "message": "Check your email for a link to sign in." } diff --git a/docs/setup.md b/docs/setup.md index 831ad55..5ec92f4 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -55,6 +55,7 @@ All settings are optional environment variables (read from `.env` or the real en | `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 | +| `APP_EMAIL_ALLOWLIST` | — (any address) | Comma-separated glob patterns (`*@example.com, *@*.example.org, someone@gmail.com`) restricting which addresses may create an account, on top of `APP_ALLOW_REGISTRATION`. Case-insensitive. An unknown address that doesn't match is silently ignored, exactly like registration being off; an address that already has an account can still sign in even if it no longer matches | | `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 | | `MAX_PROJECTS_PER_OWNER` | `0` | Maximum number of projects a single user may create. `0` means unlimited | | `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) | diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php index c3a7be6..464cd34 100644 --- a/src/Http/Controllers/AuthController.php +++ b/src/Http/Controllers/AuthController.php @@ -9,6 +9,7 @@ use App\Exception\ValidationException; use App\Mail\EmailVerifier; use App\Mail\MailException; use App\Repository\UserRepository; +use App\Support\EmailAllowlist; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -26,6 +27,7 @@ final class AuthController extends Controller private readonly SessionPayload $session, private readonly EmailVerifier $verifier, private readonly bool $allowRegistration, + private readonly EmailAllowlist $emailAllowlist, ) { } @@ -34,8 +36,9 @@ final class AuthController extends Controller * * Emails a one-time sign-in link for the given address, creating the * account first if it doesn't exist yet -- unless registration is turned - * off (APP_ALLOW_REGISTRATION=false), in which case an unknown address is - * silently ignored and only existing users can still sign in. Always + * off (APP_ALLOW_REGISTRATION=false) or the address falls outside the + * email allowlist (APP_EMAIL_ALLOWLIST), in which case an unknown address + * is silently ignored and only existing users can still sign in. Always * responds the same way either way, so registered addresses can't be * enumerated. A link is only actually (re-)sent when one hasn't gone out * in the last minute. Opening the link creates the session and, the first @@ -50,9 +53,10 @@ final class AuthController extends Controller throw new ValidationException(['email' => ['Enter a valid email address.']]); } - $user = $this->allowRegistration - ? $this->users->findOrCreateByEmail($email) - : $this->users->findByEmail($email); + $user = $this->users->findByEmail($email); + if ($user === null && $this->allowRegistration && $this->emailAllowlist->permits($email)) { + $user = $this->users->findOrCreateByEmail($email); + } if ($user !== null && !$this->recentlyEmailed($user)) { try { diff --git a/src/Support/Config.php b/src/Support/Config.php index ce5dfd5..a768950 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -17,6 +17,8 @@ final class Config public readonly bool $displayErrors, /** When false, POST /auth/magic-link only signs existing users in -- it never creates a new account. */ public readonly bool $allowRegistration, + /** Optional glob-pattern gate on which addresses may create an account. Empty => no restriction. */ + public readonly EmailAllowlist $emailAllowlist, /** Base URL of the frontend, used to build magic links. */ public readonly string $appUrl, /** WebAuthn relying party ID -- the domain a passkey is bound to. */ @@ -51,6 +53,7 @@ final class Config $jwtTtl = (int) (self::env('JWT_TTL') ?? '86400'); $displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL); $allowRegistration = filter_var(self::env('APP_ALLOW_REGISTRATION', 'true'), FILTER_VALIDATE_BOOL); + $emailAllowlist = EmailAllowlist::fromString(self::env('APP_EMAIL_ALLOWLIST')); $appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/'); @@ -86,6 +89,7 @@ final class Config $jwtTtl, $displayErrors, $allowRegistration, + $emailAllowlist, $appUrl, $webauthnRpId, $webauthnRpName, diff --git a/src/Support/EmailAllowlist.php b/src/Support/EmailAllowlist.php new file mode 100644 index 0000000..0d6cbf4 --- /dev/null +++ b/src/Support/EmailAllowlist.php @@ -0,0 +1,54 @@ + $patterns lower-cased, non-empty glob patterns */ + public function __construct(private readonly array $patterns) + { + } + + /** Parse the comma-separated APP_EMAIL_ALLOWLIST value (null/blank => allow all). */ + public static function fromString(?string $raw): self + { + $patterns = []; + foreach (explode(',', (string) $raw) as $pattern) { + $pattern = mb_strtolower(trim($pattern)); + if ($pattern !== '') { + $patterns[] = $pattern; + } + } + + return new self($patterns); + } + + /** True when $email is allowed to register -- always true while the list is off. */ + public function permits(string $email): bool + { + if ($this->patterns === []) { + return true; + } + + $email = mb_strtolower($email); + foreach ($this->patterns as $pattern) { + if (fnmatch($pattern, $email)) { + return true; + } + } + + return false; + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index e3e18c2..b48a29a 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -69,7 +69,7 @@ $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->app // ids in getCreateArgs()/getGetArgs() JSON straight to the frontend. $webAuthn = new WebAuthn($config->webauthnRpName, $config->webauthnRpId, ['none'], true); -$authController = new AuthController($users, $session, $verifier, $config->allowRegistration); +$authController = new AuthController($users, $session, $verifier, $config->allowRegistration, $config->emailAllowlist); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $projectController = new ProjectController($projects, $cardStatuses, $config->maxProjectsPerOwner); $cardController = new CardController($projects, $cards, $cardStatuses); diff --git a/tests/AuthTest.php b/tests/AuthTest.php index d28ec65..1cd27bd 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -117,4 +117,44 @@ final class AuthTest extends ApiTestCase self::assertSame(202, $response->getStatusCode()); self::assertSame('ada@example.com', $this->lastEmail()['to']); } + + public function test_the_email_allowlist_blocks_a_new_address_that_does_not_match(): void + { + $this->reconfigure(['APP_EMAIL_ALLOWLIST' => '*@example.com, someone@gmail.com']); + + $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@other.test']); + + // Same silent no-op as registration being off -- no enumeration signal. + self::assertSame(202, $response->getStatusCode()); + self::assertSame([], $this->sentEmails()); + self::assertSame(0, (int) $this->db()->query('SELECT COUNT(*) FROM users')->fetchColumn()); + } + + public function test_the_email_allowlist_lets_a_matching_new_address_register(): void + { + $this->reconfigure(['APP_EMAIL_ALLOWLIST' => '*@example.com, someone@gmail.com']); + + $this->request('POST', '/api/auth/magic-link', ['email' => 'Ada@example.com']); + $this->request('POST', '/api/auth/magic-link', ['email' => 'someone@gmail.com']); + + self::assertSame( + ['ada@example.com', 'someone@gmail.com'], + array_column($this->sentEmails(), 'to'), + ); + } + + public function test_the_email_allowlist_does_not_block_an_existing_user(): void + { + $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@other.test']); + $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]); + $this->db()->prepare('UPDATE users SET verification_email_sent_at = NULL WHERE email = :e') + ->execute(['e' => 'ada@other.test']); + + $this->reconfigure(['APP_EMAIL_ALLOWLIST' => '*@example.com']); + + $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@other.test']); + + self::assertSame(202, $response->getStatusCode()); + self::assertSame('ada@other.test', $this->lastEmail()['to']); + } }