diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..84423cc
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git/
+.idea/
+node_modules/
+vendor/
+Packages
+.github
+.claude
+.idea
+composer.lock
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..a30f1a8
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,60 @@
+name: E2E Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ e2e:
+ name: E2E Tests (${{ matrix.neos }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ neos: [neos8, neos9]
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-node@v5
+ with:
+ node-version-file: Tests/E2E/.nvmrc
+ cache: npm
+ cache-dependency-path: Tests/E2E/package-lock.json
+
+ - name: Install dependencies
+ working-directory: Tests/E2E
+ run: npm ci
+
+ - name: Install Playwright browsers
+ working-directory: Tests/E2E
+ run: npx playwright install --with-deps chromium
+
+ - name: Pre-build Docker image
+ run: docker compose -f Tests/system_under_test/${{ matrix.neos }}/docker-compose.yaml build --pull
+
+ - name: Test - defaults
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:defaults
+
+ - name: Test - enforce-all
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:enforce-all
+
+ - name: Test - enforce-role
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:enforce-role
+
+ - name: Test - enforce-provider
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:enforce-provider
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-report-${{ matrix.neos }}
+ path: Tests/E2E/playwright-report/
+ retention-days: 7
diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php
index 5e99390..000462a 100644
--- a/Classes/Controller/BackendController.php
+++ b/Classes/Controller/BackendController.php
@@ -137,7 +137,7 @@ public function newAction(): void
* @throws IllegalObjectTypeException
* @throws StopActionException
*/
- public function createAction(string $secret, string $secondFactorFromApp): void
+ public function createAction(string $secret, string $secondFactorFromApp, string $name = ''): void
{
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
@@ -157,7 +157,7 @@ public function createAction(string $secret, string $secondFactorFromApp): void
$this->redirect('new');
}
- $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount());
+ $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount(), $name);
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
@@ -189,7 +189,9 @@ public function deleteAction(SecondFactor $secondFactor): void
if ($isAdministrator || ($isOwner && $this->secondFactorService->canOneSecondFactorBeDeletedForAccount($account))) {
// User is admin or has more than one second factor
$this->secondFactorRepository->remove($secondFactor);
- $this->persistenceManager->persistAll();
+ // neos8 backwards compatibility
+ $this->persistenceManager?->persistAll();
+
$this->addFlashMessage(
$this->translator->translateById(
'module.index.delete.flashMessage.secondFactorDeleted',
diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php
index 33c7ebc..2a59f6c 100644
--- a/Classes/Controller/LoginController.php
+++ b/Classes/Controller/LoginController.php
@@ -177,12 +177,13 @@ public function setupSecondFactorAction(?string $username = null): void
/**
* @param string $secret
* @param string $secondFactorFromApp
+ * @param string $name
* @return void
* @throws IllegalObjectTypeException
* @throws SessionNotStartedException
* @throws StopActionException
*/
- public function createSecondFactorAction(string $secret, string $secondFactorFromApp): void
+ public function createSecondFactorAction(string $secret, string $secondFactorFromApp, string $name = ''): void
{
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
@@ -204,7 +205,7 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro
$account = $this->securityContext->getAccount();
- $this->secondFactorRepository->createSecondFactorForAccount($secret, $account);
+ $this->secondFactorRepository->createSecondFactorForAccount($secret, $account, $name);
$this->addFlashMessage(
$this->translator->translateById(
diff --git a/Classes/Domain/Model/SecondFactor.php b/Classes/Domain/Model/SecondFactor.php
index 569d9d4..bbde9d5 100644
--- a/Classes/Domain/Model/SecondFactor.php
+++ b/Classes/Domain/Model/SecondFactor.php
@@ -8,6 +8,8 @@
use Doctrine\ORM\Mapping as ORM;
use Neos\Flow\Annotations as Flow;
+// TODO: refactor to PHP8 code
+
/**
* Store the secrets needed for two factor authentication
*
@@ -39,6 +41,11 @@ class SecondFactor
*/
protected string $secret;
+ /**
+ * @var string
+ */
+ protected string $name;
+
/**
* Introduced with version 1.4.0
* Nullable for backwards compatibility. Null values will be shown as '-' in backend module.
@@ -73,6 +80,7 @@ public function getType(): int
}
/**
+ * Used in Fusion rendering
* @return string
*/
public function getTypeAsName(): string
@@ -104,6 +112,19 @@ public function setSecret(string $secret): void
$this->secret = $secret;
}
+ public function setName(string $name): void
+ {
+ $this->name = $name;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Used in Fusion rendering
+ */
public function getCreationDate(): DateTime|null
{
return $this->creationDate;
diff --git a/Classes/Domain/Repository/SecondFactorRepository.php b/Classes/Domain/Repository/SecondFactorRepository.php
index 08aa1fe..8e076b4 100644
--- a/Classes/Domain/Repository/SecondFactorRepository.php
+++ b/Classes/Domain/Repository/SecondFactorRepository.php
@@ -24,12 +24,13 @@ class SecondFactorRepository extends Repository
/**
* @throws IllegalObjectTypeException
*/
- public function createSecondFactorForAccount(string $secret, Account $account): void
+ public function createSecondFactorForAccount(string $secret, Account $account, string $name): void
{
$secondFactor = new SecondFactor();
$secondFactor->setAccount($account);
$secondFactor->setSecret($secret);
$secondFactor->setType(SecondFactor::TYPE_TOTP);
+ $secondFactor->setName($name);
$secondFactor->setCreationDate(new \DateTime());
$this->add($secondFactor);
$this->persistenceManager->persistAll();
diff --git a/Configuration/Settings.2FA.yaml b/Configuration/Settings.2FA.yaml
new file mode 100644
index 0000000..3ee0629
--- /dev/null
+++ b/Configuration/Settings.2FA.yaml
@@ -0,0 +1,10 @@
+Sandstorm:
+ NeosTwoFactorAuthentication:
+ # enforce 2FA for all users
+ enforceTwoFactorAuthentication: false
+ # enforce 2FA for specific authentication providers (e.g. Neos.Neos:Backend)
+ enforce2FAForAuthenticationProviders : []
+ # enforce 2FA for specific roles (e.g. Neos.Neos:Administrator)
+ enforce2FAForRoles: []
+ # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used
+ issuerName: ''
diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml
index 559bc32..a5d32b1 100644
--- a/Configuration/Settings.yaml
+++ b/Configuration/Settings.yaml
@@ -48,14 +48,3 @@ Neos:
pattern: 'ControllerObjectName'
patternOptions:
controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)'
-
-Sandstorm:
- NeosTwoFactorAuthentication:
- # enforce 2FA for all users
- enforceTwoFactorAuthentication: false
- # enforce 2FA for specific authentication providers
- enforce2FAForAuthenticationProviders : []
- # enforce 2FA for specific roles
- enforce2FAForRoles: []
- # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used
- issuerName: ''
diff --git a/Migrations/Mysql/Version20260325141345.php b/Migrations/Mysql/Version20260325141345.php
new file mode 100644
index 0000000..121dabf
--- /dev/null
+++ b/Migrations/Mysql/Version20260325141345.php
@@ -0,0 +1,41 @@
+abortIf(
+ !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform,
+ "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'."
+ );
+
+ $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD name VARCHAR(255) NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->abortIf(
+ !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform,
+ "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'."
+ );
+
+ $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor DROP name');
+ }
+}
diff --git a/README.md b/README.md
index 4573608..2c1f8bf 100644
--- a/README.md
+++ b/README.md
@@ -177,3 +177,91 @@ causes the same exception again. We get caught in an endless redirect.
The [Neos Flow Security Documentation](https://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Security.html#multi-factor-authentication-strategy)
suggests how to implement a multi-factor-authentication, but this method seems like it was never tested. At the moment of writing
it seems like the `authenticationStrategy: allTokens` flag is broken and not usable.
+
+## Contributing
+
+### Testing
+
+The package ships with end-to-end tests built on [Playwright](https://playwright.dev) and written in Gherkin syntax via [playwright-bdd](https://vitalets.github.io/playwright-bdd/).
+
+#### Running the tests
+
+Tests require Docker and Node.js. Install dependencies once (if [nvm](https://github.com/nvm-sh/nvm) is available it will automatically switch to the Node version from `.nvmrc`):
+
+```bash
+make setup-test
+```
+
+Re-generate Playwright spec files whenever a `.feature` file changes:
+```bash
+make generate-bdd-files
+```
+
+Use the Makefile targets from the repository root:
+
+```bash
+make test # run all tests (neos8 + neos9, all configurations)
+
+make test-neos8 # run all neos8 tests
+make test-neos8-defaults # default configuration only
+make test-neos8-enforce-all # enforceTwoFactorAuthentication: true
+make test-neos8-enforce-role
+make test-neos8-enforce-provider
+make test-neos8-issuer-name
+
+make test-neos9 # same targets for neos9 / PHP 8.3
+
+make down # tear down all docker compose environments and remove volumes
+```
+
+#### Debugging tests
+To debug a test, run the test from `Tests/E2E/` with flags like this:
+
+- `npm run test:neos8:enforce-all -- --debug` - to run the test in headed mode with Playwright Inspector
+- `npm run test:neos8:enforce-all -- --ui` - to run the test in headed mode with Playwright Test Runner UI
+
+If you just want to see the test running in the browser just `npm run test:neos8:enforce-all -- --headed`.
+
+> While debugging you can also enter the SUT with `make enter-neos8` and `make enter-neos9` respectively.
+>
+> You can even the tests you want to debug with `npm run test:neos8:enforce-all -- --grep @debug` and adding the `@debug` tag to the scenario you want to debug. But using the --ui flag is usually more convenient for debugging.
+
+#### System under test (SUT)
+
+There are two docker compose environments in `Tests/system_under_test/`:
+
+- `neos8/` — Neos with PHP 8.2
+- `neos9/` — Neos with PHP 8.5
+
+Both are built from the repository root as the Docker build context, so the local package source is copied into the container and installed via a Composer path repository. This means every test run tests the _current working tree_ of the package, not a published version.
+
+#### Configuration variants
+
+The `FLOW_CONTEXT` environment variable is passed into the docker compose environment via variable substitution, and Flow's hierarchical configuration loading picks up the corresponding `Settings.yaml` from the SUT:
+
+| Playwright tag | `FLOW_CONTEXT` | What is tested |
+|---|---|---|
+| `@default-context` | `Production/E2E-SUT` | No enforcement — 2FA is optional |
+| `@enforce-for-all` | `Production/E2E-SUT/EnforceForAll` | `enforceTwoFactorAuthentication: true` |
+| `@enforce-for-role` | `Production/E2E-SUT/EnforceForRole` | Enforcement scoped to `Neos.Neos:Administrator` |
+| `@enforce-for-provider` | `Production/E2E-SUT/EnforceForProvider` | Enforcement scoped to an authentication provider |
+| `@issuer-name-change` | `Production/E2E-SUT/IssuerNameChange` | Custom `issuerName` setting |
+
+#### Test isolation
+
+Each scenario starts with a clean state. An `AfterScenario` hook runs after every scenario to:
+
+1. Log the browser out via a POST to `/neos/logout`
+2. Delete all Neos users (`./flow user:delete --assume-yes '*'`)
+
+Deleting all users also cascades to their 2FA devices, so no separate cleanup step is needed. Users and devices are re-created by the Background steps at the start of each scenario.
+
+#### Design decisions
+
+**Gherkin / BDD over plain Playwright specs** — the feature files document the intended behaviour of each configuration variant at a level that is readable without knowing the implementation. The generated Playwright spec files (`.features-gen/`) are not committed; they are re-generated by `bddgen` before each test run.
+
+**UI-only device enrolment** — 2FA devices are enrolled through the browser UI (the backend module or the setup page) rather than a dedicated CLI command. This avoids coupling the tests to internal persistence details and exercises the same enrolment path a real user would take. The `deviceNameSecretMap` in `helpers/state.ts` carries TOTP secrets across steps within a scenario (e.g. from the enrolment step to the OTP entry step).
+
+**Sequential execution** — tests run with `workers: 1` and `fullyParallel: false` because all scenarios share a single running SUT container and a single database. Running them in parallel would cause interference between scenarios.
+
+**User creation via `docker exec`** — Neos user creation is done through the Flow CLI (`./flow user:create`) rather than the UI because the UI path is not part of what this package tests, and using the CLI is faster and more reliable for setup.
diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion
index 16ed82a..e573c51 100644
--- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion
+++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion
@@ -87,10 +87,18 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF
attributes.aria-label={I18n.id('otp-placeholder').package('Sandstorm.NeosTwoFactorAuthentication')}
attributes.autocomplete="off"
/>
+
| {I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | +{I18n.id('module.index.list.header.username').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | {I18n.id('module.index.list.header.type').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | +{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | {I18n.id('module.index.list.header.creationDate').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | |
|---|---|---|---|---|---|
| {props.factorAndPerson.user.name.fullName} ({props.factorAndPerson.secondFactor.account.accountIdentifier}) | {props.factorAndPerson.secondFactor.typeAsName} | +{props.factorAndPerson.secondFactor.name == null ? '-' : props.factorAndPerson.secondFactor.name} | {props.factorAndPerson.secondFactor.creationDate == null ? '-' : Date.format(props.factorAndPerson.secondFactor.creationDate, 'Y-m-d H:i')} |
-
@@ -71,7 +73,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry
{I18n.id('module.index.delete.cancel').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
diff --git a/Resources/Private/Translations/de/Backend.xlf b/Resources/Private/Translations/de/Backend.xlf
index 9ba96b8..29881a1 100644
--- a/Resources/Private/Translations/de/Backend.xlf
+++ b/Resources/Private/Translations/de/Backend.xlf
@@ -15,14 +15,18 @@
|