Download the PHP package sandstorm/neostwofactorauthentication without Composer
On this page you can find all versions of the php package sandstorm/neostwofactorauthentication. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Informations about the package neostwofactorauthentication
Neos Backend 2FA
Extend the Neos backend login to support second factors and passwordless passkey login. We support TOTP tokens (Authenticator apps) and WebAuthn / FIDO2 passkeys — both platform authenticators (Touch ID, Windows Hello) and hardware security keys (e.g. Yubikey).
⚠️ Security Notice: Versions prior to
4.0.0are affected by a security vulnerability in the underlyingweb-authn-libdependency used for WebAuthn/FIDO2 second-factor authentication. This has been fixed in v4. Please upgrade to v4 or later immediately — see PR #56 for details.
Installation
What this package does
This package allows all users to register multiple second factors — either a user- and passwordless Passkey, or a second factor in addition to their username and password. Second factors can be TOTP tokens (Authenticator App) or passkeys (WebAuthn / FIDO2: a platform authenticator such as Touch ID or Windows Hello, or a hardware key such as a Yubikey). Users can register multiple and pick which to use at login. When passwordless login is enabled (see Passwordless passkey login), a discoverable passkey can also be used to sign in straight from the login screen without a password. As an Administrator you are able to delete factors for users again, in case they locked themselves out.
The management module distinguishes the two kinds of WebAuthn credential by a badge: a discoverable, passwordless-capable credential is shown as "Passkey", a non-discoverable one (usable only as a second factor, e.g. a U2F-only key) as "Passkey as 2nd factor".
Passkeys (WebAuthn / FIDO2)
Browsers expose WebAuthn only over https:// or on localhost. Make sure the Neos backend is served
over HTTPS in production, otherwise the passkey flow will fail. Note that an IP address (e.g.
127.0.0.1) cannot be used as the relying-party id — use localhost for local development.
Configure the relying party identifier when your backend hostname differs from the registered domain:
Local development over plain HTTP
WebAuthn requires a secure context. The browser treats localhost as secure, but the verification
library is stricter: it only exempts the exact host localhost from the HTTPS requirement, and
rejects any other host served over http:// (including localhost subdomains such as
myproject.localhost) with Invalid scheme. HTTPS required..
If your local backend runs over plain HTTP on a host other than localhost, list that host under
securedRelyingPartyIds to skip the HTTPS check for it:
Keep this scoped to a development context (e.g. Configuration/Development/Settings.yaml). In
production the backend must be served over HTTPS and this setting should stay empty (the default).
Passwordless passkey login
A discoverable passkey is inherently multi-factor (something you have + the verification you perform on the device), so it can serve as the only login step. When enabled, a "Sign in with a passkey" button appears on the Neos login screen and a single tap signs the user into the backend — no username, no password.
This is disabled by default and must be turned on explicitly. The gate is enforced server-side (the endpoints reject requests while disabled):
Notes:
- A passkey usable for passwordless login must be discoverable (resident) and user-verifying. Credentials registered while this setting was off, or on U2F-only keys (e.g. YubiKey 4), are non-discoverable and remain usable only as a second factor.
- The management module shows a "Register a passkey" banner nudging users who have no discoverable credential yet, so they can opt into faster sign-in.
Attestation
There is no setting for attestation. We always request the none conveyance preference, so the
browser does not return identifying attestation data about the authenticator. Only the none and
fido-u2f attestation statement formats are accepted when loading a credential (the latter is
required for U2F-only authenticators registered via the browser's U2F-compat fallback). Other
attestation statement types are not supported yet.
Authenticator compatibility
The first two columns describe registering a credential as a second factor (the effect of the
userVerification setting). The last column describes whether the authenticator can be used for
passwordless login, which always requires a discoverable (resident) credential with user
verification — independent of the userVerification setting.
| Authenticator | 2nd factor — userVerification: discouraged |
2nd factor — userVerification: required |
Passwordless passkey |
|---|---|---|---|
| YubiKey 5 / FIDO2 keys | ✅ touch | ✅ PIN + touch | ✅ resident key + PIN/touch |
| YubiKey 4 / older U2F-only keys | ✅ touch (U2F-compat) | ❌ not supported | ❌ no resident credentials |
| Platform authenticators (Touch ID, Windows Hello) | ✅ biometric | ✅ biometric | ✅ resident key + biometric |
When
passwordlessLoginEnabledis on, every new registration requires a resident key + user verification, so U2F-only keys (e.g. YubiKey 4) can no longer be registered at all while it is enabled — turn it off if you need to enrol such a key as a second factor. Bear in mind that resident credentials also occupy a limited number of slots on hardware keys.
Versioning Scheme
| Package Version | Neos / Flow Version | Supported | Remarks |
|---|---|---|---|
| 4.x | 9.x, 8.x. | ✅ | main branch |
| 3.x | 9.x, 8.x. | ||
| 2.x | 9.x, 8.x, 7.x | ||
| 1.x | 9.x, 8.x, 7.x, 3.x |
Settings
Enforce 2FA
To enforce the setup and usage of 2FA you can add the following to your Settings.yaml.
With this setting, no user can login into the CMS without setting up a second factor first.
In addition, you can enforce 2FA for specific authentication providers and/or roles by adding following to your Settings.yaml
Issuer Naming
To override the default sitename as issuer label, you can define one via the configuration settings:
TOTP leeway
By default, TOTP codes are verified against the current 30-second window only, with no tolerance for clock drift between the user's device and the server. If users occasionally hit "invalid code" errors near the boundary of a code's lifetime, you can allow some drift via:
Tested 2FA apps
Thx to @Sebobo @Benjamin-K for creating a list of supported and testet apps!
iOS:
- Google Authenticator (used for development) ✅
- Authy ✅
- Microsoft Authenticator ✅
- 1Password ✅
Android:
- Google Authenticator ✅
- Microsoft Authenticator ✅
- Authy ✅
How we did it
-
We introduced a new middleware
SecondFactorMiddlewarewhich handles 2FA on a NeosSessionbasis.- This is an overview of the checks the
SecondFactorMiddlewaredoes for any request:
- This is an overview of the checks the
- Passwordless passkey login is a separate, opt-in mechanism that does primary authentication —
the middleware above is only a post-login gate. Because a Flow provider name maps to exactly one
token class, we did not try to make the username/password provider also accept a passkey. Instead
we added a parallel authentication provider + token (
WebAuthnPasswordlessProvider/WebAuthnPasswordlessToken), registered with no request pattern and no entry point.PasswordlessLoginControllerverifies the WebAuthn assertion, resolves theNeos.Neos:Backendaccount from the assertion's user handle (the account's persistence id), sets the parallel token toAUTHENTICATION_SUCCESSFUL, callssecurityContext->refreshTokens()to persist it to the session, and marks the package's 2FA session statusAUTHENTICATED.- It survives the redirect to
/neosbecauseAuthenticationProviderManageronly re-runs providers for tokens that areAUTHENTICATION_NEEDED, and the Neos backend usesauthenticationStrategy: oneToken— so a single authenticated token authenticates the request.UserService::getCurrentUser()resolves the Neos user from the account's party regardless of which provider authenticated it, so roles and the backend UI work normally. - A user-verified passkey is inherently multi-factor, so the 2FA gate is satisfied in one tap
(passwordless login sets the 2FA session status
AUTHENTICATED, which is check #5 above). - The "Sign in with a passkey" button is injected into the core Neos login screen by overriding
Neos.Neos:Component.Login.Form(renderer.@process) plus aViews.yamlentry. The core login is rendered by a plainFusionViewthat does not applyfusion.autoIncludeand has no script hook, so the (gated) button also emits its own<script>to load the ceremony JS. The XHR endpoints are reachable thanks to aPolicy.yamlgrant.
When updating Neos, those part will likely crash:
- the login screen for the second factor is a hard copy of the login screen from the
Neos.Neospackage- just replaced the username/password form with the form for the second factor
- maybe has to be replaced when neos gets updated
- the passwordless "Sign in with a passkey" button is injected into the core Neos login screen via
a
Views.yamloverride (pointing atresource://Neos.Neos/Private/Fusion/Backend) plus a@processonNeos.Neos:Component.Login.Form. If Neos restructures its login Fusion or view configuration, this injection may need to be re-aligned. - hopefully the rest of this package is solid enough to survive the next mayor Neos versions ;)
Why not ...?
Enhance the UsernamePassword authentication token
This actually has been the approach up until version 1.0.5.
One issue with this is the fact, that we want the user to be logged in with that token via the
PersistedUsernamePasswordProvider, but at the same time to not be logged in with that token as long as 2FA is
not authenticated as well.
We found it hard to find a secure way to model the 2FA setup solution when 2FA is enforced, but the user does not have a
second factor enabled, yet.
The middleware approach makes a clear distinction between "Logging in" and "Second Factor Authentication", while still being session based and unable to bypass.
Set the authenticationStrategy to allTokens
The AuthenticationProviderManager requires to authorize all tokens at the same time otherwise, it will throw an Exception (see AuthenticationProviderManager Line 181
)
This leads to an error where the AuthenticationProviderManager throws exceptions before the user is able to enter any
credentials. The SecurityEntryPointMiddleware catches those exceptions and redirects to the Neos Backend Login, which
causes the same exception again. We get caught in an endless redirect.
The Neos Flow Security Documentation
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 and written in Gherkin syntax via playwright-bdd.
Running the tests
Tests require Docker and Node.js. All Makefile targets are run from Tests/E2E/. Run the initial setup once — it builds the SUT images and installs the test dependencies (if nvm is available it will automatically switch to the Node version from .nvmrc):
Re-generate Playwright spec files whenever a .feature file changes:
Run the tests:
Run make help to see all available targets.
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 Inspectornpm 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-sut-neos8andmake enter-sut-neos9respectively.You can even the tests you want to debug with
npm run test:neos8:enforce-all -- --grep @debugand adding the@debugtag 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.2neos9/— 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 |
@passwordless |
Production/E2E-SUT/Passwordless |
webAuthn.passwordlessLoginEnabled: true — passwordless passkey login |
Test isolation
Each scenario starts with a clean state. An AfterScenario hook runs after every scenario to:
- Log the browser out via a POST to
/neos/logout - 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.
All versions of neostwofactorauthentication with dependencies
neos/neos Version ^8.0 | ^9.0
neos/fusion Version *
neos/fusion-afx Version *
neos/fusion-form Version *
spomky-labs/otphp Version ^11.5
chillerlan/php-qrcode Version ^5.0
web-auth/webauthn-lib Version ^5.3.5
symfony/serializer Version ^6.4 | ^7.0 | ^8.0