Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Anonymous self-signup at `/register`. The route is
open to unauthenticated visitors; submissions go through
`App\Security\Registration` which validates the email format,
checks the right-hand-side domain against an env-backed allow-list
(`REGISTRATION_ALLOWED_EMAIL_DOMAINS`, comma-separated; default
`example.test` for dev / tests), requires matching password
confirmation, and creates the `User` with `status = Pending`. The
user is redirected to `/register/pending` ("thanks, awaiting
approval") and cannot sign in until a domain manager approves
them. CSRF-protected via Symfony's `csrf_token('register')`
helper. Localised in the existing `messages` domain
([#62](https://github.com/itk-dev/ai-lib/issues/62)).
- `App\Security\AccountStatusChecker` implementing
`UserCheckerInterface` — gates the login flow so a `User` with
`status = Pending` or `status = Blocked` is rejected before the
Expand Down
1 change: 1 addition & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ security:
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/logout, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }

when@test:
security:
Expand Down
7 changes: 7 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
registration_allowed_email_domains_default: 'example.test'

services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
# The env var is the
# production source of truth; the default below keeps dev / test
# bootable when the var is unset (only `example.test` lets through,
# matching the baseline fixtures and the integration test domain).
$allowedEmailDomainsRaw: '%env(default:registration_allowed_email_domains_default:REGISTRATION_ALLOWED_EMAIL_DOMAINS)%'

# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
Expand Down
77 changes: 77 additions & 0 deletions src/Controller/RegistrationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Security\Registration;
use App\Security\RegistrationException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class RegistrationController extends AbstractController
{
public function __construct(private readonly Registration $registration)
{
}

#[Route(path: '/register', name: 'app_register', methods: ['GET', 'POST'])]
public function register(Request $request): Response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a helper class to keep controller thin as per CLAUD.md directions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
if ($this->getUser()) {
return $this->redirectToRoute('app_frontpage');
}

$submitted = [
'email' => '',
'name' => '',
];
$error = null;
$status = Response::HTTP_OK;

if ('POST' === $request->getMethod()) {
$submitted['email'] = (string) $request->request->get('email', '');
$submitted['name'] = (string) $request->request->get('name', '');
$plainPassword = (string) $request->request->get('password', '');
$plainPasswordConfirm = (string) $request->request->get('password_confirm', '');

if (!$this->isCsrfTokenValid('register', (string) $request->request->get('_token'))) {
return $this->render('registration/register.html.twig', [
'submitted' => $submitted,
'error' => 'register.error.invalid_token',
], new Response('', Response::HTTP_FORBIDDEN));
}

try {
$this->registration->register(
$submitted['email'],
$submitted['name'],
$plainPassword,
$plainPasswordConfirm,
);

return $this->redirectToRoute('app_register_pending');
} catch (RegistrationException $e) {
$error = $e->getMessage();
$status = Response::HTTP_UNPROCESSABLE_ENTITY;
}
}

return $this->render('registration/register.html.twig', [
'submitted' => $submitted,
'error' => $error,
], new Response('', $status));
}

#[Route(path: '/register/pending', name: 'app_register_pending', methods: ['GET'])]
public function pending(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('app_frontpage');
}

return $this->render('registration/pending.html.twig');
}
}
57 changes: 57 additions & 0 deletions src/Security/AllowedEmailDomains.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace App\Security;

/**
* Allow-list of email domains accepted by anonymous self-signup.
*
* Sourced from a comma-separated env var
* (`REGISTRATION_ALLOWED_EMAIL_DOMAINS=aarhus.dk,kk.dk,…`).
* Domains are normalised to lowercase + trimmed on construction
* so `aarhus.dk`, `Aarhus.DK`, and ` AARHUS.dk` all match the same
* way.
*
* Empty / blank entries are dropped silently — an env var like
* `,,aarhus.dk,` is interpreted as a single-entry list.
*/
final class AllowedEmailDomains
{
/**
* @var list<string> lowercased + trimmed domain entries
*/
private readonly array $domains;

/**
* @param string $allowedEmailDomainsRaw the comma-separated env-var payload
*/
public function __construct(string $allowedEmailDomainsRaw)
{
$entries = [];
foreach (explode(',', $allowedEmailDomainsRaw) as $entry) {
$normalised = strtolower(trim($entry));
if ('' !== $normalised) {
$entries[] = $normalised;
}
}

$this->domains = array_values(array_unique($entries));
}

/**
* @return bool whether `$domain` (case-insensitive, with surrounding whitespace tolerated) is on the allow-list
*/
public function contains(string $domain): bool
{
return \in_array(strtolower(trim($domain)), $this->domains, true);
}

/**
* @return list<string> the normalised allow-list, for diagnostics / templating
*/
public function all(): array
{
return $this->domains;
}
}
98 changes: 98 additions & 0 deletions src/Security/Registration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Enum\UserStatus;

/**
* Public-signup orchestration.
*
* Sits between {@see \App\Controller\RegistrationController} and the
* existing {@see UserManager}, owning the rules that distinguish a
* legitimate self-signup attempt from one that should be rejected.
*
* 1. The submitted email must syntactically parse as an email.
* 2. The right-hand side of the email must be on the allow-list
* {@see AllowedEmailDomains}.
* 3. The two password fields must match.
* 4. The name must be non-empty (rule shared with {@see UserManager}).

*
* On success the new {@see User} is persisted with
* `status = Pending`. The {@see \App\Security\AccountStatusChecker}
* keeps them out of the login flow until a domain manager approves
* the row.
*/
final class Registration
{
/**
* @param UserManager $userManager owns the persistence + password-hashing step
* @param AllowedEmailDomains $allowedEmailDomains domain allow-list parsed from the env var
*/
public function __construct(
private readonly UserManager $userManager,
private readonly AllowedEmailDomains $allowedEmailDomains,
) {
}

/**
* Run the self-signup pipeline and persist a `Pending` user.
*
* The thrown exceptions carry localised translation keys; the
* controller uses them as the rendered form error.
*
* @param string $email submitted email; must be valid + on the allow-list
* @param string $name display name; trimmed by {@see UserManager}
* @param string $plainPassword chosen password
* @param string $plainPasswordConfirm confirmation field; must match `$plainPassword`
*
* @return User the persisted user with `status = Pending`
*
* @throws RegistrationException when any of the inputs fails validation or the email already exists
*/
public function register(
string $email,
string $name,
string $plainPassword,
string $plainPasswordConfirm,
): User {
if (!filter_var($email, \FILTER_VALIDATE_EMAIL)) {
throw new RegistrationException('register.error.invalid_email');
}

// `filter_var` above guarantees the email has a non-trailing `@`,
// so `strrpos` returns an int and the substr is the domain. See
// the file-level comment for why this is inlined instead of
// calling App\Security\EmailDomain::of().
$domain = strtolower(substr($email, (int) strrpos($email, '@') + 1));
if (!$this->allowedEmailDomains->contains($domain)) {
throw new RegistrationException('register.error.domain_not_allowed');
}

if ($plainPassword !== $plainPasswordConfirm) {
throw new RegistrationException('register.error.password_mismatch');
}

if ('' === trim($name)) {
throw new RegistrationException('register.error.empty_name');
}

if ('' === $plainPassword) {
throw new RegistrationException('register.error.empty_password');
}

try {
return $this->userManager->createUser(
$email,
trim($name),
$plainPassword,
status: UserStatus::Pending,
);
} catch (\DomainException) {
throw new RegistrationException('register.error.email_in_use');
}
}
}
18 changes: 18 additions & 0 deletions src/Security/RegistrationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Security;

/**
* Raised by {@see Registration::register()} when the self-signup
* pipeline rejects the submitted input.
*
* The exception message carries a translation key in the `messages`
* domain (e.g. `register.error.invalid_email`), not a user-facing
* string. The controller renders it through `|trans` so the error
* stays localised.
*/
final class RegistrationException extends \RuntimeException
{
}
20 changes: 20 additions & 0 deletions templates/registration/pending.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends 'base.html.twig' %}

{% block title %}{{ 'register.pending.title'|trans({'%brand%': brand_name}) }}{% endblock %}

{% block body %}
<section class="max-w-md">
<twig:Eyebrow as="p" class="mb-4">{{ 'register.pending.eyebrow'|trans }}</twig:Eyebrow>
<h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink">
{{ 'register.pending.heading'|trans }}
</h1>

<p class="text-sm text-text">{{ 'register.pending.body'|trans }}</p>

<p class="mt-6">
<a class="text-primary underline hover:text-primary-hover" href="{{ path('app_login') }}">
{{ 'register.pending.login_link'|trans }}
</a>
</p>
</section>
{% endblock %}
76 changes: 76 additions & 0 deletions templates/registration/register.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{% extends 'base.html.twig' %}

{% block title %}{{ 'register.title'|trans({'%brand%': brand_name}) }}{% endblock %}

{% block body %}
<section class="max-w-md">
<twig:Eyebrow as="p" class="mb-4">{{ 'register.eyebrow'|trans }}</twig:Eyebrow>
<h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink">
Comment thread
martinyde marked this conversation as resolved.
{{ 'register.heading'|trans }}
</h1>

<p class="mb-6 text-sm text-text">{{ 'register.lead'|trans }}</p>

{% if error %}
<div class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-text" role="alert">
{{ error|trans }}
</div>
{% endif %}

<form action="{{ path('app_register') }}" method="post" class="grid gap-4">
<twig:Form:Label for="inputEmail" text="{{ 'register.email_label'|trans }}">
<twig:Form:TextInput
id="inputEmail"
name="email"
type="email"
value="{{ submitted.email }}"
autocomplete="email"
required
autofocus
/>
</twig:Form:Label>

<twig:Form:Label for="inputName" text="{{ 'register.name_label'|trans }}">
<twig:Form:TextInput
id="inputName"
name="name"
type="text"
value="{{ submitted.name }}"
autocomplete="name"
required
/>
</twig:Form:Label>

<twig:Form:Label for="inputPassword" text="{{ 'register.password_label'|trans }}">
<twig:Form:TextInput
id="inputPassword"
name="password"
type="password"
autocomplete="new-password"
required
/>
</twig:Form:Label>

<twig:Form:Label for="inputPasswordConfirm" text="{{ 'register.password_confirm_label'|trans }}">
<twig:Form:TextInput
id="inputPasswordConfirm"
name="password_confirm"
type="password"
autocomplete="new-password"
required
/>
</twig:Form:Label>

<input type="hidden" name="_token" value="{{ csrf_token('register') }}">

<div class="flex items-center gap-4">
<twig:Form:Button type="submit">
{{ 'register.submit'|trans }}
</twig:Form:Button>
<a class="text-sm text-text underline hover:text-primary" href="{{ path('app_login') }}">
{{ 'register.login_link'|trans }}
</a>
</div>
</form>
</section>
{% endblock %}
Loading
Loading