Add APP_EMAIL_ALLOWLIST gate on account creation
Build / build-and-push (push) Successful in 14s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 21:26:33 +01:00
co-authored by Claude Sonnet 5
parent 06d5b721a3
commit 8732e0e5f5
9 changed files with 120 additions and 7 deletions
+7
View File
@@ -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.
+3
View File
@@ -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}"
+1 -1
View File
@@ -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 (`<APP_URL>/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 (`<APP_URL>/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." }
+1
View File
@@ -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`) |
+9 -5
View File
@@ -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 {
+4
View File
@@ -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,
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* An optional gate on which email addresses may create an account, on top of
* APP_ALLOW_REGISTRATION. Configured as a comma-separated list of glob patterns
* (APP_EMAIL_ALLOWLIST), e.g. `*@example.com, *@*.example.org, someone@gmail.com`.
*
* An empty list (the default) permits every address -- the allowlist is off.
* Matching is case-insensitive; addresses are already lower-cased by the caller.
* Only account creation is gated: an address that already has an account can
* still sign in even if it no longer matches (the list was tightened later).
*/
final class EmailAllowlist
{
/** @param list<string> $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;
}
}
+1 -1
View File
@@ -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);
+40
View File
@@ -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']);
}
}