Skip to content
Snippets Groups Projects
Verified Commit f72b19b6 authored by Pavel Břoušek's avatar Pavel Břoušek
Browse files

feat: improve WebAuthn user experience

replace alert with changing message,
normalize dictionary entries,
remove unused dictionary entries,
replace line breaks with paragraphs

BREAKING CHANGE: renamed dictionary entries, changed HTML structure
parent 0e5db60a
No related branches found
No related tags found
1 merge request!1Improve WebAuthn user experience
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
"login_text_challenge": { "login_text_challenge": {
"en": "You entered the correct PIN or password. But now you also need to use your second factor." "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." "en": "Scan this QR code with an app like Google Authenticator or privacyIDEA Authenticator and enter the displayed code below."
}, },
"chal_resp_message": { "chal_resp_message": {
...@@ -44,31 +44,49 @@ ...@@ -44,31 +44,49 @@
"enroll_u2f": { "enroll_u2f": {
"en": "Please use your u2f device to finish the enrollment process" "en": "Please use your u2f device to finish the enrollment process"
}, },
"u2fNotWorking": {
"en": "U2F is currently not working."
},
"alternate_login_options": { "alternate_login_options": {
"en": "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!" "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." "en": "Could not load WebAuthn, your device probably does not support it. Please try again or use another method."
}, },
"alert_webAuthnSignRequest_error": { "webauthn_AbortError": {
"en": "Error while signing WebAuthnSignRequest:" "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!" "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." "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:" "en": "Error while signing U2FSignRequest:"
}, },
"tryAgain": { "try_again": {
"en": "Try Again" "en": "Try Again"
}, },
"error_message": { "error_message": {
......
...@@ -54,7 +54,7 @@ ...@@ -54,7 +54,7 @@
"de": "Sie haben die korrekte PIN, bzw. das korrekte Passwort eingegeben. Aber nun müssen Sie noch Ihren zweiten Faktor nutzen.", "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." "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.", "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.", "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." "nl": "Scan deze QR-code met een app zoals Google Authenticator of privacyIDEA Authenticator en geef de getoonde code in."
...@@ -69,34 +69,50 @@ ...@@ -69,34 +69,50 @@
"de": "Bitte nutzen Sie Ihr U2F-Gerät, um das Ausrollen fertigzustellen.", "de": "Bitte nutzen Sie Ihr U2F-Gerät, um das Ausrollen fertigzustellen.",
"nl": "Gebruik uw u2f-toestel om de aanmelding verder te zetten." "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": { "alternate_login_options": {
"cs": "Další možnosti přihlášení:", "cs": "Další možnosti přihlášení:",
"de": "Alternative Login Optionen:" "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." "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." "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": { "webauthn_AbortError": {
"cs": "Chyba při podepisování WebAuthnSignRequest:" "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." "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." "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:" "cs": "Chyba při podepisování U2FSignRequest:"
}, },
"tryAgain": { "try_again": {
"cs": "Zkusit znovu", "cs": "Zkusit znovu",
"de": "Nochmal versuchen", "de": "Nochmal versuchen",
"nl": "Probeer opnieuw" "nl": "Probeer opnieuw"
......
...@@ -93,14 +93,15 @@ if (null !== $this->data['errorCode']) { ...@@ -93,14 +93,15 @@ if (null !== $this->data['errorCode']) {
<?php <?php
} else { } else {
?> ?>
<label for="username" class="sr-only"> <p>
<?php echo $this->t('{login:username}'); ?> <label for="username" class="sr-only">
</label> <?php echo $this->t('{login:username}'); ?>
<input type="text" id="username" tabindex="1" name="username" autofocus </label>
value="<?php echo htmlspecialchars($this->data['username'], ENT_QUOTES); ?>" <input type="text" id="username" tabindex="1" name="username" autofocus
placeholder="<?php echo htmlspecialchars($this->t('{login:username}'), ENT_QUOTES); ?>" value="<?php echo htmlspecialchars($this->data['username'], ENT_QUOTES); ?>"
/> placeholder="<?php echo htmlspecialchars($this->t('{login:username}'), ENT_QUOTES); ?>"
<br> />
</p>
<?php <?php
} }
...@@ -137,7 +138,7 @@ if (null !== $this->data['errorCode']) { ...@@ -137,7 +138,7 @@ if (null !== $this->data['errorCode']) {
<input id="password" name="password" tabindex="1" type="password" value="" class="text" <input id="password" name="password" tabindex="1" type="password" value="" class="text"
placeholder="<?php echo htmlspecialchars($passHint, ENT_QUOTES); ?>"/> placeholder="<?php echo htmlspecialchars($passHint, ENT_QUOTES); ?>"/>
<strong id="message"><?php <p id="message" role="alert"><?php
$messageOverride = $this->data['messageOverride'] ?? null; $messageOverride = $this->data['messageOverride'] ?? null;
if (null === $messageOverride || is_string($messageOverride)) { if (null === $messageOverride || is_string($messageOverride)) {
echo htmlspecialchars( echo htmlspecialchars(
...@@ -147,18 +148,20 @@ if (null !== $this->data['errorCode']) { ...@@ -147,18 +148,20 @@ if (null !== $this->data['errorCode']) {
} elseif (is_callable($messageOverride)) { } elseif (is_callable($messageOverride)) {
echo call_user_func($messageOverride, $this->data['message'] ?? ''); echo call_user_func($messageOverride, $this->data['message'] ?? '');
} }
?></strong> ?></p>
<br><br>
<label for="otp" class="sr-only"> <p>
<?php echo $this->t('{privacyidea:privacyidea:otp}'); ?> <label for="otp" class="sr-only">
</label> <?php echo $this->t('{privacyidea:privacyidea:otp}'); ?>
<input id="otp" name="otp" type="password" </label>
placeholder="<?php echo htmlspecialchars($otpHint, ENT_QUOTES); ?>"> <input id="otp" name="otp" type="password" placeholder="<?php echo htmlspecialchars($otpHint, ENT_QUOTES); ?>">
<br><br> </p>
<input id="submitButton" tabindex="1" class="rc-button rc-button-submit" type="submit"
name="Submit" <p>
value="<?php echo htmlspecialchars($this->t('{login:login_button}'), ENT_QUOTES); ?>"/> <button id="submitButton" tabindex="1" class="rc-button rc-button-submit" type="submit" name="Submit" value="1">
<br><br> <?php echo htmlspecialchars($this->t('{login:login_button}'), ENT_QUOTES); ?>
</button>
</p>
<!-- Undefined index is suppressed and the default is used for these values --> <!-- Undefined index is suppressed and the default is used for these values -->
<input id="mode" type="hidden" name="mode" <input id="mode" type="hidden" name="mode"
...@@ -193,7 +196,7 @@ if (null !== $this->data['errorCode']) { ...@@ -193,7 +196,7 @@ if (null !== $this->data['errorCode']) {
<?php <?php
// If enrollToken load QR Code // If enrollToken load QR Code
if (isset($this->data['tokenQR'])) { if (isset($this->data['tokenQR'])) {
echo htmlspecialchars($this->t('{privacyidea:privacyidea:scanTokenQR}')); ?> echo htmlspecialchars($this->t('{privacyidea:privacyidea:scan_token_qr}')); ?>
<div class="tokenQR"> <div class="tokenQR">
<?php echo '<img src="' . $this->data['tokenQR'] . '" />'; ?> <?php echo '<img src="' . $this->data['tokenQR'] . '" />'; ?>
</div> </div>
...@@ -297,8 +300,20 @@ if (!empty($this->data['links'])) { ...@@ -297,8 +300,20 @@ if (!empty($this->data['links'])) {
<meta id="privacyidea-translations" name="privacyidea-translations" content="<?php <meta id="privacyidea-translations" name="privacyidea-translations" content="<?php
$translations = []; $translations = [];
$translation_keys = [ $translation_keys = [
'alert_webauthn_insecure_context', 'alert_webauthn_unavailable', 'alert_webAuthnSignRequest_error', 'webauthn_insecure_context',
'alert_u2f_insecure_context', 'alert_u2f_unavailable', 'alert_U2FSignRequest_error', '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) { foreach ($translation_keys as $translation_key) {
$translations[$translation_key] = $this->t(sprintf('{privacyidea:privacyidea:%s}', $translation_key)); $translations[$translation_key] = $this->t(sprintf('{privacyidea:privacyidea:%s}', $translation_key));
......
...@@ -24,109 +24,92 @@ function set(id, value) { ...@@ -24,109 +24,92 @@ function set(id, value) {
} }
} }
function disable(id) { function hide(id) {
const element = getElement(id); const element = getElement(id);
if (element != null) { if (element != null) {
element.classList.add("hidden"); element.classList.add("hidden");
} }
} }
function enable(id) { function show(id) {
const element = getElement(id); const element = getElement(id);
if (element != null) { if (element != null) {
element.classList.remove("hidden"); element.classList.remove("hidden");
} }
} }
function changeMode(newMode) { function disable(id) {
set("mode", newMode); const element = getElement(id);
set("modeChanged", "true"); if (element != null) {
document.forms["piLoginForm"].submit(); element.disabled = true;
} element.classList.add("disabled");
}
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");
}
if (!booleanValue("pushAvailable")) {
disable("usePushButton");
} }
if (!booleanValue("otpAvailable")) { function enable(id) {
disable("useOTPButton"); const element = getElement(id);
if (element != null) {
element.disabled = false;
element.classList.remove("disabled");
}
} }
if ( function changeMode(newMode) {
!booleanValue("pushAvailable") && set("mode", newMode);
value("webAuthnSignRequest") === "" && set("modeChanged", "true");
value("u2fSignRequest") === "" document.forms["piLoginForm"].submit();
) {
disable("alternateTokenDiv");
} }
if (value("mode") === "otp") { function setMessage(newMessage) {
disable("useOTPButton"); getElement("message").innerText = newMessage;
} }
if (value("mode") === "webauthn") { function t(key) {
disable("otp"); return JSON.parse(getContent("privacyidea-translations"))[key];
disable("submitButton");
doWebAuthn();
} }
if (value("mode") === "u2f") { function getWebAuthnErrorMessage(err) {
disable("otp"); switch (err.name) {
disable("submitButton"); case "AbortError":
doU2F(); 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") { function webAuthnError(err) {
const pollingIntervals = [4, 3, 2, 1]; const errorMessage = getWebAuthnErrorMessage(err);
console.log("Error while signing WebAuthnSignRequest: " + err);
disable("otp"); enable("useWebAuthnButton");
disable("usePushButton"); setMessage(errorMessage);
disable("submitButton"); if (getElement("retryWebAuthnButton")) {
show("retryWebAuthnButton");
if (value("loadCounter") > pollingIntervals.length - 1) {
refreshTime = pollingIntervals[pollingIntervals.length - 1];
} else { } 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() { 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 mode is push, we have to change it, otherwise the site will refresh while doing webauthn
if (value("mode") === "push") { if (value("mode") === "push") {
changeMode("webauthn"); changeMode("webauthn");
} }
if (!window.isSecureContext) { if (!window.isSecureContext) {
alert(t("alert_webauthn_insecure_context")); enable("useWebAuthnButton");
setMessage(t("webauthn_insecure_context"));
console.log( console.log(
"Insecure context detected: Aborting Web Authn authentication!" "Insecure context detected: Aborting Web Authn authentication!"
); );
...@@ -135,7 +118,8 @@ function doWebAuthn() { ...@@ -135,7 +118,8 @@ function doWebAuthn() {
} }
if (!window.pi_webauthn) { if (!window.pi_webauthn) {
alert(t("alert_webauthn_unavailable")); enable("useWebAuthnButton");
setMessage(t("webauthn_library_unavailable"));
changeMode("otp"); changeMode("otp");
return; return;
} }
...@@ -156,15 +140,17 @@ function doWebAuthn() { ...@@ -156,15 +140,17 @@ function doWebAuthn() {
const requestjson = JSON.parse(requestStr); const requestjson = JSON.parse(requestStr);
const webAuthnSignResponse = window.pi_webauthn.sign(requestjson); const webAuthnSignResponse = window.pi_webauthn.sign(requestjson);
webAuthnSignResponse.then((webauthnresponse) => { webAuthnSignResponse
const response = JSON.stringify(webauthnresponse); .then((webauthnresponse) => {
set("webAuthnSignResponse", response); const response = JSON.stringify(webauthnresponse);
set("mode", "webauthn"); set("webAuthnSignResponse", response);
document.forms["piLoginForm"].submit(); set("mode", "webauthn");
}); setMessage(t("webauthn_success"));
document.forms["piLoginForm"].submit();
})
.catch(webAuthnError);
} catch (err) { } catch (err) {
console.log("Error while signing WebAuthnSignRequest: " + err); webAuthnError(err);
alert(t("alert_webAuthnSignRequest_error") + " " + err);
} }
} }
...@@ -175,7 +161,7 @@ function doU2F() { ...@@ -175,7 +161,7 @@ function doU2F() {
} }
if (!window.isSecureContext) { if (!window.isSecureContext) {
alert(t("alert_u2f_insecure_context")); setMessage(t("u2f_insecure_context"));
console.log("Insecure context detected: Aborting U2F authentication!"); console.log("Insecure context detected: Aborting U2F authentication!");
changeMode("otp"); changeMode("otp");
return; return;
...@@ -184,7 +170,7 @@ function doU2F() { ...@@ -184,7 +170,7 @@ function doU2F() {
const requestStr = value("u2fSignRequest"); const requestStr = value("u2fSignRequest");
if (requestStr === null) { if (requestStr === null) {
alert(t("alert_u2f_unavailable")); setMessage(t("u2f_unavailable"));
changeMode("otp"); changeMode("otp");
return; return;
} }
...@@ -194,7 +180,7 @@ function doU2F() { ...@@ -194,7 +180,7 @@ function doU2F() {
sign_u2f_request(requestjson); sign_u2f_request(requestjson);
} catch (err) { } catch (err) {
console.log("Error while signing U2FSignRequest: " + err); console.log("Error while signing U2FSignRequest: " + err);
alert(t("alert_U2FSignRequest_error") + " " + err); setMessage(t("u2f_sign_request_error") + " " + err);
} }
} }
...@@ -221,17 +207,93 @@ function sign_u2f_request(signRequest) { ...@@ -221,17 +207,93 @@ function sign_u2f_request(signRequest) {
}); });
} }
if (getContent("privacyidea-hide-alternate") === "true") { function addClickListener(id, listener) {
disable("AlternateLoginOptions"); const el = getElement(id);
if (el !== null) {
el.addEventListener("click", listener);
}
} }
document.addEventListener("DOMContentLoaded", (event) => { function initPrivacyIDEA() {
getElement("useWebAuthnButton").addEventListener("click", doWebAuthn); const step = getContent("privacyidea-step");
getElement("usePushButton").addEventListener("click", function () {
changeMode("push"); if (step > "1") {
}); hide("username");
getElement("useOTPButton").addEventListener("click", function () { hide("password");
changeMode("otp"); } else {
}); hide("otp");
getElement("useU2FButton").addEventListener("click", doU2F); hide("message");
}); hide("AlternateLoginOptions");
}
// Set alternate token button visibility
if (value("webAuthnSignRequest") === "") {
hide("useWebAuthnButton");
}
if (value("u2fSignRequest") === "") {
hide("useU2FButton");
}
if (!booleanValue("pushAvailable")) {
hide("usePushButton");
}
if (!booleanValue("otpAvailable")) {
hide("useOTPButton");
}
if (
!booleanValue("pushAvailable") &&
value("webAuthnSignRequest") === "" &&
value("u2fSignRequest") === ""
) {
hide("AlternateLoginOptions");
}
if (value("mode") === "otp") {
hide("useOTPButton");
}
if (value("mode") === "webauthn") {
hide("otp");
hide("submitButton");
doWebAuthn();
}
if (value("mode") === "u2f") {
hide("otp");
hide("submitButton");
doU2F();
}
if (value("mode") === "push") {
const pollingIntervals = [4, 3, 2, 1];
hide("otp");
hide("usePushButton");
hide("submitButton");
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);
}
if (getContent("privacyidea-hide-alternate") === "true") {
hide("AlternateLoginOptions");
}
addClickListener("useWebAuthnButton", doWebAuthn);
addClickListener("usePushButton", () => changeMode("push"));
addClickListener("useOTPButton", () => changeMode("otp"));
addClickListener("useU2FButton", doU2F);
}
document.addEventListener("DOMContentLoaded", initPrivacyIDEA);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment