diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 4947eab..e703991 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -278,6 +278,27 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro $this->redirectToInterceptedRequestOrBackend(); } + /** + * Abort the login process from the 2FA challenge or enforced-setup screen: tear down + * the half-authenticated session and bounce the user back to the regular login screen. + * + * @throws StopActionException + */ + public function cancelLoginAction(): void + { + // Resolve the redirect target *before* destroying the session: the intercepted request + // lives in the (session-backed) security context and would be gone afterwards. Sending the + // user back to that same URI means the security entry point re-intercepts it, so the next + // login attempt resumes at the page they originally wanted instead of the backend default. + $redirectUri = $this->interceptedRequestOrBackendUri(); + + $this->secondFactorSessionStorageService->cancelLoginAttempt(); + + // The session (including the Neos backend authentication) is gone now, so this secured + // target bounces straight to the login screen via the security entry point. + $this->redirectToUri($redirectUri); + } + // ------------------------------------------------------------------ // WebAuthn XHR endpoints (registration ceremony) // ------------------------------------------------------------------ diff --git a/Classes/Http/Middleware/SecondFactorMiddleware.php b/Classes/Http/Middleware/SecondFactorMiddleware.php index d583ac7..7810827 100644 --- a/Classes/Http/Middleware/SecondFactorMiddleware.php +++ b/Classes/Http/Middleware/SecondFactorMiddleware.php @@ -21,6 +21,7 @@ class SecondFactorMiddleware implements MiddlewareInterface { const LOGGING_PREFIX = 'Sandstorm/NeosTwoFactorAuthentication: '; + const SECOND_FACTOR_PACKAGE_KEY = 'Sandstorm.NeosTwoFactorAuthentication'; const SECOND_FACTOR_LOGIN_URI = '/neos/second-factor-login'; const SECOND_FACTOR_SETUP_URI = '/neos/second-factor-setup'; @@ -167,6 +168,19 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } + // Let a cancellation request through regardless of the 2FA state (challenge or enforced setup), + // so the controller can tear down the half-authenticated session instead of being redirected + // back to the 2FA screen by the checks below. + $routingMatchResults = $request->getAttribute(ServerRequestAttributes::ROUTING_RESULTS) ?? []; + if ( + ($routingMatchResults['@package'] ?? '') === self::SECOND_FACTOR_PACKAGE_KEY + && ($routingMatchResults['@action'] ?? '') === 'cancelLogin' + ) { + $this->log('Second factor login cancellation requested, skipping second factor.'); + + return $handler->handle($request); + } + $account = $this->securityContext->getAccount(); $isEnabledForAccount = $this->secondFactorService->isSecondFactorEnabledForAccount($account); diff --git a/Classes/Service/SecondFactorSessionStorageService.php b/Classes/Service/SecondFactorSessionStorageService.php index 84e6e77..17f1806 100644 --- a/Classes/Service/SecondFactorSessionStorageService.php +++ b/Classes/Service/SecondFactorSessionStorageService.php @@ -85,4 +85,18 @@ public function removeValue(string $key): void unset($data[$key]); $session->putData(self::SESSION_OBJECT_ID, $data); } + + /** + * Abort the login attempt: destroy the whole session to leave the in-between state + * of "authenticated with username/password, but not yet with the second factor". + * This also drops the Neos backend authentication, so the next request lands on the + * regular login screen again. + */ + public function cancelLoginAttempt(): void + { + $session = $this->sessionManager->getCurrentSession(); + if ($session->isStarted()) { + $session->destroy('Second factor login attempt cancelled by user.'); + } + } } diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml index 92180b2..09433cc 100644 --- a/Configuration/Routes.yaml +++ b/Configuration/Routes.yaml @@ -37,6 +37,15 @@ '@format': 'html' httpMethods: ['POST'] +- name: 'Sandstorm Two Factor Authentication - Cancel Login' + uriPattern: 'neos/second-factor-cancel-login' + defaults: + '@package': 'Sandstorm.NeosTwoFactorAuthentication' + '@controller': 'Login' + '@action': 'cancelLogin' + '@format': 'html' + httpMethods: ['POST'] + - name: 'Sandstorm Two Factor Authentication - Setup TOTP' uriPattern: 'neos/second-factor-setup/totp' defaults: diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 8b7e951..4b6761f 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -13,7 +13,7 @@ Neos: icon: 'fas fa-qrcode' additionalResources: styleSheets: - - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Login.css' + - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Shared.css' - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Backend.css' javaScripts: - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/index.js' @@ -27,6 +27,7 @@ Neos: backendLoginForm: stylesheets: + 'Sandstorm.NeosTwoFactorAuthentication:SharedStyles': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Shared.css' 'Sandstorm.NeosTwoFactorAuthentication:AdditionalStyles': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Login.css' scripts: 'Sandstorm.NeosTwoFactorAuthentication:AdditionalScripts': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/index.js' diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion index 6816c25..a4bfec3 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -8,9 +8,9 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF content = Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker { items = Neos.Fusion:DataStructure { totp = Neos.Fusion:DataStructure { + id = 'method.totp' label = ${I18n.id('method.totp.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.totp.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} - testId = 'select-method-totp' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Backend' @@ -18,9 +18,9 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF } } webauthn = Neos.Fusion:DataStructure { + id = 'method.webauthn' label = ${I18n.id('method.webauthn.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.webauthn.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} - testId = 'select-method-webauthn' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Backend' diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion index 3e17bbf..739a9f9 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/NewTotp.fusion @@ -126,7 +126,6 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.newTotp = Sandstorm.Neos
{I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} diff --git a/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion index ffd517e..4c14750 100644 --- a/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion @@ -8,9 +8,9 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.setupSecondFactor = Sandst body = Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker { items = Neos.Fusion:DataStructure { totp = Neos.Fusion:DataStructure { + id = 'method.totp' label = ${I18n.id('method.totp.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.totp.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} - testId = 'select-method-totp' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Login' @@ -18,9 +18,9 @@ Sandstorm.NeosTwoFactorAuthentication.LoginController.setupSecondFactor = Sandst } } webauthn = Neos.Fusion:DataStructure { + id = 'method.webauthn' label = ${I18n.id('method.webauthn.label').package('Sandstorm.NeosTwoFactorAuthentication').translate()} description = ${I18n.id('method.webauthn.description').package('Sandstorm.NeosTwoFactorAuthentication').translate()} - testId = 'select-method-webauthn' href = Neos.Fusion:UriBuilder { package = 'Sandstorm.NeosTwoFactorAuthentication' controller = 'Login' diff --git a/Resources/Private/Fusion/Presentation/Components/CancelLoginButton.fusion b/Resources/Private/Fusion/Presentation/Components/CancelLoginButton.fusion new file mode 100644 index 0000000..36450c3 --- /dev/null +++ b/Resources/Private/Fusion/Presentation/Components/CancelLoginButton.fusion @@ -0,0 +1,22 @@ +prototype(Sandstorm.NeosTwoFactorAuthentication:Component.CancelLoginButton) < prototype(Neos.Fusion:Component) { + # Secondary "escape hatch" shown on every 2FA screen (challenge + enforced setup). + # It is a self-contained form, so it works whether or not the surrounding step is itself + # a form (the WebAuthn step and method picker are not), and avoids invalid nested forms. + renderer = afx` + + + {I18n.id('login.cancel').value('Cancel and return to login').package('Sandstorm.NeosTwoFactorAuthentication').translate()} + + + ` +} diff --git a/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion index 17d216d..eb2d931 100644 --- a/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion +++ b/Resources/Private/Fusion/Presentation/Components/MethodPicker.fusion @@ -1,16 +1,17 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.MethodPicker) < prototype(Neos.Fusion:Component) { - # array of { label, description, href, testId } + # array of { id, label, description, href } + # Each method is a link whose visible label is its accessible name, so it can + # be addressed in tests by role+name (e.g. "Authenticator app") — no test id needed. items = ${[]} renderer = afx` -
-
+
- - {item.description} +
diff --git a/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion b/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion index f3f65a5..00a24c7 100644 --- a/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion @@ -47,8 +47,8 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry {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')} - @@ -56,7 +56,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry
- +
{I18n.id('module.index.delete.header').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
diff --git a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion index 7aca70a..8e4d11c 100644 --- a/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SetupTotpStep.fusion @@ -5,95 +5,92 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SetupTotpStep) < proto renderer = afx` -
+
-
-
- -
- -
+
+ +
+ +
- -
-
-

- { - Array.join( - Array.map( - String.split(props.secret, ''), - char => Type.isNumeric(char) ? '' + char + '' : '' + char + '' - ), - '' - ) - } -

- - -
-
- -
- - -
+ +
+ -
- - {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} - -
+
+
+ + + + {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} + +
+
` } diff --git a/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion b/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion index 3bf99e4..c0c5f60 100644 --- a/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion +++ b/Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion @@ -79,8 +79,9 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage) < pr {I18n.id('login.index.title').value('Login to').package('Neos.Neos').source('Main')} {site.name} - diff --git a/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion b/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion index babaaf6..510bf48 100644 --- a/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion +++ b/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion @@ -77,9 +77,10 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.SetupSecondFactorPage) < pr {I18n.id('login.index.title').value('Login to').package('Neos.Neos').source('Main')} {props.site.name} - diff --git a/Resources/Private/Translations/de/Backend.xlf b/Resources/Private/Translations/de/Backend.xlf index 120bffe..b5f7067 100644 --- a/Resources/Private/Translations/de/Backend.xlf +++ b/Resources/Private/Translations/de/Backend.xlf @@ -52,6 +52,10 @@ Cancel Abbrechen + + Close + Schließen + Delete second factor Zweiten Faktor löschen diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf index 0ec7dec..e3f01ef 100644 --- a/Resources/Private/Translations/de/Main.xlf +++ b/Resources/Private/Translations/de/Main.xlf @@ -114,6 +114,10 @@ or oder + + Cancel and return to login + Abbrechen und zurück zur Anmeldung + Could not verify your security key. Please try again. Der Sicherheitsschlüssel konnte nicht überprüft werden. Bitte versuchen Sie es erneut. diff --git a/Resources/Private/Translations/en/Backend.xlf b/Resources/Private/Translations/en/Backend.xlf index 593c517..fe5433b 100644 --- a/Resources/Private/Translations/en/Backend.xlf +++ b/Resources/Private/Translations/en/Backend.xlf @@ -40,6 +40,9 @@ Cancel + + Close + Delete second factor diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index 7ca5b7f..d37b095 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -86,6 +86,9 @@ or + + Cancel and return to login + Could not verify your security key. Please try again. diff --git a/Resources/Public/Styles/Backend.css b/Resources/Public/Styles/Backend.css index f48b957..b5d5419 100644 --- a/Resources/Public/Styles/Backend.css +++ b/Resources/Public/Styles/Backend.css @@ -1,3 +1,8 @@ +/* + * Styles used ONLY by the backend module (Sandstorm\...\Controller\BackendController). + * Loaded after Shared.css. Nothing in here is loaded on the login screen. + */ + .neos-two-factor__form { max-width: 800px; } @@ -83,6 +88,21 @@ img.neos-two-factor__qr-code { height: unset !important; } +/* Keyboard focus ring for buttons and button-styled links. Backend.css only loads on this + package's standalone module page, so .neos-button is already package-scoped. !important + beats Neos core's `:focus { outline: 0 }` reset. */ +.neos-button:focus-visible { + outline: 2px solid #00b5ff !important; + outline-offset: 2px; +} + +/* Method picker — backend layout: compact button on the right, description to its left. + (Login.css gives the login screen a stacked column layout instead.) */ +.neos div.neos-two-factor__method-picker { + margin-top: 16px; + gap: 12px; +} + /* * Backend module pages DO load Neos core's Foundation/_tooltip.scss (the `.neos`-scoped base * tooltip: absolute, black, centred 5px arrow) but NOT Login.scss (which adds the in-flow layout diff --git a/Resources/Public/Styles/Login.css b/Resources/Public/Styles/Login.css index cd1e660..3d8c648 100644 --- a/Resources/Public/Styles/Login.css +++ b/Resources/Public/Styles/Login.css @@ -1,3 +1,9 @@ +/* + * Styles used ONLY by the 2FA login/setup flow (Sandstorm\...\Controller\LoginController, + * rendered inside Neos' .neos-login-body shell). Loaded after Shared.css. + * Nothing in here is loaded by the backend module. + */ + .neos-two-factor-flashmessage { font-size: 13px; line-height: 1.4; @@ -23,239 +29,79 @@ opacity: 0.85; } -.neos-two-factor__secret__show__button { - background-color: #00b5ff !important; -} - -.neos-two-factor__secret-wrapper { - /* specificity hack */ - display: flex !important; -} - -.neos-two-factor__secret-wrapper dialog { - padding: 8px; - - border: none; -} - -.neos-two-factor__secret-wrapper dialog::backdrop { - background-color: rgba(0, 0, 0, 0.5); +/* Method picker — login layout: stacked column, light text on the dark login box. + (Backend.css gives the backend module a button-beside-description row layout.) */ +.neos-two-factor__method-picker { + gap: 16px; } -.neos-two-factor__secret-wrapper dialog > div { - display: flex; +.neos-two-factor__method-picker__row { flex-direction: column; - align-items: center; - gap: 8px; -} - -.neos-two-factor__secret-wrapper dialog .neos-two-factor__dialog__actions { - display: flex; - gap: 8px; -} - -.neos-two-factor__secret-wrapper dialog > div > .neos-actions { - max-width: 200px; -} - -.neos-two-factor__secret__copy__button { - /* specificity hack */ - display: flex !important; gap: 8px; - align-items: center; - justify-content: center; -} - -.neos-two-factor__secret__copy__button span, -.neos-two-factor__secret__copy__button span i { - display: flex; - align-items: center; } -.neos-two-factor__secret__copy__button svg { - height: 16px; - width: 16px; - - fill: #fff; +.neos-two-factor__method-picker__description { color: #fff; } -.neos-two-factor__hidden { - /* specificity hack */ - display: none !important; -} - -.neos-two-factor__secret { - position: relative; - display: block; - width: 100%; - overflow: hidden; - - font-size: 14px; - line-height: 1.6em; -} - -.neos-two-factor__secret div, -.neos-two-factor__secret p, -.neos-two-factor__secret svg { - box-sizing: content-box; - margin: 0 !important; - padding: 0 !important; -} - -.neos-two-factor__secret p { - overflow: scroll; - - color: #0f0f0f; - font-family: monospace; -} - -.neos-two-factor__secret span:nth-child(3n) { - margin-right: 4px; -} - -.neos-two-factor__secret .neos-two-factor__secret__number { - color: #007ead; -} - -.neos-two-factor__secret__overflow-indicator--left, -.neos-two-factor__secret__overflow-indicator--right { - position: absolute; - top: 0; - width: 10em; - height: 1.6em; - +/* Separator between the two methods on the 2FA challenge screen */ +.neos-two-factor__or-separator { display: flex; align-items: center; - - pointer-events: none; -} - -.neos-two-factor__secret__overflow-indicator--left svg, -.neos-two-factor__secret__overflow-indicator--right svg { - height: 1.2em; - - fill: #3f3f3f; -} - -.neos-two-factor__secret__overflow-indicator--left { - left: 0; - - justify-content: left; - - background: linear-gradient(to left, rgba(255, 255, 255, 0), #fff); -} - -.neos-two-factor__secret__overflow-indicator--right { - right: 0; - - justify-content: right; - - background: linear-gradient(to right, rgba(255, 255, 255, 0), #fff); -} - -/* override custom neos backend scrollbar styles */ -.neos-two-factor__secret-wrapper ::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.neos-two-factor__secret-wrapper ::-webkit-scrollbar-corner { - background-color: initial; -} - -.neos-two-factor__secret-wrapper ::-webkit-scrollbar-thumb { - background-color: initial; - border: initial; -} - -.neos-two-factor__secret-wrapper ::-webkit-scrollbar-track { - background-color: initial; -} - -/* Method picker (2FA setup) — compact button on the left, description to its right */ -.neos-two-factor__method-picker { - display: flex; - flex-direction: column; gap: 12px; - margin-top: 16px; -} - -.neos-two-factor__method-picker__row { - display: flex; - align-items: center; - gap: 16px; -} - -.neos-two-factor__method-picker__button { - flex: 0 0 auto; - background-color: #00b5ff !important; - text-decoration: none; - white-space: nowrap; -} - -.neos-two-factor__method-picker__button:hover, -.neos-two-factor__method-picker__button:focus { - text-decoration: none; + margin: 20px 0; + color: #fff; + opacity: .6; + text-transform: uppercase; + font-size: .8rem; + letter-spacing: .1em; } -.neos-two-factor__method-picker__description { - flex: 1 1 auto; - font-size: .9rem; - opacity: .85; - line-height: 1.4; +.neos-two-factor__or-separator::before, +.neos-two-factor__or-separator::after { + content: ''; + flex: 1; + border-top: 1px solid currentColor; + opacity: .5; } -/* WebAuthn step (setup + login) */ -.neos-two-factor__webauthn-step fieldset { - padding: 12px 0; - border: 0; +/* Cancel / abort login — secondary "escape hatch" shown on every 2FA screen */ +.neos-two-factor__cancel-login { + display: flex; + justify-content: center; } -.neos-two-factor__webauthn-prompt, -.neos-two-factor__webauthn-intro { - margin: 0 0 16px; +.neos-two-factor__login button.neos-two-factor__cancel-login__button { + margin-top: 24px; + border: none; + background: transparent !important; color: #fff; - opacity: .9; } -.neos-two-factor__webauthn-button { - background-color: #00b5ff !important; +.neos-two-factor__login button.neos-two-factor__cancel-login__button:hover, +.neos-two-factor__login button.neos-two-factor__cancel-login__button:focus { + text-decoration: underline; } -.neos-two-factor__webauthn-button[disabled] { - opacity: .5; - cursor: not-allowed; +/* Keyboard focus ring for buttons and button-styled links. Scoped to .neos-two-factor__login + (added to our 2FA page templates) so the first-factor Neos login is untouched. !important + beats Neos core's `:focus { outline: 0 }` reset. */ +.neos-two-factor__login .neos-button:focus-visible { + outline: 2px solid #00b5ff !important; + outline-offset: 2px; } -.neos-two-factor__webauthn-fallback { +.neos-two-factor__login fieldset + fieldset { margin-top: 16px; - text-align: center; -} - -.neos-two-factor__webauthn-fallback a { - color: #00adee; - text-decoration: underline; - font-size: .9rem; } -/* Separator between the two methods on the 2FA challenge screen */ -.neos-two-factor__or-separator { +.neos-two-factor__login fieldset .neos-2fa-control-group { display: flex; + flex-direction: column; align-items: center; - gap: 12px; - margin: 20px 0; - color: #fff; - opacity: .6; - text-transform: uppercase; - font-size: .8rem; - letter-spacing: .1em; + gap: 8px; } -.neos-two-factor__or-separator::before, -.neos-two-factor__or-separator::after { - content: ''; - flex: 1; - border-top: 1px solid currentColor; - opacity: .5; +.neos-two-factor__login fieldset .neos-2fa-control-group > .neos-button { + margin-left: 0; } diff --git a/Resources/Public/Styles/Shared.css b/Resources/Public/Styles/Shared.css new file mode 100644 index 0000000..3fc9b07 --- /dev/null +++ b/Resources/Public/Styles/Shared.css @@ -0,0 +1,216 @@ +/* + * Styles shared by BOTH the backend module and the 2FA login/setup flow. + * Loaded in both contexts via shared Fusion components (MethodPicker, + * SetupTotpStep, SetupWebAuthnStep, ...). + * + * Keep these context-neutral. Anything that only makes sense on the login + * screen belongs in Login.css; anything specific to the backend module + * belongs in Backend.css. + */ + +/* --- TOTP secret display (backend NewTotp + login SetupTotpStep) --- */ +.neos-two-factor__secret__show__button { + background-color: #00b5ff !important; +} + +.neos-two-factor__secret-wrapper { + /* specificity hack */ + display: flex !important; +} + +.neos-two-factor__secret-wrapper dialog { + padding: 8px; + + border: none; +} + +.neos-two-factor__secret-wrapper dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +.neos-two-factor__secret-wrapper dialog > div { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.neos-two-factor__secret-wrapper dialog .neos-two-factor__dialog__actions { + display: flex; + gap: 8px; +} + +.neos-two-factor__secret-wrapper dialog > div > .neos-actions { + max-width: 200px; +} + +.neos-two-factor__secret__copy__button { + /* specificity hack */ + display: flex !important; + gap: 8px; + align-items: center; + justify-content: center; +} + +.neos-two-factor__secret__copy__button span, +.neos-two-factor__secret__copy__button span i { + display: flex; + align-items: center; +} + +.neos-two-factor__secret__copy__button svg { + height: 16px; + width: 16px; + + fill: #fff; + color: #fff; +} + +.neos-two-factor__hidden { + /* specificity hack */ + display: none !important; +} + +.neos-two-factor__secret { + position: relative; + display: block; + width: 100%; + overflow: hidden; + + font-size: 14px; + line-height: 1.6em; +} + +.neos-two-factor__secret div, +.neos-two-factor__secret p, +.neos-two-factor__secret svg { + box-sizing: content-box; + margin: 0 !important; + padding: 0 !important; +} + +.neos-two-factor__secret p { + overflow: scroll; + + color: #0f0f0f; + font-family: monospace; +} + +.neos-two-factor__secret span:nth-child(3n) { + margin-right: 4px; +} + +.neos-two-factor__secret .neos-two-factor__secret__number { + color: #007ead; +} + +.neos-two-factor__secret__overflow-indicator--left, +.neos-two-factor__secret__overflow-indicator--right { + position: absolute; + top: 0; + width: 10em; + height: 1.6em; + + display: flex; + align-items: center; + + pointer-events: none; +} + +.neos-two-factor__secret__overflow-indicator--left svg, +.neos-two-factor__secret__overflow-indicator--right svg { + height: 1.2em; + + fill: #3f3f3f; +} + +.neos-two-factor__secret__overflow-indicator--left { + left: 0; + + justify-content: left; + + background: linear-gradient(to left, rgba(255, 255, 255, 0), #fff); +} + +.neos-two-factor__secret__overflow-indicator--right { + right: 0; + + justify-content: right; + + background: linear-gradient(to right, rgba(255, 255, 255, 0), #fff); +} + +/* override custom neos backend scrollbar styles */ +.neos-two-factor__secret-wrapper ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.neos-two-factor__secret-wrapper ::-webkit-scrollbar-corner { + background-color: initial; +} + +.neos-two-factor__secret-wrapper ::-webkit-scrollbar-thumb { + background-color: initial; + border: initial; +} + +.neos-two-factor__secret-wrapper ::-webkit-scrollbar-track { + background-color: initial; +} + +/* --- Method picker (backend New + login SetupSecondFactor) --- */ +/* Base layout only. Per-context direction/spacing live in Backend.css and + Login.css, which each load on top of this file. */ +.neos-two-factor__method-picker { + display: flex; + flex-direction: column; + margin-top: 16px; +} + +.neos-two-factor__method-picker__row { + display: flex; + align-items: center; + gap: 16px; +} + +.neos-two-factor__method-picker__button { + flex: 0 0 auto; + background-color: #00b5ff !important; + text-decoration: none; + white-space: nowrap; +} + +.neos-two-factor__method-picker__button:hover, +.neos-two-factor__method-picker__button:focus { + text-decoration: none; +} + +.neos-two-factor__method-picker__description { + flex: 1 1 auto; + font-size: .9rem; + opacity: .85; + line-height: 1.4; +} + +/* --- WebAuthn step (backend NewWebAuthn + login SetupWebAuthn/challenge) --- */ +.neos-two-factor__webauthn-step fieldset { + padding: 12px 0; + border: 0; +} + +.neos-two-factor__webauthn-prompt, +.neos-two-factor__webauthn-intro { + margin: 0 0 16px; + color: #fff; + opacity: .9; +} + +.neos-two-factor__webauthn-button { + background-color: #00b5ff !important; +} + +.neos-two-factor__webauthn-button[disabled] { + opacity: .5; + cursor: not-allowed; +} diff --git a/Tests/E2E/README.md b/Tests/E2E/README.md index 4d52d1f..3897492 100644 --- a/Tests/E2E/README.md +++ b/Tests/E2E/README.md @@ -235,7 +235,9 @@ To complete a 2FA login a test needs a valid one-time code. `helpers/totp.ts` ge ### 7. The "add second factor" workflow -Adding a second factor (both in the backend module at `/new` and during enforced login setup at `/neos/second-factor-setup`) starts on a **method picker** where the user chooses TOTP or WebAuthn. The picker buttons carry `data-test-id="select-method-totp"` / `select-method-webauthn`. The page objects in `helpers/2fa-pages.ts` (`BackendModulePage.chooseMethod`, `SecondFactorSetupPage.chooseTotp/chooseWebAuthn`) walk this picker before driving the method-specific form. +Adding a second factor (both in the backend module at `/new` and during enforced login setup at `/neos/second-factor-setup`) starts on a **method picker** where the user chooses TOTP or WebAuthn. The picker entries are links selected by their accessible name (`getByRole('link', { name: 'Authenticator app' | 'Security key (Yubikey / WebAuthn)' })`). The page objects in `helpers/2fa-pages.ts` (`BackendModulePage.chooseMethod`, `SecondFactorSetupPage.chooseTotp/chooseWebAuthn`) walk this picker before driving the method-specific form. + +> **Selector convention:** prefer addressing interactive elements by their accessible name (`getByRole(role, { name })`, matching the element's visible label or `aria-label`, which the SUT renders in English). Fall back to `data-test-id` only where an accessible-name selector isn't reliably possible — e.g. the delete-confirmation button, whose accessible name is identical to the row's delete button. ### 8. Testing WebAuthn (security keys) diff --git a/Tests/E2E/features/default/login.feature b/Tests/E2E/features/default/login.feature index 30148cd..4417729 100644 --- a/Tests/E2E/features/default/login.feature +++ b/Tests/E2E/features/default/login.feature @@ -62,6 +62,29 @@ Feature: Login flow with default settings And I restart the WebAuthn challenge and authenticate with my security key Then I should see the Neos content page + Scenario: User can cancel the 2FA verification and return to the login screen + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + And I log out + And I log in with username "admin" and password "password" + And I should see the 2FA verification page + And I cancel the 2FA login + Then I should see the login page + + Scenario: Cancelling the 2FA verification keeps the originally requested page for the next login + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + And I log out + And I open "/neos/management/twoFactorAuthentication" while logged out + And I log in with username "admin" and password "password" + And I should see the 2FA verification page + And I cancel the 2FA login + And I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Test Device" + Then I should land on "/neos/management/twoFactorAuthentication" + Scenario: User is redirected to the originally requested page after logging in with TOTP When I log in with username "admin" and password "password" And I navigate to the 2FA management page diff --git a/Tests/E2E/features/enforce-for-all/login.feature b/Tests/E2E/features/enforce-for-all/login.feature index a0c85c5..6b59352 100644 --- a/Tests/E2E/features/enforce-for-all/login.feature +++ b/Tests/E2E/features/enforce-for-all/login.feature @@ -20,6 +20,12 @@ Feature: Login flow with 2FA enforced for all users Then I should see the 2FA setup page And I should see the 2FA method selection + Scenario: User can cancel the enforced 2FA setup and return to the login screen + When I log in with username "editor" and password "password" + And I should see the 2FA setup page + And I cancel the 2FA login + Then I should see the login page + Scenario: User can log in when after setting up a 2FA device When I log in with username "editor" and password "password" And I set up a 2FA device with name "Editor Test Device" diff --git a/Tests/E2E/helpers/2fa-pages.ts b/Tests/E2E/helpers/2fa-pages.ts index 251c6c6..e0b885b 100644 --- a/Tests/E2E/helpers/2fa-pages.ts +++ b/Tests/E2E/helpers/2fa-pages.ts @@ -1,6 +1,16 @@ import type { Page } from '@playwright/test'; import { generateOtp } from './totp.js'; +/** + * Accessible names (English) of the two method-picker entries, used to select + * them by role+name instead of a test id. These mirror the `method.*.label` + * translations the SUT renders in English. + */ +const METHOD_LINK_NAME = { + totp: 'Authenticator app', + webauthn: 'Security key (Yubikey / WebAuthn)', +} as const; + /** * The 2FA verification page shown on login when the account already has an * enrolled second factor (route: /neos/second-factor-login). @@ -98,12 +108,12 @@ export class SecondFactorSetupPage { } async chooseTotp() { - await this.page.locator('[data-test-id="select-method-totp"]').click(); + await this.page.getByRole('link', { name: METHOD_LINK_NAME.totp, exact: true }).click(); await this.page.waitForURL('**/neos/second-factor-setup/totp'); } async chooseWebAuthn() { - await this.page.locator('[data-test-id="select-method-webauthn"]').click(); + await this.page.getByRole('link', { name: METHOD_LINK_NAME.webauthn, exact: true }).click(); await this.page.waitForURL('**/neos/second-factor-setup/webauthn'); } @@ -119,7 +129,9 @@ export class SecondFactorSetupPage { await this.page.fill('input#name', name); } await this.page.locator('input#secondFactorFromApp').fill(generateOtp(secret)); - await this.page.locator('button[type="submit"]').click(); + // Scope to the TOTP form's submit by its accessible name: the page also + // renders the cancel button, which is itself a type="submit" button. + await this.page.getByRole('button', { name: 'Register second factor', exact: true }).click(); } /** @@ -169,7 +181,7 @@ export class BackendModulePage { /** Open the "add second factor" method picker and select a method. */ async chooseMethod(method: 'totp' | 'webauthn') { await this.page.goto('/neos/management/twoFactorAuthentication/new'); - await this.page.locator(`[data-test-id="select-method-${method}"]`).click(); + await this.page.getByRole('link', { name: METHOD_LINK_NAME[method], exact: true }).click(); } /** @@ -194,7 +206,7 @@ export class BackendModulePage { await this.page.fill('input#name', name); await this.page.fill('input#secondFactorFromApp', generateOtp(secret)); - await this.page.locator('button[data-test-id="create-second-factor-submit-button"]').click(); + await this.page.getByRole('button', { name: 'Register second factor', exact: true }).click(); await this.page.waitForLoadState('networkidle'); // Success redirects to the index (table visible); rejection re-renders the form. @@ -220,7 +232,10 @@ export class BackendModulePage { /** Find the table row for the named device and click the delete button, then confirm. */ async deleteDeviceByName(name: string): Promise { const row = this.locatorForDeviceRow(name); - await row.locator('button[data-test-id="delete-second-factor-button"]').click(); + await row.getByRole('button', { name: 'Delete second factor' }).click(); + // The confirm button shares the same accessible name as the row's delete + // button, so a unique aria-label selector isn't possible here — target it by + // test id, scoped to the now-visible modal. await this.page.locator('button[data-test-id="confirm-delete"]:visible').click(); await this.page.waitForLoadState('networkidle'); } @@ -232,7 +247,7 @@ export class BackendModulePage { */ async tryDeleteDeviceByName(name: string): Promise { const row = this.locatorForDeviceRow(name); - const deleteButton = row.locator('button[data-test-id="delete-second-factor-button"]'); + const deleteButton = row.getByRole('button', { name: 'Delete second factor' }); if (await deleteButton.isDisabled()) { return; diff --git a/Tests/E2E/steps/2fa-login.steps.ts b/Tests/E2E/steps/2fa-login.steps.ts index a2732f2..e2e5127 100644 --- a/Tests/E2E/steps/2fa-login.steps.ts +++ b/Tests/E2E/steps/2fa-login.steps.ts @@ -108,6 +108,16 @@ When('I restart the WebAuthn challenge and authenticate with my security key', a await page.waitForLoadState('networkidle'); }); +When('I cancel the 2FA login', async ({ page }) => { + // The cancel button is the same shared component on both the 2FA verification + // and the enforced-setup screens. Submitting it tears down the half-authenticated + // session; the browser then follows the redirect chain back to the login screen. + // It carries an aria-label, so we target it by its accessible name (rendered in + // English by the SUT) rather than a test id. + await page.getByRole('button', { name: 'Cancel and return to login', exact: true }).click(); + await page.locator('input[type="password"]').waitFor(); +}); + When('I authenticate with my security key', async ({ page }) => { // The second-factor-login page auto-starts the WebAuthn ceremony ~200ms after // load, so by the time this step runs the virtual authenticator may have already diff --git a/Tests/E2E/steps/general-login.steps.ts b/Tests/E2E/steps/general-login.steps.ts index 658f325..94b44f3 100644 --- a/Tests/E2E/steps/general-login.steps.ts +++ b/Tests/E2E/steps/general-login.steps.ts @@ -49,6 +49,11 @@ Then('I should land on {string}', async ({ page }, path: string) => { await expect(page).toHaveURL(new RegExp(escaped)); }); +Then('I should see the login page', async ({ page }) => { + await expect(page).toHaveURL(/neos\/login/); + await expect(page.locator('input[type="password"]')).toBeVisible(); +}); + Then('I cannot access the Neos content page', async ({ page }) => { const neosContentPage = new NeosContentPage(page); await neosContentPage.goto();