Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.git/
.idea/
node_modules/
vendor/
Packages
.github
.claude
.idea
composer.lock
60 changes: 60 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions Classes/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions Classes/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions Classes/Domain/Model/SecondFactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -73,6 +80,7 @@ public function getType(): int
}

/**
* Used in Fusion rendering
* @return string
*/
public function getTypeAsName(): string
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion Classes/Domain/Repository/SecondFactorRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions Configuration/Settings.2FA.yaml
Original file line number Diff line number Diff line change
@@ -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: ''
11 changes: 0 additions & 11 deletions Configuration/Settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
41 changes: 41 additions & 0 deletions Migrations/Mysql/Version20260325141345.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Neos\Flow\Persistence\Doctrine\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260325141345 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() 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 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');
}
}
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,18 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF
attributes.aria-label={I18n.id('otp-placeholder').package('Sandstorm.NeosTwoFactorAuthentication')}
attributes.autocomplete="off"
/>
<Neos.Fusion.Form:Input
field.name="name"
attributes.id="name"
attributes.placeholder={I18n.id('form.name.label').package('Sandstorm.NeosTwoFactorAuthentication')}
attributes.aria-label={I18n.id('form.name.label').package('Sandstorm.NeosTwoFactorAuthentication')}
/>
</div>

<div class="neos-control-group">
<Neos.Fusion.Form:Button>
<Neos.Fusion.Form:Button
attributes.data-test-id="create-second-factor-submit-button"
>
{I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
</Neos.Fusion.Form:Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList) < pr
<table class="neos-table">
<thead>
<tr>
<th>{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}</th>
<th>{I18n.id('module.index.list.header.username').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}</th>
<th>{I18n.id('module.index.list.header.type').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}</th>
<th>{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}</th>
<th>{I18n.id('module.index.list.header.creationDate').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}</th>
<th>&nbsp;</th>
</tr>
Expand Down Expand Up @@ -43,9 +44,10 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry
<tr>
<td>{props.factorAndPerson.user.name.fullName} ({props.factorAndPerson.secondFactor.account.accountIdentifier})</td>
<td>{props.factorAndPerson.secondFactor.typeAsName}</td>
<td>{props.factorAndPerson.secondFactor.name == null ? '-' : props.factorAndPerson.secondFactor.name}</td>
<td>{props.factorAndPerson.secondFactor.creationDate == null ? '-' : Date.format(props.factorAndPerson.secondFactor.creationDate, 'Y-m-d H:i')}</td>
<td>
<button class="neos-button neos-button-danger" data-toggle="modal"
<button class="neos-button neos-button-danger" data-toggle="modal" data-test-id="delete-second-factor-button"
href={'#user-' + props.iterator.index} title={I18n.id('module.index.list.action.delete').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} data-neos-toggle="tooltip">
<i class="fas fa-trash-alt icon-white"></i>
</button>
Expand All @@ -71,7 +73,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry
{I18n.id('module.index.delete.cancel').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
</a>
<Neos.Fusion.Form:Form form.target.action="delete" attributes.class="neos-inline" form.target.arguments={{secondFactor: props.factorAndPerson.secondFactor}}>
<Neos.Fusion.Form:Button attributes.class="neos-button neos-button-danger">
<Neos.Fusion.Form:Button attributes.class="neos-button neos-button-danger" attributes.data-test-id="confirm-delete">
{I18n.id('module.index.delete.confirm').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
</Neos.Fusion.Form:Button>
</Neos.Fusion.Form:Form>
Expand Down
Loading
Loading