diff --git a/CHANGELOG.md b/CHANGELOG.md index bf952a7..f7fe899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index ec318ec..9a5132c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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: diff --git a/config/services.yaml b/config/services.yaml index 7adddb9..e888655 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..7161df9 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,77 @@ +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'); + } +} diff --git a/src/Security/AllowedEmailDomains.php b/src/Security/AllowedEmailDomains.php new file mode 100644 index 0000000..0bd51aa --- /dev/null +++ b/src/Security/AllowedEmailDomains.php @@ -0,0 +1,57 @@ + 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 the normalised allow-list, for diagnostics / templating + */ + public function all(): array + { + return $this->domains; + } +} diff --git a/src/Security/Registration.php b/src/Security/Registration.php new file mode 100644 index 0000000..219c21a --- /dev/null +++ b/src/Security/Registration.php @@ -0,0 +1,98 @@ +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'); + } + } +} diff --git a/src/Security/RegistrationException.php b/src/Security/RegistrationException.php new file mode 100644 index 0000000..b20ed55 --- /dev/null +++ b/src/Security/RegistrationException.php @@ -0,0 +1,18 @@ + + {{ 'register.pending.eyebrow'|trans }} +

+ {{ 'register.pending.heading'|trans }} +

+ +

{{ 'register.pending.body'|trans }}

+ +

+ + {{ 'register.pending.login_link'|trans }} + +

+ +{% endblock %} diff --git a/templates/registration/register.html.twig b/templates/registration/register.html.twig new file mode 100644 index 0000000..cf46b93 --- /dev/null +++ b/templates/registration/register.html.twig @@ -0,0 +1,76 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'register.title'|trans({'%brand%': brand_name}) }}{% endblock %} + +{% block body %} +
+ {{ 'register.eyebrow'|trans }} +

+ {{ 'register.heading'|trans }} +

+ +

{{ 'register.lead'|trans }}

+ + {% if error %} + + {% endif %} + +
+ + + + + + + + + + + + + + + + + + +
+ + {{ 'register.submit'|trans }} + + + {{ 'register.login_link'|trans }} + +
+
+
+{% endblock %} diff --git a/tests/Integration/Controller/RegistrationControllerTest.php b/tests/Integration/Controller/RegistrationControllerTest.php new file mode 100644 index 0000000..c263dcc --- /dev/null +++ b/tests/Integration/Controller/RegistrationControllerTest.php @@ -0,0 +1,185 @@ +client = self::createClient(); + } + + // Tests that the /register form renders the expected fields and CSRF token. + public function testRegisterPageRenders(): void + { + $this->client->request('GET', '/register'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="email"]'); + self::assertSelectorExists('input[name="name"]'); + self::assertSelectorExists('input[name="password"]'); + self::assertSelectorExists('input[name="password_confirm"]'); + self::assertSelectorExists('input[name="_token"]'); + } + + // Verifies a valid submission creates a Pending user and redirects to the pending page. + public function testSuccessfulRegistrationCreatesPendingUserAndRedirects(): void + { + $crawler = $this->client->request('GET', '/register'); + $form = $crawler->filter('form')->form(); + $form['email'] = 'eve@example.test'; + $form['name'] = 'Eve'; + $form['password'] = 'secret'; + $form['password_confirm'] = 'secret'; + $this->client->submit($form); + + self::assertResponseRedirects('/register/pending'); + $crawler = $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + self::assertStringContainsString('venter på godkendelse', $crawler->filter('body')->text()); + + $user = self::getContainer()->get(UserRepository::class)->findOneBy(['email' => 'eve@example.test']); + self::assertNotNull($user); + self::assertSame('Eve', $user->getName()); + self::assertSame(UserStatus::Pending, $user->getStatus()); + } + + // Verifies the hand-off through AccountStatusChecker: a freshly-registered user cannot log in. + public function testPendingUserCreatedByRegistrationCannotLogIn(): void + { + $crawler = $this->client->request('GET', '/register'); + $form = $crawler->filter('form')->form(); + $form['email'] = 'frank@example.test'; + $form['name'] = 'Frank'; + $form['password'] = 'secret'; + $form['password_confirm'] = 'secret'; + $this->client->submit($form); + $this->client->followRedirect(); + + $crawler = $this->client->request('GET', '/login'); + $form = $crawler->filter('form')->form(); + $form['_username'] = 'frank@example.test'; + $form['_password'] = 'secret'; + $this->client->submit($form); + + self::assertResponseRedirects('/login'); + $crawler = $this->client->followRedirect(); + self::assertStringContainsString('venter på godkendelse', $crawler->filter('body')->text()); + self::assertNull( + $this->client->getContainer()->get('security.token_storage')->getToken(), + ); + } + + // Ensures emails outside the allow-list are rejected with 422 and no user persisted. + public function testRejectsNonAllowListedDomain(): void + { + $crawler = $this->client->request('GET', '/register'); + $form = $crawler->filter('form')->form(); + $form['email'] = 'mallory@other.invalid'; + $form['name'] = 'Mallory'; + $form['password'] = 'secret'; + $form['password_confirm'] = 'secret'; + $crawler = $this->client->submit($form); + + self::assertResponseStatusCodeSame(422); + self::assertStringContainsString('domæne er ikke godkendt', $crawler->filter('body')->text()); + self::assertNull( + self::getContainer()->get(UserRepository::class)->findOneBy(['email' => 'mallory@other.invalid']), + ); + } + + // Ensures mismatched password + confirmation are rejected with 422. + public function testRejectsPasswordMismatch(): void + { + $crawler = $this->client->request('GET', '/register'); + $form = $crawler->filter('form')->form(); + $form['email'] = 'grace@example.test'; + $form['name'] = 'Grace'; + $form['password'] = 'secret'; + $form['password_confirm'] = 'different'; + $crawler = $this->client->submit($form); + + self::assertResponseStatusCodeSame(422); + self::assertStringContainsString('ikke ens', $crawler->filter('body')->text()); + } + + // Ensures registering with an existing email is rejected with 422. + public function testRejectsDuplicateEmail(): void + { + $crawler = $this->client->request('GET', '/register'); + $form = $crawler->filter('form')->form(); + // alice@example.test is in the baseline fixtures. + $form['email'] = 'alice@example.test'; + $form['name'] = 'Alice'; + $form['password'] = 'secret'; + $form['password_confirm'] = 'secret'; + $crawler = $this->client->submit($form); + + self::assertResponseStatusCodeSame(422); + self::assertStringContainsString('allerede en konto', $crawler->filter('body')->text()); + } + + // Ensures an invalid CSRF token yields 403 and no user is persisted. + public function testRejectsInvalidCsrfToken(): void + { + $this->client->request('POST', '/register', [ + 'email' => 'henry@example.test', + 'name' => 'Henry', + 'password' => 'secret', + 'password_confirm' => 'secret', + '_token' => 'nope', + ]); + + self::assertResponseStatusCodeSame(403); + self::assertNull( + self::getContainer()->get(UserRepository::class)->findOneBy(['email' => 'henry@example.test']), + ); + } + + // Tests that an authenticated visitor hitting /register is redirected to the frontpage. + public function testLoggedInUserIsRedirectedAwayFromRegister(): void + { + $this->loginAsAlice(); + + $this->client->request('GET', '/register'); + + self::assertResponseRedirects('/'); + } + + // Tests that an authenticated visitor hitting /register/pending is redirected to the frontpage. + public function testLoggedInUserIsRedirectedAwayFromPendingPage(): void + { + $this->loginAsAlice(); + + $this->client->request('GET', '/register/pending'); + + self::assertResponseRedirects('/'); + } + + private function loginAsAlice(): void + { + $crawler = $this->client->request('GET', '/login'); + $form = $crawler->filter('form')->form(); + $form['_username'] = 'alice@example.test'; + $form['_password'] = 'password'; + $this->client->submit($form); + $this->client->followRedirect(); + } +} diff --git a/tests/Unit/Security/AllowedEmailDomainsTest.php b/tests/Unit/Security/AllowedEmailDomainsTest.php new file mode 100644 index 0000000..c2ccd25 --- /dev/null +++ b/tests/Unit/Security/AllowedEmailDomainsTest.php @@ -0,0 +1,63 @@ +all()); + self::assertFalse($allow->contains('aarhus.dk')); + } + + // Tests that a single allow-list entry matches the exact domain. + public function testSingleEntryIsMatched(): void + { + $allow = new AllowedEmailDomains('aarhus.dk'); + + self::assertSame(['aarhus.dk'], $allow->all()); + self::assertTrue($allow->contains('aarhus.dk')); + } + + // Verifies multiple entries are kept in their original order and deduplicated. + public function testMultipleEntriesArePreservedInOrderAndDeduplicated(): void + { + $allow = new AllowedEmailDomains('aarhus.dk,kk.dk,aarhus.dk'); + + self::assertSame(['aarhus.dk', 'kk.dk'], $allow->all()); + } + + // Verifies entries are lowercased and trimmed during parsing. + public function testEntriesAreLowercasedAndTrimmed(): void + { + $allow = new AllowedEmailDomains(' Aarhus.DK , AARHUS.DK , kk.dk '); + + self::assertSame(['aarhus.dk', 'kk.dk'], $allow->all()); + } + + // Ensures blank entries (e.g. leading/trailing commas) are silently dropped. + public function testBlankEntriesAreSilentlyDropped(): void + { + $allow = new AllowedEmailDomains(',,aarhus.dk,,'); + + self::assertSame(['aarhus.dk'], $allow->all()); + } + + // Verifies contains() is case-insensitive and tolerates surrounding whitespace. + public function testContainsIsCaseInsensitiveAndWhitespaceTolerant(): void + { + $allow = new AllowedEmailDomains('aarhus.dk'); + + self::assertTrue($allow->contains('AARHUS.DK')); + self::assertTrue($allow->contains(' aarhus.dk ')); + self::assertFalse($allow->contains('other.dk')); + } +} diff --git a/tests/Unit/Security/RegistrationTest.php b/tests/Unit/Security/RegistrationTest.php new file mode 100644 index 0000000..a674b7c --- /dev/null +++ b/tests/Unit/Security/RegistrationTest.php @@ -0,0 +1,144 @@ +registration(allowList: 'example.test'); + + $this->expectException(RegistrationException::class); + $this->expectExceptionMessage('register.error.invalid_email'); + + $reg->register('not-an-email', 'Carol', 'secret', 'secret'); + } + + // Ensures emails outside the allow-list raise domain_not_allowed. + public function testRejectsDomainNotOnAllowList(): void + { + $reg = $this->registration(allowList: 'aarhus.dk'); + + $this->expectException(RegistrationException::class); + $this->expectExceptionMessage('register.error.domain_not_allowed'); + + $reg->register('carol@example.test', 'Carol', 'secret', 'secret'); + } + + // Ensures mismatched password + confirmation raise password_mismatch. + public function testRejectsPasswordMismatch(): void + { + $reg = $this->registration(allowList: 'example.test'); + + $this->expectException(RegistrationException::class); + $this->expectExceptionMessage('register.error.password_mismatch'); + + $reg->register('carol@example.test', 'Carol', 'secret', 'different'); + } + + // Ensures whitespace-only names raise empty_name. + public function testRejectsEmptyName(): void + { + $reg = $this->registration(allowList: 'example.test'); + + $this->expectException(RegistrationException::class); + $this->expectExceptionMessage('register.error.empty_name'); + + $reg->register('carol@example.test', ' ', 'secret', 'secret'); + } + + // Ensures empty passwords raise empty_password. + public function testRejectsEmptyPassword(): void + { + $reg = $this->registration(allowList: 'example.test'); + + $this->expectException(RegistrationException::class); + $this->expectExceptionMessage('register.error.empty_password'); + + $reg->register('carol@example.test', 'Carol', '', ''); + } + + // Verifies UserManager's duplicate-email DomainException is translated into a localised RegistrationException. + public function testTranslatesDuplicateEmailIntoRegistrationException(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $repo = $this->createMock(UserRepository::class); + $hasher = $this->createMock(UserPasswordHasherInterface::class); + + // First call (in `register`) finds an existing user; UserManager throws DomainException. + $repo->method('findOneBy')->willReturn(new User()); + + $reg = new Registration( + new UserManager($em, $repo, $hasher), + new AllowedEmailDomains('example.test'), + ); + + $this->expectException(RegistrationException::class); + $this->expectExceptionMessage('register.error.email_in_use'); + + $reg->register('carol@example.test', 'Carol', 'secret', 'secret'); + } + + // Tests the happy path: valid submission persists a Pending user with trimmed name and hashed password. + public function testPersistsPendingUserOnHappyPath(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $repo = $this->createMock(UserRepository::class); + $hasher = $this->createMock(UserPasswordHasherInterface::class); + + $repo->method('findOneBy')->willReturn(null); + $hasher->method('hashPassword')->willReturn('hashed-secret'); + + $captured = null; + $em->expects(self::once()) + ->method('persist') + ->willReturnCallback(function (object $entity) use (&$captured): void { + \assert($entity instanceof User); + $captured = $entity; + }); + $em->expects(self::once())->method('flush'); + + $reg = new Registration( + new UserManager($em, $repo, $hasher), + new AllowedEmailDomains('example.test'), + ); + + $user = $reg->register('Carol@Example.test', ' Carol ', 'secret', 'secret'); + + self::assertSame($user, $captured); + self::assertSame('Carol@Example.test', $user->getEmail()); + self::assertSame('Carol', $user->getName(), 'Name is trimmed before persistence.'); + self::assertSame(UserStatus::Pending, $user->getStatus()); + self::assertSame('hashed-secret', $user->getPassword()); + } + + /** + * Build a Registration with mock collaborators that never reach + * the persistence step. + */ + private function registration(string $allowList): Registration + { + $em = $this->createMock(EntityManagerInterface::class); + $repo = $this->createMock(UserRepository::class); + $hasher = $this->createMock(UserPasswordHasherInterface::class); + + return new Registration( + new UserManager($em, $repo, $hasher), + new AllowedEmailDomains($allowList), + ); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f71b193..527e494 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -42,6 +42,32 @@ security: password_label: "Adgangskode" submit: "Log ind" +register: + title: "Opret bruger – %brand%" + eyebrow: "Opret bruger" + heading: "Opret bruger" + lead: "Brug din arbejds-e-mail. Når du har oprettet kontoen, skal en administrator godkende den, før du kan logge ind." + email_label: "E-mail" + name_label: "Navn" + password_label: "Adgangskode" + password_confirm_label: "Bekræft adgangskode" + submit: "Opret bruger" + login_link: "Har du allerede en konto? Log ind" + error: + invalid_email: "Indtast en gyldig e-mailadresse." + domain_not_allowed: "E-mailens domæne er ikke godkendt til at oprette konti her." + password_mismatch: "De to adgangskoder er ikke ens." + empty_name: "Navnet må ikke være tomt." + empty_password: "Adgangskoden må ikke være tom." + email_in_use: "Der findes allerede en konto med den e-mail." + invalid_token: "Sessionen er udløbet. Indsend formularen igen." + pending: + title: "Konto oprettet – %brand%" + eyebrow: "Tak" + heading: "Tak — vi har modtaget din oprettelse" + body: "Din konto venter på godkendelse fra en administrator. Du modtager besked, når kontoen er klar, og kan så logge ind." + login_link: "← Tilbage til log ind" + assistant: detail: title: "%title% – %brand%"