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 @@
"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": {
......@@ -44,31 +44,49 @@
"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": {
......
......@@ -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.",
"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."
......@@ -69,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"
......
......@@ -93,14 +93,15 @@ if (null !== $this->data['errorCode']) {
<?php
} else {
?>
<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
}
......@@ -137,7 +138,7 @@ if (null !== $this->data['errorCode']) {
<input id="password" name="password" tabindex="1" type="password" value="" class="text"
placeholder="<?php echo htmlspecialchars($passHint, ENT_QUOTES); ?>"/>
<strong id="message"><?php
<p id="message" role="alert"><?php
$messageOverride = $this->data['messageOverride'] ?? null;
if (null === $messageOverride || is_string($messageOverride)) {
echo htmlspecialchars(
......@@ -147,18 +148,20 @@ if (null !== $this->data['errorCode']) {
} elseif (is_callable($messageOverride)) {
echo call_user_func($messageOverride, $this->data['message'] ?? '');
}
?></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>
?></p>
<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>
<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"
......@@ -193,7 +196,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>
......@@ -297,8 +300,20 @@ if (!empty($this->data['links'])) {
<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));
......
......@@ -24,109 +24,92 @@ 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");
}
if (!booleanValue("pushAvailable")) {
disable("usePushButton");
function disable(id) {
const element = getElement(id);
if (element != null) {
element.disabled = true;
element.classList.add("disabled");
}
}
if (!booleanValue("otpAvailable")) {
disable("useOTPButton");
function enable(id) {
const element = getElement(id);
if (element != null) {
element.disabled = false;
element.classList.remove("disabled");
}
}
if (
!booleanValue("pushAvailable") &&
value("webAuthnSignRequest") === "" &&
value("u2fSignRequest") === ""
) {
disable("alternateTokenDiv");
function changeMode(newMode) {
set("mode", newMode);
set("modeChanged", "true");
document.forms["piLoginForm"].submit();
}
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!"
);
......@@ -135,7 +118,8 @@ function doWebAuthn() {
}
if (!window.pi_webauthn) {
alert(t("alert_webauthn_unavailable"));
enable("useWebAuthnButton");
setMessage(t("webauthn_library_unavailable"));
changeMode("otp");
return;
}
......@@ -156,15 +140,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,7 +161,7 @@ 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");
return;
......@@ -184,7 +170,7 @@ function doU2F() {
const requestStr = value("u2fSignRequest");
if (requestStr === null) {
alert(t("alert_u2f_unavailable"));
setMessage(t("u2f_unavailable"));
changeMode("otp");
return;
}
......@@ -194,7 +180,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 +207,93 @@ 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");
});
getElement("useU2FButton").addEventListener("click", doU2F);
});
function initPrivacyIDEA() {
const step = getContent("privacyidea-step");
if (step > "1") {
hide("username");
hide("password");
} else {
hide("otp");
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.
Please register or to comment