Compare commits
13
Commits
fc1fbcc762
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce294faed7 | ||
|
|
8732e0e5f5 | ||
|
|
06d5b721a3 | ||
|
|
00d9008bf9 | ||
|
|
923d68f317 | ||
|
|
a3b44907b8 | ||
|
|
d0abd683cc | ||
|
|
8bb9f05559 | ||
|
|
950d116e53 | ||
|
|
2370b06913 | ||
|
|
16f94293ab | ||
|
|
e59890618e | ||
|
|
6faf4a72df |
@@ -1,5 +1,6 @@
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.gitea
|
||||||
.dockerignore
|
.dockerignore
|
||||||
vendor
|
vendor
|
||||||
storage
|
storage
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ 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
|
||||||
|
|
||||||
|
# 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
|
# 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
|
# address (sign-in or email-change). The Docker Compose setup overrides this
|
||||||
# to 0 for local development, so links can be resent immediately.
|
# to 0 for local development, so links can be resent immediately.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
# Builds the image on every push to main and pushes it to Gitea's container
|
||||||
|
# registry as "latest" (after first re-tagging the current "latest" as
|
||||||
|
# "previous", for a one-step-back rollback point -- no-ops on the very first
|
||||||
|
# run, when there's no existing "latest" to promote). Deliberately just
|
||||||
|
# "latest"/"previous", not per-commit tags, to avoid accumulating history.
|
||||||
|
# See release.yml for tagged releases (git tag -> matching image tag).
|
||||||
|
#
|
||||||
|
# This project's CI/CD scope is intentionally just test/build/push --
|
||||||
|
# deployment is being split into a separate project.
|
||||||
|
#
|
||||||
|
# Requires secrets.BUILD_API_TOKEN -- a personal access token
|
||||||
|
# (write:package scope) from the pushing account, stored manually as a repo
|
||||||
|
# secret. The auto-injected secrets.GITEA_TOKEN does NOT work for this: it
|
||||||
|
# never grants package-registry access regardless of the workflow's own
|
||||||
|
# `permissions:` block -- a known Gitea limitation, not a config mistake
|
||||||
|
# (https://github.com/go-gitea/gitea/issues/23642).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Relies on the Gitea runner's Docker daemon being reachable from job
|
||||||
|
# containers, which it is out of the box -- no extra runner config needed.
|
||||||
|
container:
|
||||||
|
image: docker:cli
|
||||||
|
steps:
|
||||||
|
# actions/checkout is a JS action; docker:cli is Alpine-based and has
|
||||||
|
# no node on PATH by default (same issue fixed in ci.yml).
|
||||||
|
- name: Install Node
|
||||||
|
run: apk add --no-cache nodejs
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the container registry
|
||||||
|
env:
|
||||||
|
BUILD_API_TOKEN: ${{ secrets.BUILD_API_TOKEN }}
|
||||||
|
run: echo "$BUILD_API_TOKEN" | docker login code.aneur.in -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Promote the current "latest" to "previous"
|
||||||
|
run: |
|
||||||
|
IMAGE="code.aneur.in/${{ gitea.repository }}"
|
||||||
|
if docker pull "$IMAGE:latest"; then
|
||||||
|
docker tag "$IMAGE:latest" "$IMAGE:previous"
|
||||||
|
docker push "$IMAGE:previous"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and push "latest"
|
||||||
|
run: |
|
||||||
|
IMAGE="code.aneur.in/${{ gitea.repository }}:latest"
|
||||||
|
docker build -t "$IMAGE" .
|
||||||
|
docker push "$IMAGE"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: php:8.3-cli-alpine
|
||||||
|
steps:
|
||||||
|
# actions/checkout is a JS action -- php:8.3-cli-alpine has no node on
|
||||||
|
# PATH by default, so install it (musl-native, no glibc/Alpine mismatch)
|
||||||
|
# before any step that needs it.
|
||||||
|
- name: Install Node
|
||||||
|
run: apk add --no-cache nodejs
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Composer
|
||||||
|
run: curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-progress --prefer-dist
|
||||||
|
|
||||||
|
- name: Audit dependencies
|
||||||
|
run: composer audit
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: vendor/bin/phpunit
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:24-alpine
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Audit dependencies
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
|
||||||
|
- name: Type-check and build
|
||||||
|
run: npm run build
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Builds and pushes an image tagged to match the git tag that triggered this
|
||||||
|
# run -- e.g. pushing tag "v1.2.3" produces code.aneur.in/<owner>/<repo>:v1.2.3.
|
||||||
|
# Separate from build.yml's "latest"/"previous" tracking on push to main.
|
||||||
|
# This project's CI/CD scope is intentionally just test/build/push --
|
||||||
|
# deployment (including how a given tag actually gets deployed) is being
|
||||||
|
# split into a separate project.
|
||||||
|
#
|
||||||
|
# Assumes tags are cut from commits already on main (and so already covered
|
||||||
|
# by ci.yml's checks) -- this workflow doesn't run the test suite itself.
|
||||||
|
#
|
||||||
|
# Requires secrets.BUILD_API_TOKEN -- a personal access token
|
||||||
|
# (write:package scope) from the pushing account, stored manually as a repo
|
||||||
|
# secret. The auto-injected secrets.GITEA_TOKEN does NOT work for this: it
|
||||||
|
# never grants package-registry access regardless of the workflow's own
|
||||||
|
# `permissions:` block -- a known Gitea limitation, not a config mistake
|
||||||
|
# (https://github.com/go-gitea/gitea/issues/23642).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Relies on the Gitea runner's Docker daemon being reachable from job
|
||||||
|
# containers, which it is out of the box -- no extra runner config needed.
|
||||||
|
container:
|
||||||
|
image: docker:cli
|
||||||
|
steps:
|
||||||
|
# actions/checkout is a JS action; docker:cli is Alpine-based and has
|
||||||
|
# no node on PATH by default (same issue fixed in ci.yml).
|
||||||
|
- name: Install Node
|
||||||
|
run: apk add --no-cache nodejs
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the container registry
|
||||||
|
env:
|
||||||
|
BUILD_API_TOKEN: ${{ secrets.BUILD_API_TOKEN }}
|
||||||
|
run: echo "$BUILD_API_TOKEN" | docker login code.aneur.in -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push the image
|
||||||
|
run: |
|
||||||
|
IMAGE="code.aneur.in/${{ gitea.repository }}:${{ gitea.ref_name }}"
|
||||||
|
docker build -t "$IMAGE" .
|
||||||
|
docker push "$IMAGE"
|
||||||
+13
-5
@@ -6,9 +6,11 @@ FROM node:24-alpine AS frontend
|
|||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
|
|
||||||
# Dependencies in their own layer, cached unless the manifests change. npm ci
|
# Dependencies in their own layer, cached unless the manifests change. npm ci
|
||||||
# resolves this stage's (musl) platform binaries for rollup/esbuild.
|
# resolves this stage's (musl) platform binaries for rollup/esbuild. The cache
|
||||||
|
# mount keeps the npm download cache warm across builds, so a lockfile bump
|
||||||
|
# only re-fetches what actually changed.
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/package-lock.json ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build # vue-tsc type-check, then `vite build` -> /web/dist
|
RUN npm run build # vue-tsc type-check, then `vite build` -> /web/dist
|
||||||
@@ -56,14 +58,17 @@ COPY docker/apache.conf /etc/apache2/conf.d/zz-app.conf
|
|||||||
# Copied early, alongside the other rarely-changing setup above -- after
|
# Copied early, alongside the other rarely-changing setup above -- after
|
||||||
# COPY . . (below) every later layer re-runs on nearly every build, so this
|
# COPY . . (below) every later layer re-runs on nearly every build, so this
|
||||||
# would otherwise redo work for a file that essentially never changes.
|
# would otherwise redo work for a file that essentially never changes.
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
# --- PHP dependencies (own layer, cached unless composer.* changes) --------
|
# --- PHP dependencies (own layer, cached unless composer.* changes) --------
|
||||||
|
# The cache mount keeps Composer's package cache warm across builds, so a
|
||||||
|
# composer.lock bump only re-fetches the packages that changed.
|
||||||
COPY composer.json composer.lock ./
|
COPY composer.json composer.lock ./
|
||||||
RUN composer install --no-dev --no-interaction --no-progress --prefer-dist --no-autoloader
|
RUN --mount=type=cache,target=/tmp/composer-cache \
|
||||||
|
COMPOSER_CACHE_DIR=/tmp/composer-cache \
|
||||||
|
composer install --no-dev --no-interaction --no-progress --prefer-dist --no-autoloader
|
||||||
|
|
||||||
# --- Application source ----------------------------------------------------
|
# --- Application source ----------------------------------------------------
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -74,6 +79,9 @@ RUN composer dump-autoload --optimize --no-dev \
|
|||||||
# --- Built frontend: served from the web root next to the API front controller
|
# --- Built frontend: served from the web root next to the API front controller
|
||||||
COPY --from=frontend /web/dist/ ./public/
|
COPY --from=frontend /web/dist/ ./public/
|
||||||
|
|
||||||
|
# Apache listens on 80 -- declare it so `docker run -P` and tooling pick it up.
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
CMD ["httpd", "-D", "FOREGROUND"]
|
CMD ["httpd", "-D", "FOREGROUND"]
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
# 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
|
# 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.
|
# resent immediately while developing -- override if that gets in the way.
|
||||||
MAGIC_LINK_RESEND_SECONDS: "${MAGIC_LINK_RESEND_SECONDS:-0}"
|
MAGIC_LINK_RESEND_SECONDS: "${MAGIC_LINK_RESEND_SECONDS:-0}"
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ There is no password and no separate registration endpoint. Entering an email ad
|
|||||||
|
|
||||||
Request: `{ "email": "ada@example.com" }`.
|
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
|
```json
|
||||||
{ "message": "Check your email for a link to sign in." }
|
{ "message": "Check your email for a link to sign in." }
|
||||||
|
|||||||
@@ -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_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 |
|
||||||
|
| `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 |
|
| `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 |
|
| `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`) |
|
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Exception\ValidationException;
|
|||||||
use App\Mail\EmailVerifier;
|
use App\Mail\EmailVerifier;
|
||||||
use App\Mail\MailException;
|
use App\Mail\MailException;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use App\Support\EmailAllowlist;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ final class AuthController extends Controller
|
|||||||
private readonly SessionPayload $session,
|
private readonly SessionPayload $session,
|
||||||
private readonly EmailVerifier $verifier,
|
private readonly EmailVerifier $verifier,
|
||||||
private readonly bool $allowRegistration,
|
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
|
* 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
|
* account first if it doesn't exist yet -- unless registration is turned
|
||||||
* off (APP_ALLOW_REGISTRATION=false), in which case an unknown address is
|
* off (APP_ALLOW_REGISTRATION=false) or the address falls outside the
|
||||||
* silently ignored and only existing users can still sign in. Always
|
* 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
|
* 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
|
* 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
|
* 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.']]);
|
throw new ValidationException(['email' => ['Enter a valid email address.']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->allowRegistration
|
$user = $this->users->findByEmail($email);
|
||||||
? $this->users->findOrCreateByEmail($email)
|
if ($user === null && $this->allowRegistration && $this->emailAllowlist->permits($email)) {
|
||||||
: $this->users->findByEmail($email);
|
$user = $this->users->findOrCreateByEmail($email);
|
||||||
|
}
|
||||||
|
|
||||||
if ($user !== null && !$this->recentlyEmailed($user)) {
|
if ($user !== null && !$this->recentlyEmailed($user)) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ final class Config
|
|||||||
public readonly bool $displayErrors,
|
public readonly bool $displayErrors,
|
||||||
/** When false, POST /auth/magic-link only signs existing users in -- it never creates a new account. */
|
/** When false, POST /auth/magic-link only signs existing users in -- it never creates a new account. */
|
||||||
public readonly bool $allowRegistration,
|
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. */
|
/** Base URL of the frontend, used to build magic links. */
|
||||||
public readonly string $appUrl,
|
public readonly string $appUrl,
|
||||||
/** WebAuthn relying party ID -- the domain a passkey is bound to. */
|
/** 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');
|
$jwtTtl = (int) (self::env('JWT_TTL') ?? '86400');
|
||||||
$displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
|
$displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
|
||||||
$allowRegistration = filter_var(self::env('APP_ALLOW_REGISTRATION', 'true'), 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'), '/');
|
$appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/');
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ final class Config
|
|||||||
$jwtTtl,
|
$jwtTtl,
|
||||||
$displayErrors,
|
$displayErrors,
|
||||||
$allowRegistration,
|
$allowRegistration,
|
||||||
|
$emailAllowlist,
|
||||||
$appUrl,
|
$appUrl,
|
||||||
$webauthnRpId,
|
$webauthnRpId,
|
||||||
$webauthnRpName,
|
$webauthnRpName,
|
||||||
|
|||||||
@@ -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
@@ -69,7 +69,7 @@ $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->app
|
|||||||
// ids in getCreateArgs()/getGetArgs() JSON straight to the frontend.
|
// ids in getCreateArgs()/getGetArgs() JSON straight to the frontend.
|
||||||
$webAuthn = new WebAuthn($config->webauthnRpName, $config->webauthnRpId, ['none'], true);
|
$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);
|
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
|
||||||
$projectController = new ProjectController($projects, $cardStatuses, $config->maxProjectsPerOwner);
|
$projectController = new ProjectController($projects, $cardStatuses, $config->maxProjectsPerOwner);
|
||||||
$cardController = new CardController($projects, $cards, $cardStatuses);
|
$cardController = new CardController($projects, $cards, $cardStatuses);
|
||||||
|
|||||||
@@ -117,4 +117,44 @@ final class AuthTest extends ApiTestCase
|
|||||||
self::assertSame(202, $response->getStatusCode());
|
self::assertSame(202, $response->getStatusCode());
|
||||||
self::assertSame('ada@example.com', $this->lastEmail()['to']);
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user