-
Notifications
You must be signed in to change notification settings - Fork 0
feat(registration): anonymous self-signup with email-domain allow-list (#62) #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
martinydeAI
wants to merge
7
commits into
feature/issue-63-account-status-checker
Choose a base branch
from
feature/issue-62-self-signup
base: feature/issue-63-account-status-checker
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
a5c4307
feat(registration): anonymous self-signup with domain allow-list
martinydeAI 8b4dbb8
Fix comment in services.yaml for clarity
martinyde 5f1422c
Update AllowedEmailDomains.php
martinyde 92e4b6a
Refactor comments and clarify self-signup rules
martinyde 0baae50
Update comment in RegistrationControllerTest.php
martinyde 539296e
Update CHANGELOG.md
martinyde b0754e7
chore(tests): add one-line intent comments to registration tests
martinydeAI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| { | ||
| 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'); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| { | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 %} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"> | ||
|
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 %} | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tuj