diff --git a/dictionaries/privacyidea.definition.json b/dictionaries/privacyidea.definition.json index 7ec13d06184b24be1a4e5fddbc2c782dd15b14ba..a762ff495955526ff0b62ca16d84667f61e337be 100644 --- a/dictionaries/privacyidea.definition.json +++ b/dictionaries/privacyidea.definition.json @@ -17,8 +17,17 @@ "otp": { "en": "OTP" }, + "webauthn": { + "en": "WebAuthn" + }, + "push": { + "en": "Push" + }, + "u2f": { + "en": "U2F" + }, "login_title_challenge": { - "en": "Please enter your One Time Password" + "en": "Multi-factor authentication" }, "otp_extra_text": { "en": "Please enter your Password and One Time Password" @@ -26,7 +35,7 @@ "login_text_challenge": { "en": "You entered the correct PIN or password. But now you also need to use your second factor." }, - "scanTokenQR": { + "scan_token_qr": { "en": "Scan this QR code with an app like Google Authenticator or privacyIDEA Authenticator and enter the displayed code below." }, "chal_resp_message": { @@ -35,37 +44,55 @@ "enroll_u2f": { "en": "Please use your u2f device to finish the enrollment process" }, - "u2fNotWorking": { - "en": "U2F is currently not working." - }, "alternate_login_options": { "en": "Alternate login options:" }, - "alert_webauthn_insecure_context": { + "webauthn_insecure_context": { "en": "Unable to proceed with Web Authn because the context is insecure!" }, - "alert_webauthn_unavailable": { + "webauthn_library_unavailable": { "en": "Could not load WebAuthn, your device probably does not support it. Please try again or use another method." }, - "alert_webAuthnSignRequest_error": { - "en": "Error while signing WebAuthnSignRequest:" + "webauthn_AbortError": { + "en": "You have canceled the authentication." + }, + "webauthn_InvalidStateError": { + "en": "You have used an unregistered WebAuthn device. Please use a different one." + }, + "webauthn_NotAllowedError": { + "en": "WebAuthn is disabled for this site." + }, + "webauthn_NotSupportedError": { + "en": "Your device does not support WebAuthn features that we need." + }, + "webauthn_TypeError": { + "en": "There is a problem with the WebAuthn parameters that we use." + }, + "webauthn_other_error": { + "en": "There was a problem communicating with your device." + }, + "webauthn_in_progress": { + "en": "Trying to communicate with your WebAuthn device. Plug it in and press the button or confirm the system dialog." + }, + "webauthn_success": { + "en": "We heard back from your WebAuthn device. You have been authenticated." }, - "alert_u2f_insecure_context": { + "u2f_insecure_context": { "en": "Unable to proceed with U2F because the context is insecure!" }, - "alert_u2f_unavailable": { + "u2f_unavailable": { "en": "Could not load U2F, your device probably does not support it. Please try again or use another method." }, - "alert_U2FSignRequest_error": { + "u2f_sign_request_error": { "en": "Error while signing U2FSignRequest:" }, - "tryAgain": { + "try_again": { "en": "Try Again" }, "error_message": { "en": "Verification was not successful. Please try again." }, "error": { - "en": "Error " + "en": "Error" } } diff --git a/dictionaries/privacyidea.translation.json b/dictionaries/privacyidea.translation.json index b74df6542a22031bf00514cb8b4b60aad3b55e93..a1d528c67c09c136205230bd81695751bb44b563 100644 --- a/dictionaries/privacyidea.translation.json +++ b/dictionaries/privacyidea.translation.json @@ -24,10 +24,25 @@ "de": "Einmalpasswort", "nl": "OTP" }, + "webauthn": { + "cs": "WebAuthn", + "de": "WebAuthn", + "nl": "WebAuthn" + }, + "push": { + "cs": "push notifikace", + "de": "Push", + "nl": "Push" + }, + "u2f": { + "cs": "U2F", + "de": "U2F", + "nl": "U2F" + }, "login_title_challenge": { - "cs": "VloĹľte jednorázovĂ˝ kĂłd", - "de": "Bitte geben Sie Ihr Einmalpasswort ein", - "nl": "Vul je eenmalige wachtwoord in." + "cs": "VĂcefázovĂ© ověřenĂ", + "de": "Multi-Faktor-Authentisierung", + "nl": "Multifactorauthenticatie" }, "otp_extra_text": { "cs": "VloĹľte svĂ© heslo a jednorázovĂ˝ kĂłd", @@ -39,7 +54,7 @@ "de": "Sie haben die korrekte PIN, bzw. das korrekte Passwort eingegeben. Aber nun mĂĽssen Sie noch Ihren zweiten Faktor nutzen.", "nl": "U heeft het de juiste PIN of wachtwoord ingegegeven. Geef nu ook uw tweede factor in." }, - "scanTokenQR": { + "scan_token_qr": { "cs": "Naskenujte tento QR kĂłd aplikacĂ pro TOTP (napĹ™. Google Authenticator nebo privacyIDEA Authenticator) a zobrazenĂ˝ kĂłd vloĹľte nĂĹľe.", "de": "Scannen Sie den QR-Code mit einer App, wie dem Google Authenticator oder privacyIDEA Authenticator und geben Sie den angezeigten Code unten ein.", "nl": "Scan deze QR-code met een app zoals Google Authenticator of privacyIDEA Authenticator en geef de getoonde code in." @@ -54,34 +69,50 @@ "de": "Bitte nutzen Sie Ihr U2F-Gerät, um das Ausrollen fertigzustellen.", "nl": "Gebruik uw u2f-toestel om de aanmelding verder te zetten." }, - "u2fNotWorking": { - "cs": "U2F nenĂ k dispozici.", - "de": "U2F funktioniert momentan nicht.", - "nl": "U2F werkt momenteel niet." - }, "alternate_login_options": { "cs": "Dalšà moĹľnosti pĹ™ihlášenĂ:", "de": "Alternative Login Optionen:" }, - "alert_webauthn_insecure_context": { + "webauthn_insecure_context": { "cs": "NenĂ moĹľnĂ© pouĹľĂt autentizaci WebAuthn, protoĹľe neodpovĂdá bezpeÄŤnostnĂ kontext." }, - "alert_webauthn_unavailable": { + "webauthn_library_unavailable": { "cs": "NaÄŤtenĂ WebAuthn se nezdaĹ™ilo, nejspĂš ho Vaše zaĹ™ĂzenĂ nepodporuje. Zkuste to znovu nebo pouĹľijte jinou metodu." }, - "alert_webAuthnSignRequest_error": { - "cs": "Chyba pĹ™i podepisovánĂ WebAuthnSignRequest:" + "webauthn_AbortError": { + "cs": "OvěřenĂ WebAuthn bylo zrušeno." + }, + "webauthn_NotAllowedError": { + "cs": "WebAuthn nenĂ pro tuto stránku povoleno." + }, + "webauthn_InvalidStateError": { + "cs": "PouĹľitĂ© WebAuthn zaĹ™ĂzenĂ nemáte zaregistrovanĂ©, prosĂm pouĹľijte jinĂ©." + }, + "webauthn_NotSupportedError": { + "cs": "Vaše zaĹ™ĂzenĂ nepodporuje moĹľnosti WebAuthn, kterĂ© potĹ™ebujeme." + }, + "webauthn_TypeError": { + "cs": "WebAuthn parametry jsou nesprávnĂ©." + }, + "webauthn_other_error": { + "cs": "Komunikace s VašĂm WebAuthn zaĹ™ĂzenĂm se nezdaĹ™ila." + }, + "webauthn_in_progress": { + "cs": "ProbĂhá komunikace s VašĂm WebAuthn zaĹ™ĂzenĂm. PĹ™ipojte fyzickĂ© zaĹ™ĂzenĂ a zmáčknÄ›te tlaÄŤĂtko nebo potvrÄŹte systĂ©movĂ˝ dialog." + }, + "webauthn_success": { + "cs": "ObdrĹľeli jsme odpověď z Vašeho WebAuthn zaĹ™ĂzenĂ. Autentizace byla Ăşspěšná." }, - "alert_u2f_insecure_context": { + "u2f_insecure_context": { "cs": "NenĂ moĹľnĂ© pouĹľĂt autentizaci U2F, protoĹľe neodpovĂdá bezpeÄŤnostnĂ kontext." }, - "alert_u2f_unavailable": { + "u2f_unavailable": { "cs": "NaÄŤtenĂ U2F se nezdaĹ™ilo, nejspĂš ho Vaše zaĹ™ĂzenĂ nepodporuje. Zkuste to znovu nebo pouĹľijte jinou metodu." }, - "alert_U2FSignRequest_error": { + "u2f_sign_request_error": { "cs": "Chyba pĹ™i podepisovánĂ U2FSignRequest:" }, - "tryAgain": { + "try_again": { "cs": "Zkusit znovu", "de": "Nochmal versuchen", "nl": "Probeer opnieuw" @@ -90,6 +121,6 @@ "cs": "OvěřenĂ nebylo ĂşspěšnĂ©. Zkuste to znovu nebo pouĹľijte jinou metodu." }, "error": { - "cs": "Chyba " + "cs": "Chyba" } } diff --git a/docs/privacyidea.md b/docs/privacyidea.md index c6ccc2cd5fb156b2d5027f60a06be288e7ffd1ca..0de4049fcaf36b27fcd9d0e3a052bee64c469656 100644 --- a/docs/privacyidea.md +++ b/docs/privacyidea.md @@ -4,10 +4,8 @@ This module is an authentication module for simpleSAMLphp to use with the privac You can use this plugin in two different ways: -<ol> - <li> AuthSource: This module does the complete authentication process against privacyIDEA - <li> AuthProc: This module does just one step of the authentication, the second factor against privacyIDEA -</ol> +1. AuthSource: This module does the complete authentication process against privacyIDEA +2. AuthProc: This module does just one step of the authentication, the second factor against privacyIDEA NOTE: This plugin is enabled by default when installed, you do not need to enable it manually. @@ -31,14 +29,14 @@ You need to add the authentication source 'privacyidea' to * The value have to be a string. * Optional. */ - 'sslVerifyHost' => 'false', + 'sslVerifyHost' => false, /* * Check if the certificate is valid, signed by a trusted CA. * The value have to be a string. * Optional. */ - 'sslVerifyPeer' => 'false', + 'sslVerifyPeer' => false, /* * The realm where the user is located in. @@ -57,21 +55,21 @@ You need to add the authentication source 'privacyidea' to 'servicePass' => 'service', /** - * Set doTriggerChallenge to 'true' to trigger challenges prior to the login + * Set doTriggerChallenge to true to trigger challenges prior to the login * using the configured service account. * This setting takes precedence over 'doSendPassword'. * The value have to be a string. */ - 'doTriggerChallenge' => 'true', + 'doTriggerChallenge' => true, /** - * Set doSendPassword to 'true' to send a request to validate/check with the username + * Set doSendPassword to true to send a request to validate/check with the username * and an empty pass prior to the login. * This can be used to trigger challenges depending on the configuration in privacyIDEA * and requires no service account. If 'doTriggerChallenge' is enabled, this setting has no effect. * The value have to be a string. */ - 'doSendPassword' => 'true', + 'doSendPassword' => true, /** * Set custom hints for the OTP and password fields @@ -80,11 +78,11 @@ You need to add the authentication source 'privacyidea' to 'passFieldHint' => 'Password', /** - * Set SSO to 'true' if you want to use single sign on. + * Set SSO to true if you want to use single sign on. * All information required for SSO will be saved in the session. * After logging out, the SSO data will be removed from the session. */ - 'SSO' => 'false', + 'SSO' => false, /** * Set preferredTokenType to your favourite token type. @@ -128,6 +126,14 @@ You need to add the authentication source 'privacyidea' to 'serial' => 'otpSerial', 'otplen' => 'otpLength' ], + + /* + * Override (string) or reformat (callable) messages from privacyIDEA. + * When using callable, HTML is not escaped. + * Optional. + */ + //'messageOverride' => 'Use any of your tokens.', + //'messageOverride' => function($messages){return htmlspecialchars(current(explode(',', $messages)));}, ], ``` @@ -180,13 +186,13 @@ If you want to use privacyIDEA as an auth process filter, add the configuration * Check if the hostname matches the name in the certificate. * The value have to be a string. */ - 'sslVerifyHost' => 'true', + 'sslVerifyHost' => true, /** * Check if the certificate is valid, signed by a trusted CA * The value have to be a string. */ - 'sslVerifyPeer' => 'true', + 'sslVerifyPeer' => true, /** * Here you need to enter the username of your service account @@ -202,10 +208,10 @@ If you want to use privacyIDEA as an auth process filter, add the configuration * You can add this option, if you want to enroll tokens for users, who do not have one yet. * The value have to be a string. */ - 'doEnrollToken' => 'true', + 'doEnrollToken' => true, /** - * You can select a time based otp (totp), an event based otp (hotp) or an u2f (u2f) + * You can select the token type for enrollment: a time based otp (totp), an event based otp (hotp) or an u2f (u2f) */ 'tokenType' => 'totp', @@ -213,14 +219,14 @@ If you want to use privacyIDEA as an auth process filter, add the configuration * You can enable or disable trigger challenge. * The value have to be a string. */ - 'doTriggerChallenge' => 'true', + 'doTriggerChallenge' => true, /** - * Set this to 'true' if you want to use single sign on. + * Set this to true if you want to use single sign on. * All information required for SSO will be saved in the session. * After logging out, the SSO data will be removed from the session. */ - 'SSO' => 'false', + 'SSO' => false, /** * Set preferredTokenType to your favourite token type. @@ -252,7 +258,7 @@ If you want to use privacyIDEA as an auth process filter, add the configuration * privacyIDEA. If passOnNoToken is activated and the user does not have a token, he will be passed by privacyIDEA. * NOTE: Do not use it with privacyidea:tokenEnrollment. */ - 'tryFirstAuthentication' => 'true', + 'tryFirstAuthentication' => true, /** * You can decide, which password should be used for tryFirstAuthentication @@ -273,7 +279,7 @@ If you want to use privacyIDEA as an auth process filter, add the configuration * the entityID and/or SAML attributes, you may enable this filter. * Value have to be string. */ - 'checkEntityID' => 'true', + 'checkEntityID' => true, /** * Depending on excludeEntityIDs and includeAttributes the filter will set the state variable @@ -317,6 +323,14 @@ If you want to use privacyIDEA as an auth process filter, add the configuration * Optional, default to true. */ 'showLogout' => false, + + /** + * Override (string) or reformat (callable) messages from privacyIDEA. + * When using callable, HTML is not escaped. + * Optional. + */ + //'messageOverride' => 'Use any of your tokens.', + //'messageOverride' => function($messages){return htmlspecialchars(current(explode(',', $messages)));}, ], ] ``` diff --git a/lib/Auth/Process/PrivacyideaAuthProc.php b/lib/Auth/Process/PrivacyideaAuthProc.php index 94ed5f52c9ec26390dc86343500ba24c9b5788e3..861e6906144ff6d851ca6a61de70b4023b964ec1 100644 --- a/lib/Auth/Process/PrivacyideaAuthProc.php +++ b/lib/Auth/Process/PrivacyideaAuthProc.php @@ -56,6 +56,7 @@ class PrivacyideaAuthProc extends ProcessingFilter $state['privacyidea:privacyidea'] = $this->authProcConfig; $state['privacyidea:privacyidea']['authenticationMethod'] = 'authprocess'; $state['privacyidea:privacyidea:ui']['showLogout'] = $this->authProcConfig['showLogout'] ?? true; + $state['privacyidea:privacyidea:ui']['messageOverride'] = $this->authProcConfig['messageOverride'] ?? null; // If set in config, allow to check the IP of the client and to control the 2FA depending on the client IP. // It can be used to configure that a user does not need to provide a second factor when logging in from the local network. @@ -67,9 +68,9 @@ class PrivacyideaAuthProc extends ProcessingFilter } } - // If set to "true" in config, selectively disable the privacyIDEA authentication using the entityID and/or SAML attributes. + // If set to true in config, selectively disable the privacyIDEA authentication using the entityID and/or SAML attributes. // The skipping will be done in self::isPrivacyIDEADisabled - if (!empty($this->authProcConfig['checkEntityID']) && 'true' === $this->authProcConfig['checkEntityID']) { + if (!empty($this->authProcConfig['checkEntityID']) && true === $this->authProcConfig['checkEntityID']) { $stateId = State::saveState($state, 'privacyidea:privacyidea'); $stateId = $this->checkEntityID($this->authProcConfig, $stateId); $state = State::loadState($stateId, 'privacyidea:privacyidea'); @@ -83,7 +84,7 @@ class PrivacyideaAuthProc extends ProcessingFilter // SSO check if authentication should be skipped if (array_key_exists('SSO', $this->authProcConfig) - && 'true' === $this->authProcConfig['SSO']) { + && true === $this->authProcConfig['SSO']) { if (Utils::checkForValidSSO($state)) { Logger::debug('privacyIDEA: SSO data valid - logging in..'); ProcessingChain::resumeProcessing($state); @@ -96,12 +97,12 @@ class PrivacyideaAuthProc extends ProcessingFilter $stateId = State::saveState($state, 'privacyidea:privacyidea'); // Check if it should be controlled that user has no tokens and a new token should be enrolled. - if (!empty($this->authProcConfig['doEnrollToken']) && 'true' === $this->authProcConfig['doEnrollToken']) { + if (!empty($this->authProcConfig['doEnrollToken']) && true === $this->authProcConfig['doEnrollToken']) { $stateId = $this->enrollToken($stateId, $username); } // Check if triggerChallenge or a call with a static pass to /validate/check should be done - if (!empty($this->authProcConfig['doTriggerChallenge']) && 'true' === $this->authProcConfig['doTriggerChallenge']) { + if (!empty($this->authProcConfig['doTriggerChallenge']) && true === $this->authProcConfig['doTriggerChallenge']) { // Call /validate/triggerchallenge with the service account from the configuration to trigger all token of the user $stateId = State::saveState($state, 'privacyidea:privacyidea'); if (!$this->pi->serviceAccountAvailable()) { @@ -120,7 +121,7 @@ class PrivacyideaAuthProc extends ProcessingFilter $stateId = Utils::processPIResponse($stateId, $response); } } - } elseif (!empty($this->authProcConfig['tryFirstAuthentication']) && 'true' === $this->authProcConfig['tryFirstAuthentication']) { + } elseif (!empty($this->authProcConfig['tryFirstAuthentication']) && true === $this->authProcConfig['tryFirstAuthentication']) { // Call /validate/check with a static pass from the configuration // This could already end the authentication with the "passOnNoToken" policy, or it could trigger challenges $response = Utils::authenticatePI($state, [ diff --git a/lib/Auth/Source/PrivacyideaAuthSource.php b/lib/Auth/Source/PrivacyideaAuthSource.php index 4dc73af18eddfe559ba70726a02f14613885e59c..70198eeffb9ba4d9ad2a34da940f2c1464c4e035 100644 --- a/lib/Auth/Source/PrivacyideaAuthSource.php +++ b/lib/Auth/Source/PrivacyideaAuthSource.php @@ -103,6 +103,7 @@ class PrivacyideaAuthSource extends UserPassBase $state['privacyidea:privacyidea:ui']['otpFieldHint'] = $this->authSourceConfig['otpFieldHint'] ?? ''; $state['privacyidea:privacyidea:ui']['passFieldHint'] = $this->authSourceConfig['passFieldHint'] ?? ''; $state['privacyidea:privacyidea:ui']['loadCounter'] = '1'; + $state['privacyidea:privacyidea:ui']['messageOverride'] = $this->authSourceConfig['messageOverride'] ?? null; $stateId = State::saveState($state, 'privacyidea:privacyidea'); @@ -144,7 +145,7 @@ class PrivacyideaAuthSource extends UserPassBase $stateId = State::saveState($state, 'privacyidea:privacyidea'); if (array_key_exists('doTriggerChallenge', $source->authSourceConfig) - && 'true' === $source->authSourceConfig['doTriggerChallenge']) { + && true === $source->authSourceConfig['doTriggerChallenge']) { if (!empty($username) && $source->pi->serviceAccountAvailable()) { try { $response = $source->pi->triggerChallenge($username); @@ -153,7 +154,7 @@ class PrivacyideaAuthSource extends UserPassBase } } } elseif (array_key_exists('doSendPassword', $source->authSourceConfig) - && 'true' === $source->authSourceConfig['doSendPassword']) { + && true === $source->authSourceConfig['doSendPassword']) { if (!empty($username)) { try { $response = $source->pi->validateCheck($username, $password); diff --git a/lib/Auth/Utils.php b/lib/Auth/Utils.php index 99ff885ac485db6b4bb40d2329bb74404cbf7af1..e4a294f45d4878cbc6688e2c677c6bbb9c9dec8c 100644 --- a/lib/Auth/Utils.php +++ b/lib/Auth/Utils.php @@ -32,7 +32,7 @@ class Utils $state['privacyidea:privacyidea:ui']['mode'] = $formParams['mode']; // If the mode was changed, do not make any requests - if ('true' === $formParams['modeChanged']) { + if (true === $formParams['modeChanged']) { $state['privacyidea:privacyidea:ui']['loadCounter'] = 1; return null; @@ -202,11 +202,11 @@ class Utils $pi->logger = new PILogger(); if (array_key_exists('sslVerifyHost', $config) && !empty($config['sslVerifyHost'])) { - $pi->sslVerifyHost = 'false' !== $config['sslVerifyHost']; + $pi->sslVerifyHost = false !== $config['sslVerifyHost']; } if (array_key_exists('sslVerifyPeer', $config) && !empty($config['sslVerifyPeer'])) { - $pi->sslVerifyPeer = 'false' !== $config['sslVerifyPeer']; + $pi->sslVerifyPeer = false !== $config['sslVerifyPeer']; } if (array_key_exists('serviceAccount', $config) && !empty($config['serviceAccount'])) { diff --git a/templates/LoginForm.php b/templates/LoginForm.php index 0ba5d5bc233071f2c60d691b97c8f7f55cc3d7a1..93528e049d521a69a0bf0a70ed4f86d082bb6b46 100644 --- a/templates/LoginForm.php +++ b/templates/LoginForm.php @@ -2,6 +2,17 @@ use SimpleSAML\Module; +$this->data['u2fAvailable'] = !empty($this->data['u2fSignRequest']); +$this->data['webauthnAvailable'] = !empty($this->data['webAuthnSignRequest']); +$this->data['mode'] = ($this->data['mode'] ?? null) ?: 'otp'; +$this->data['noAlternatives'] = true; +foreach (['otp', 'push', 'u2f', 'webauthn'] as $mode) { + if ($mode !== $this->data['mode'] && $this->data[$mode . 'Available']) { + $this->data['noAlternatives'] = false; + break; + } +} + // Set default scenario if isn't set if (!empty($this->data['authProcFilterScenario'])) { if (empty($this->data['username'])) { @@ -43,16 +54,22 @@ if (null !== $this->data['errorCode']) { ?> <div class="error-dialog"> - <img src="/<?php echo htmlspecialchars( + <img src="/<?php + echo htmlspecialchars( $this->data['baseurlpath'], ENT_QUOTES ); ?>resources/icons/experience/gtk-dialog-error.48x48.png" class="float-l erroricon" alt="gtk-dialog-error"/> <h2><?php echo $this->t('{login:error_header}'); ?></h2> <p> - <strong><?php echo htmlspecialchars( - $this->t('{privacyidea:privacyidea:error}') . $this->data['errorCode'] . ': ' . $this->data['errorMessage'] - ); ?></strong> + <strong> + <?php + echo htmlspecialchars( + sprintf('%s%s: %s', $this->t( + '{privacyidea:privacyidea:error}' + ), $this->data['errorCode'] ? (' ' . $this->data['errorCode']) : '', $this->data['errorMessage']) + ); ?> + </strong> </p> </div> @@ -62,15 +79,11 @@ if (null !== $this->data['errorCode']) { <div class="container"> <div class="login"> - <div class="loginlogo"></div> - <?php if ($this->data['authProcFilterScenario']) { echo '<h2>' . htmlspecialchars($this->t('{privacyidea:privacyidea:login_title_challenge}')) . '</h2>'; - } else { - if ($this->data['step'] < 2) { - echo '<h2>' . htmlspecialchars($this->t('{privacyidea:privacyidea:login_title}')) . '</h2>'; - } + } elseif ($this->data['step'] < 2) { + echo '<h2>' . htmlspecialchars($this->t('{privacyidea:privacyidea:login_title}')) . '</h2>'; } ?> @@ -89,16 +102,17 @@ if (null !== $this->data['errorCode']) { <input type="hidden" id="username" name="username" value="<?php echo htmlspecialchars($this->data['username'] ?? '', ENT_QUOTES); ?>"/> <?php - } else { + } elseif ($this->data['step'] <= 1) { ?> - <label for="username" class="sr-only"> - <?php echo $this->t('{login:username}'); ?> - </label> - <input type="text" id="username" tabindex="1" name="username" autofocus - value="<?php echo htmlspecialchars($this->data['username'], ENT_QUOTES); ?>" - placeholder="<?php echo htmlspecialchars($this->t('{login:username}'), ENT_QUOTES); ?>" - /> - <br> + <p> + <label for="username" class="sr-only"> + <?php echo $this->t('{login:username}'); ?> + </label> + <input type="text" id="username" tabindex="1" name="username" autofocus + value="<?php echo htmlspecialchars($this->data['username'], ENT_QUOTES); ?>" + placeholder="<?php echo htmlspecialchars($this->t('{login:username}'), ENT_QUOTES); ?>" + /> + </p> <?php } @@ -129,28 +143,46 @@ if (null !== $this->data['errorCode']) { } ?> <!-- Pass and OTP fields --> + <?php if (!$this->data['authProcFilterScenario']) { ?> <label for="password" class="sr-only"> <?php echo $this->t('{privacyidea:privacyidea:password}'); ?> </label> <input id="password" name="password" tabindex="1" type="password" value="" class="text" placeholder="<?php echo htmlspecialchars($passHint, ENT_QUOTES); ?>"/> + <?php } ?> + + <?php if ($this->data['step'] > 1) { ?> + <p id="message" role="alert"><?php + $messageOverride = $this->data['messageOverride'] ?? null; + if (null === $messageOverride || is_string($messageOverride)) { + echo htmlspecialchars( + $messageOverride ?? $this->data['message'] ?? '', + ENT_QUOTES + ); + } elseif (is_callable($messageOverride)) { + echo call_user_func($messageOverride, $this->data['message'] ?? ''); + } + ?></p> + <?php } ?> - <strong id="message"><?php echo htmlspecialchars($this->data['message'] ?? '', ENT_QUOTES); ?></strong> - <br><br> - <label for="otp" class="sr-only"> - <?php echo $this->t('{privacyidea:privacyidea:otp}'); ?> - </label> - <input id="otp" name="otp" type="password" - placeholder="<?php echo htmlspecialchars($otpHint, ENT_QUOTES); ?>"> - <br><br> - <input id="submitButton" tabindex="1" class="rc-button rc-button-submit" type="submit" - name="Submit" - value="<?php echo htmlspecialchars($this->t('{login:login_button}'), ENT_QUOTES); ?>"/> - <br><br> + <?php if ($this->data['step'] > 1) { ?> + <p> + <label for="otp" class="sr-only"> + <?php echo $this->t('{privacyidea:privacyidea:otp}'); ?> + </label> + <input id="otp" name="otp" type="password" placeholder="<?php echo htmlspecialchars($otpHint, ENT_QUOTES); ?>"> + </p> + <?php } ?> + + <p> + <button id="submitButton" tabindex="1" class="rc-button rc-button-submit" type="submit" name="Submit" value="1"> + <?php echo htmlspecialchars($this->t('{login:login_button}'), ENT_QUOTES); ?> + </button> + </p> <!-- Undefined index is suppressed and the default is used for these values --> - <input id="mode" type="hidden" name="mode" - value="<?php echo htmlspecialchars(($this->data['mode'] ?? null) ?: 'otp', ENT_QUOTES); ?>"/> + <input id="mode" type="hidden" name="mode" value="otp" + data-preferred="<?php echo htmlspecialchars($this->data['mode'], ENT_QUOTES); ?>"/> <input id="pushAvailable" type="hidden" name="pushAvailable" value="<?php echo ($this->data['pushAvailable'] ?? false) ? 'true' : ''; ?>"/> @@ -181,7 +213,7 @@ if (null !== $this->data['errorCode']) { <?php // If enrollToken load QR Code if (isset($this->data['tokenQR'])) { - echo htmlspecialchars($this->t('{privacyidea:privacyidea:scanTokenQR}')); ?> + echo htmlspecialchars($this->t('{privacyidea:privacyidea:scan_token_qr}')); ?> <div class="tokenQR"> <?php echo '<img src="' . $this->data['tokenQR'] . '" />'; ?> </div> @@ -228,20 +260,37 @@ if (null !== $this->data['errorCode']) { </div> <!-- slide-out--> </div> <!-- form-panel --> - <div id="AlternateLoginOptions" class="groupMargin"> + <?php if (!$this->data['noAlternatives'] && $this->data['step'] > 1) { ?> + <div id="AlternateLoginOptions" class="groupMargin hidden js-show"> <h3><?php echo $this->t('{privacyidea:privacyidea:alternate_login_options}'); ?></h3> <!-- Alternate Login Options--> - <input id="useWebAuthnButton" name="useWebAuthnButton" type="button" value="WebAuthn"/> - <input id="usePushButton" name="usePushButton" type="button" value="Push"/> - <input id="useOTPButton" name="useOTPButton" type="button" value="OTP"/> - <input id="useU2FButton" name="useU2FButton" type="button" value="U2F"/> + <?php if (($this->data['webauthnAvailable'] ?? false) && 'webauthn' !== $this->data['mode']) { ?> + <button id="useWebAuthnButton" name="useWebAuthnButton" type="button"> + <span><?php echo $this->t('{privacyidea:privacyidea:webauthn}'); ?></span> + </button> + <?php } ?> + <?php if (($this->data['pushAvailable'] ?? false) && 'push' !== $this->data['mode']) { ?> + <button id="usePushButton" name="usePushButton" type="button"> + <span><?php echo $this->t('{privacyidea:privacyidea:push}'); ?></span> + </button> + <?php } ?> + <?php if (($this->data['otpAvailable'] ?? true) && 'otp' !== $this->data['mode']) { ?> + <button id="useOTPButton" name="useOTPButton" type="button"> + <span><?php echo $this->t('{privacyidea:privacyidea:otp}'); ?></span> + </button> + <?php } ?> + <?php if (($this->data['u2fAvailable'] ?? false) && 'u2f' !== $this->data['mode']) { ?> + <button id="useU2FButton" name="useU2FButton" type="button"> + <span><?php echo $this->t('{privacyidea:privacyidea:u2f}'); ?></span> + </button> + <?php } ?> </div> - <br> + <?php } ?> </form> <?php // Logout - if ($this->data['showLogout'] ?? true && isset($this->data['LogoutURL'])) { ?> + if (($this->data['showLogout'] ?? true) && isset($this->data['LogoutURL'])) { ?> <p> <a href="<?php echo htmlspecialchars($this->data['LogoutURL']); ?>"><?php echo $this->t('{status:logout}'); ?></a> </p> @@ -268,17 +317,24 @@ if (!empty($this->data['links'])) { </script> <meta id="privacyidea-step" name="privacyidea-step" content="<?php echo $this->data['step']; ?>"> - <meta id="privacyidea-hide-alternate" name="privacyidea-hide-alternate" content="<?php echo ( - !$this->data['pushAvailable'] - && (!isset($this->data['u2fSignRequest']) || ($this->data['u2fSignRequest']) === '') - && (!isset($this->data['webAuthnSignRequest']) || ($this->data['webAuthnSignRequest']) === '') - ) ? 'true' : ''; ?>"> <meta id="privacyidea-translations" name="privacyidea-translations" content="<?php $translations = []; $translation_keys = [ - 'alert_webauthn_insecure_context', 'alert_webauthn_unavailable', 'alert_webAuthnSignRequest_error', - 'alert_u2f_insecure_context', 'alert_u2f_unavailable', 'alert_U2FSignRequest_error', + 'webauthn_insecure_context', + 'webauthn_library_unavailable', + 'webauthn_AbortError', + 'webauthn_InvalidStateError', + 'webauthn_NotAllowedError', + 'webauthn_NotSupportedError', + 'webauthn_TypeError', + 'webauthn_other_error', + 'webauthn_in_progress', + 'webauthn_success', + 'u2f_insecure_context', + 'u2f_unavailable', + 'u2f_sign_request_error', + 'try_again', ]; foreach ($translation_keys as $translation_key) { $translations[$translation_key] = $this->t(sprintf('{privacyidea:privacyidea:%s}', $translation_key)); diff --git a/www/js/loginform.js b/www/js/loginform.js index 4f8f1362dbc2c2b7dc87d2b3bc3e18cacabe17f3..3ac9a0839bb477d457d56baca320dcf8df2e04e0 100644 --- a/www/js/loginform.js +++ b/www/js/loginform.js @@ -24,119 +24,111 @@ function set(id, value) { } } -function disable(id) { +function hide(id) { const element = getElement(id); if (element != null) { element.classList.add("hidden"); } } -function enable(id) { +function show(id) { const element = getElement(id); if (element != null) { element.classList.remove("hidden"); } } -function changeMode(newMode) { - set("mode", newMode); - set("modeChanged", "true"); - document.forms["piLoginForm"].submit(); -} - -function t(key) { - return JSON.parse(getContent("privacyidea-translations"))[key]; -} - -const step = getContent("privacyidea-step"); - -if (step > "1") { - disable("username"); - disable("password"); -} else { - disable("otp"); - disable("message"); - disable("AlternateLoginOptions"); -} - -// Set alternate token button visibility -if (value("webAuthnSignRequest") === "") { - disable("useWebAuthnButton"); -} - -if (value("u2fSignRequest") === "") { - disable("useU2FButton"); +function disable(id) { + const element = getElement(id); + if (element != null) { + element.disabled = true; + element.classList.add("disabled"); + } } -if (!booleanValue("pushAvailable")) { - disable("usePushButton"); +function enable(id) { + const element = getElement(id); + if (element != null) { + element.disabled = false; + element.classList.remove("disabled"); + } } -if (!booleanValue("otpAvailable")) { - disable("useOTPButton"); +function changeMode(newMode) { + set("mode", newMode); + set("modeChanged", "true"); + document.forms["piLoginForm"].submit(); } -if ( - !booleanValue("pushAvailable") && - value("webAuthnSignRequest") === "" && - value("u2fSignRequest") === "" -) { - disable("alternateTokenDiv"); +function fallbackToOTP() { + if (value("mode") !== "otp") { + setTimeout(() => { + changeMode("otp"); + }, 3000); + } } -if (value("mode") === "otp") { - disable("useOTPButton"); +function setMessage(newMessage) { + getElement("message").innerText = newMessage; } -if (value("mode") === "webauthn") { - disable("otp"); - disable("submitButton"); - doWebAuthn(); +function t(key) { + return JSON.parse(getContent("privacyidea-translations"))[key]; } -if (value("mode") === "u2f") { - disable("otp"); - disable("submitButton"); - doU2F(); +function getWebAuthnErrorMessage(err) { + switch (err.name) { + case "AbortError": + case "InvalidStateError": + case "NotAllowedError": + case "NotSupportedError": + case "TypeError": + return t("webauthn_" + err.name); + default: + return t("webauthn_other_error") + " (" + err.name + ")"; + } } -if (value("mode") === "push") { - const pollingIntervals = [4, 3, 2, 1]; - - disable("otp"); - disable("usePushButton"); - disable("submitButton"); - - if (value("loadCounter") > pollingIntervals.length - 1) { - refreshTime = pollingIntervals[pollingIntervals.length - 1]; +function webAuthnError(err) { + const errorMessage = getWebAuthnErrorMessage(err); + console.log("Error while signing WebAuthnSignRequest: " + err); + enable("useWebAuthnButton"); + setMessage(errorMessage); + if (getElement("retryWebAuthnButton")) { + show("retryWebAuthnButton"); } else { - refreshTime = pollingIntervals[Number(value("loadCounter") - 1)]; + const retryWebAuthnButton = getElement("useWebAuthnButton").cloneNode(); + retryWebAuthnButton.addEventListener("click", doWebAuthn); + retryWebAuthnButton.id = "retryWebAuthnButton"; + retryWebAuthnButton.innerHTML = "<span>" + t("try_again") + "</span>"; + getElement("message").innerHTML += " "; + getElement("message").appendChild(retryWebAuthnButton); } - - refreshTime *= 1000; - setTimeout(() => { - document.forms["piLoginForm"].submit(); - }, refreshTime); } function doWebAuthn() { + disable("useWebAuthnButton"); + hide("retryWebAuthnButton"); + setMessage(t("webauthn_in_progress")); // If mode is push, we have to change it, otherwise the site will refresh while doing webauthn if (value("mode") === "push") { changeMode("webauthn"); } if (!window.isSecureContext) { - alert(t("alert_webauthn_insecure_context")); + enable("useWebAuthnButton"); + setMessage(t("webauthn_insecure_context")); console.log( "Insecure context detected: Aborting Web Authn authentication!" ); - changeMode("otp"); + fallbackToOTP(); return; } if (!window.pi_webauthn) { - alert(t("alert_webauthn_unavailable")); - changeMode("otp"); + enable("useWebAuthnButton"); + setMessage(t("webauthn_library_unavailable")); + fallbackToOTP(); return; } @@ -156,15 +148,17 @@ function doWebAuthn() { const requestjson = JSON.parse(requestStr); const webAuthnSignResponse = window.pi_webauthn.sign(requestjson); - webAuthnSignResponse.then((webauthnresponse) => { - const response = JSON.stringify(webauthnresponse); - set("webAuthnSignResponse", response); - set("mode", "webauthn"); - document.forms["piLoginForm"].submit(); - }); + webAuthnSignResponse + .then((webauthnresponse) => { + const response = JSON.stringify(webauthnresponse); + set("webAuthnSignResponse", response); + set("mode", "webauthn"); + setMessage(t("webauthn_success")); + document.forms["piLoginForm"].submit(); + }) + .catch(webAuthnError); } catch (err) { - console.log("Error while signing WebAuthnSignRequest: " + err); - alert(t("alert_webAuthnSignRequest_error") + " " + err); + webAuthnError(err); } } @@ -175,17 +169,17 @@ function doU2F() { } if (!window.isSecureContext) { - alert(t("alert_u2f_insecure_context")); + setMessage(t("u2f_insecure_context")); console.log("Insecure context detected: Aborting U2F authentication!"); - changeMode("otp"); + fallbackToOTP(); return; } const requestStr = value("u2fSignRequest"); if (requestStr === null) { - alert(t("alert_u2f_unavailable")); - changeMode("otp"); + setMessage(t("u2f_unavailable")); + fallbackToOTP(); return; } @@ -194,7 +188,7 @@ function doU2F() { sign_u2f_request(requestjson); } catch (err) { console.log("Error while signing U2FSignRequest: " + err); - alert(t("alert_U2FSignRequest_error") + " " + err); + setMessage(t("u2f_sign_request_error") + " " + err); } } @@ -221,17 +215,65 @@ function sign_u2f_request(signRequest) { }); } -if (getContent("privacyidea-hide-alternate") === "true") { - disable("AlternateLoginOptions"); +function addClickListener(id, listener) { + const el = getElement(id); + if (el !== null) { + el.addEventListener("click", listener); + } } -document.addEventListener("DOMContentLoaded", (event) => { - getElement("useWebAuthnButton").addEventListener("click", doWebAuthn); - getElement("usePushButton").addEventListener("click", function () { - changeMode("push"); - }); - getElement("useOTPButton").addEventListener("click", function () { - changeMode("otp"); +function initPrivacyIDEA() { + // set preferred mode by JavaScript so that users without it always have "otp" + const preferredMode = getElement("mode").dataset.preferred; + if (preferredMode) { + set("mode", preferredMode); + } + document.querySelectorAll(".js-show").forEach((el) => { + el.classList.remove("hidden"); }); - getElement("useU2FButton").addEventListener("click", doU2F); -}); + + const step = getContent("privacyidea-step"); + + if (step > 1) { + hide("username"); + hide("password"); + } else { + hide("otp"); + hide("message"); + } + + if (step > 1 && value("mode") !== "otp") { + hide("otp"); + hide("submitButton"); + } + + if (value("mode") === "webauthn") { + doWebAuthn(); + } + + if (value("mode") === "u2f") { + doU2F(); + } + + if (value("mode") === "push") { + const pollingIntervals = [4, 3, 2, 1]; + + if (value("loadCounter") > pollingIntervals.length - 1) { + refreshTime = pollingIntervals[pollingIntervals.length - 1]; + } else { + refreshTime = pollingIntervals[Number(value("loadCounter") - 1)]; + } + + refreshTime *= 1000; + setTimeout(() => { + document.forms["piLoginForm"].submit(); + }, refreshTime); + } + + addClickListener("useWebAuthnButton", doWebAuthn); + addClickListener("usePushButton", () => changeMode("push")); + addClickListener("useOTPButton", () => changeMode("otp")); + addClickListener("useU2FButton", doU2F); +} + +document.addEventListener("DOMContentLoaded", initPrivacyIDEA);