Kaynağa Gözat

Refactor WebAuthn Javascript code

Frédéric Guillot 2 yıl önce
ebeveyn
işleme
2b8342fcd5

+ 0 - 3
internal/template/templates/common/layout.html

@@ -44,9 +44,6 @@
 
     <script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script>
     <script src="{{ route "javascript" "name" "service-worker" "checksum" .sw_js_checksum }}" defer id="service-worker-script"></script>
-    {{ if .webAuthnEnabled }}
-    <script src="{{ route "javascript" "name" "webauthn" "checksum" .webauthn_js_checksum }}" defer></script>
-    {{ end }}
 </head>
 <body
     {{ if .csrf }}data-csrf-token="{{ .csrf }}"{{ end }}

+ 0 - 1
internal/template/templates/views/settings.html

@@ -48,7 +48,6 @@
         </div>
         </fieldset>
 
-
         {{ if .webAuthnEnabled }}
         <fieldset>
             <legend>{{ t "page.settings.webauthn.passkeys" }}</legend>

+ 10 - 1
internal/ui/static/js/app.js

@@ -688,4 +688,13 @@ function checkShareAPI(title, url) {
         console.error(err);
         window.location.reload();
     }
-}
+}
+
+function getCsrfToken() {
+    let element = document.querySelector("body[data-csrf-token]");
+    if (element !== null) {
+        return element.dataset.csrfToken;
+    }
+
+    return "";
+}

+ 36 - 4
internal/ui/static/js/bootstrap.js

@@ -1,4 +1,4 @@
-document.addEventListener("DOMContentLoaded", function () {
+document.addEventListener("DOMContentLoaded", () => {
     handleSubmitButtons();
 
     if (!document.querySelector("body[data-disable-keyboard-shortcuts=true]")) {
@@ -48,6 +48,37 @@ document.addEventListener("DOMContentLoaded", function () {
     let touchHandler = new TouchHandler();
     touchHandler.listen();
 
+    if (WebAuthnHandler.isWebAuthnSupported()) {
+        const webauthnHandler = new WebAuthnHandler();
+
+        onClick("#webauthn-delete", () => { webauthnHandler.removeAllCredentials() });
+
+        let registerButton = document.getElementById("webauthn-register");
+        if (registerButton != null) {
+            registerButton.disabled = false;
+
+            onClick("#webauthn-register", () => {
+                webauthnHandler.register().catch((err) => WebAuthnHandler.showErrorMessage(err));
+            });
+        }
+
+        let loginButton = document.getElementById("webauthn-login");
+        if (loginButton != null) {
+            const abortController = new AbortController();
+            loginButton.disabled = false;
+
+            onClick("#webauthn-login", () => {
+                let usernameField = document.getElementById("form-username");
+                if (usernameField != null) {
+                    abortController.abort();
+                    webauthnHandler.login(usernameField.value).catch(err => WebAuthnHandler.showErrorMessage(err));
+                }
+            });
+
+            webauthnHandler.conditionalLogin(abortController).catch(err => WebAuthnHandler.showErrorMessage(err));
+        }
+    }
+
     onClick("a[data-save-entry]", (event) => handleSaveEntry(event.target));
     onClick("a[data-toggle-bookmark]", (event) => handleBookmark(event.target));
     onClick("a[data-fetch-content-entry]", () => handleFetchOriginalContent());
@@ -116,11 +147,12 @@ document.addEventListener("DOMContentLoaded", function () {
         }
     });
 
-   // enclosure media player position save & resume
+    // Save and resume media position
     const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]");
     elements.forEach((element) => {
-        // we set the current time of media players
-        if (element.dataset.lastPosition){ element.currentTime = element.dataset.lastPosition; }
+        if (element.dataset.lastPosition) {
+            element.currentTime = element.dataset.lastPosition;
+        }
         element.ontimeupdate = () => handlePlayerProgressionSave(element);
     });
 });

+ 1 - 10
internal/ui/static/js/request_builder.js

@@ -9,7 +9,7 @@ class RequestBuilder {
             body: null,
             headers: new Headers({
                 "Content-Type": "application/json",
-                "X-Csrf-Token": this.getCsrfToken()
+                "X-Csrf-Token": getCsrfToken()
             })
         };
     }
@@ -29,15 +29,6 @@ class RequestBuilder {
         return this;
     }
 
-    getCsrfToken() {
-        let element = document.querySelector("body[data-csrf-token]");
-        if (element !== null) {
-            return element.dataset.csrfToken;
-        }
-
-        return "";
-    }
-
     execute() {
         fetch(new Request(this.url, this.options)).then((response) => {
             if (this.callback) {

+ 0 - 196
internal/ui/static/js/webauthn.js

@@ -1,196 +0,0 @@
-function isWebAuthnSupported() {
-    return window.PublicKeyCredential;
-}
-
-async function isConditionalLoginSupported() {
-    return isWebAuthnSupported() &&
-     window.PublicKeyCredential.isConditionalMediationAvailable &&
-     window.PublicKeyCredential.isConditionalMediationAvailable();
-}
-
-// URLBase64 to ArrayBuffer
-function bufferDecode(value) {
-    return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
-}
-
-// ArrayBuffer to URLBase64
-function bufferEncode(value) {
-    return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
-    .replace(/\+/g, "-")
-    .replace(/\//g, "_")
-    .replace(/=/g, "");
-}
-
-function getCsrfToken() {
-    let element = document.querySelector("body[data-csrf-token]");
-    if (element !== null) {
-        return element.dataset.csrfToken;
-    }
-    return "";
-}
-
-async function post(urlKey, username, data) {
-    var url = document.body.dataset[urlKey];
-    if (username) {
-        url += "?username=" + username;
-    }
-    return fetch(url, {
-        method: "POST",
-        headers: {
-            "Content-Type": "application/json",
-            "X-Csrf-Token": getCsrfToken()
-        },
-        body: JSON.stringify(data),
-    });
-}
-
-async function get(urlKey, username) {
-    var url = document.body.dataset[urlKey];
-    if (username) {
-        url += "?username=" + username;
-    }
-    return fetch(url);
-}
-
-function showError(error) {
-    console.log("webauthn error: " + error);
-    let alert = document.getElementById("webauthn-error");
-    if (alert) {
-        alert.classList.remove("hidden");
-    }
-}
-
-async function register() {
-    let beginRegisterURL = "webauthnRegisterBeginUrl";
-    let r = await get(beginRegisterURL);
-    let credOptions = await r.json();
-    credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge);
-    credOptions.publicKey.user.id = bufferDecode(credOptions.publicKey.user.id);
-    if(Object.hasOwn(credOptions.publicKey, 'excludeCredentials')) {
-        credOptions.publicKey.excludeCredentials.forEach((credential) => credential.id = bufferDecode(credential.id));
-    }
-    let attestation = await navigator.credentials.create(credOptions);
-    let cred = {
-        id: attestation.id,
-        rawId: bufferEncode(attestation.rawId),
-        type: attestation.type,
-        response: {
-            attestationObject: bufferEncode(attestation.response.attestationObject),
-            clientDataJSON: bufferEncode(attestation.response.clientDataJSON),
-        },
-    };
-    let finishRegisterURL = "webauthnRegisterFinishUrl";
-    let response = await post(finishRegisterURL, null, cred);
-    if (!response.ok) {
-        throw new Error("Login failed with HTTP status " + response.status);
-    }
-    console.log("registration successful");
-
-    let jsonData = await response.json();
-    let redirect = jsonData.redirect;
-    window.location.href = redirect;
-}
-
-async function login(username, conditional) {
-    let beginLoginURL = "webauthnLoginBeginUrl";
-    let r = await get(beginLoginURL, username);
-    let credOptions = await r.json();
-    credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge);
-    if(Object.hasOwn(credOptions.publicKey, 'allowCredentials')) {
-        credOptions.publicKey.allowCredentials.forEach((credential) => credential.id = bufferDecode(credential.id));
-    }
-    if (conditional) {
-        credOptions.signal = abortController.signal;
-        credOptions.mediation = "conditional";
-    }
-    
-    var assertion;
-    try {
-        assertion = await navigator.credentials.get(credOptions);
-    }
-    catch (err) {
-        // swallow aborted conditional logins
-        if (err instanceof DOMException && err.name == "AbortError") {
-            return;
-        }
-        throw err;
-    }
-    
-    if (!assertion) {
-        return;
-    }
-    
-    let assertionResponse = {
-        id: assertion.id,
-        rawId: bufferEncode(assertion.rawId),
-        type: assertion.type,
-        response: {
-            authenticatorData: bufferEncode(assertion.response.authenticatorData),
-            clientDataJSON: bufferEncode(assertion.response.clientDataJSON),
-            signature: bufferEncode(assertion.response.signature),
-            userHandle: bufferEncode(assertion.response.userHandle),
-        },
-    };
-    
-    let finishLoginURL = "webauthnLoginFinishUrl";
-    let response = await post(finishLoginURL, username, assertionResponse);
-    if (!response.ok) {
-        throw new Error("Login failed with HTTP status " + response.status);
-    }
-    window.location.reload();
-}
-
-async function conditionalLogin() {
-    if (await isConditionalLoginSupported()) {
-        login("", true);
-    }
-}
-
-async function removeCreds(event) {
-    event.preventDefault();
-    let removeCredsURL = "webauthnDeleteAllUrl";
-    await post(removeCredsURL, null, {});
-    window.location.reload();
-}
-
-let abortController = new AbortController();
-document.addEventListener("DOMContentLoaded", function () {
-    if (!isWebAuthnSupported()) {
-        return;
-    }
-
-    let registerButton = document.getElementById("webauthn-register");
-    if (registerButton != null) {
-        registerButton.disabled = false;
-        registerButton.addEventListener("click", (e) => {
-            e.preventDefault();
-            register().catch((err) => showError(err));
-        });
-    }
-
-    let removeCredsButton = document.getElementById("webauthn-delete");
-    if (removeCredsButton != null) {
-        removeCredsButton.addEventListener("click", removeCreds);
-    }
-    
-    let loginButton = document.getElementById("webauthn-login");
-    if (loginButton != null) {
-        loginButton.disabled = false;
-        let usernameField = document.getElementById("form-username");
-        if (usernameField != null) {
-            usernameField.autocomplete += " webauthn";
-        }
-        let passwordField = document.getElementById("form-password");
-        if (passwordField != null) {
-            passwordField.autocomplete += " webauthn";
-        }
-
-        loginButton.addEventListener("click", (e) => {
-            e.preventDefault();
-            abortController.abort();
-            login(usernameField.value).catch(err => showError(err));
-        });
-        
-        conditionalLogin().catch(err => showError(err));
-    }
-});

+ 177 - 0
internal/ui/static/js/webauthn_handler.js

@@ -0,0 +1,177 @@
+class WebAuthnHandler {
+    static isWebAuthnSupported() {
+        return window.PublicKeyCredential;
+    }
+
+    static showErrorMessage(errorMessage) {
+        console.log("webauthn error: " + errorMessage);
+        let alertElement = document.getElementById("webauthn-error");
+        if (alertElement) {
+            alertElement.textContent += " (" + errorMessage + ")";
+            alertElement.classList.remove("hidden");
+        }
+    }
+
+    async isConditionalLoginSupported() {
+        return WebAuthnHandler.isWebAuthnSupported() &&
+            window.PublicKeyCredential.isConditionalMediationAvailable &&
+            window.PublicKeyCredential.isConditionalMediationAvailable();
+    }
+
+    async conditionalLogin(abortController) {
+        if (await this.isConditionalLoginSupported()) {
+            this.login("", abortController);
+        }
+    }
+
+    decodeBuffer(value) {
+        return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
+    }
+
+    encodeBuffer(value) {
+        return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
+            .replace(/\+/g, "-")
+            .replace(/\//g, "_")
+            .replace(/=/g, "");
+    }
+
+    async post(urlKey, username, data) {
+        let url = document.body.dataset[urlKey];
+        if (username) {
+            url += "?username=" + username;
+        }
+
+        return fetch(url, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json",
+                "X-Csrf-Token": getCsrfToken()
+            },
+            body: JSON.stringify(data),
+        });
+    }
+
+    async get(urlKey, username) {
+        let url = document.body.dataset[urlKey];
+        if (username) {
+            url += "?username=" + username;
+        }
+        return fetch(url);
+    }
+
+    async removeAllCredentials() {
+        try {
+            await this.post("webauthnDeleteAllUrl", null, {});
+        } catch (err) {
+            WebAuthnHandler.showErrorMessage(err);
+            return;
+        }
+
+        window.location.reload();
+    }
+
+    async register() {
+        let registerBeginResponse;
+        try {
+            registerBeginResponse = await this.get("webauthnRegisterBeginUrl");
+        } catch (err) {
+            WebAuthnHandler.showErrorMessage(err);
+            return;
+        }
+
+        let credentialCreationOptions = await registerBeginResponse.json();
+        credentialCreationOptions.publicKey.challenge = this.decodeBuffer(credentialCreationOptions.publicKey.challenge);
+        credentialCreationOptions.publicKey.user.id = this.decodeBuffer(credentialCreationOptions.publicKey.user.id);
+        if (Object.hasOwn(credentialCreationOptions.publicKey, 'excludeCredentials')) {
+            credentialCreationOptions.publicKey.excludeCredentials.forEach((credential) => credential.id = this.decodeBuffer(credential.id));
+        }
+
+        let attestation = await navigator.credentials.create(credentialCreationOptions);
+
+        let registrationFinishResponse;
+        try {
+            registrationFinishResponse = await this.post("webauthnRegisterFinishUrl", null, {
+                id: attestation.id,
+                rawId: this.encodeBuffer(attestation.rawId),
+                type: attestation.type,
+                response: {
+                    attestationObject: this.encodeBuffer(attestation.response.attestationObject),
+                    clientDataJSON: this.encodeBuffer(attestation.response.clientDataJSON),
+                },
+            });
+        } catch (err) {
+            WebAuthnHandler.showErrorMessage(err);
+            return;
+        }
+
+        if (!registrationFinishResponse.ok) {
+            throw new Error("Login failed with HTTP status code " + response.status);
+        }
+
+        let jsonData = await registrationFinishResponse.json();
+        window.location.href = jsonData.redirect;
+    }
+
+    async login(username, abortController) {
+        let loginBeginResponse;
+        try {
+            loginBeginResponse = await this.get("webauthnLoginBeginUrl", username);
+        } catch (err) {
+            WebAuthnHandler.showErrorMessage(err);
+            return;
+        }
+
+        let credentialRequestOptions = await loginBeginResponse.json();
+        credentialRequestOptions.publicKey.challenge = this.decodeBuffer(credentialRequestOptions.publicKey.challenge);
+
+        if (Object.hasOwn(credentialRequestOptions.publicKey, 'allowCredentials')) {
+            credentialRequestOptions.publicKey.allowCredentials.forEach((credential) => credential.id = this.decodeBuffer(credential.id));
+        }
+
+        if (abortController) {
+            credentialRequestOptions.signal = abortController.signal;
+            credentialRequestOptions.mediation = "conditional";
+        }
+
+        let assertion;
+        try {
+            assertion = await navigator.credentials.get(credentialRequestOptions);
+        }
+        catch (err) {
+            // Swallow aborted conditional logins
+            if (err instanceof DOMException && err.name == "AbortError") {
+                return;
+            }
+            WebAuthnHandler.showErrorMessage(err);
+            return;
+        }
+
+        if (!assertion) {
+            return;
+        }
+
+        let loginFinishResponse;
+        try {
+            loginFinishResponse = await this.post("webauthnLoginFinishUrl", username, {
+                id: assertion.id,
+                rawId: this.encodeBuffer(assertion.rawId),
+                type: assertion.type,
+                response: {
+                    authenticatorData: this.encodeBuffer(assertion.response.authenticatorData),
+                    clientDataJSON: this.encodeBuffer(assertion.response.clientDataJSON),
+                    signature: this.encodeBuffer(assertion.response.signature),
+                    userHandle: this.encodeBuffer(assertion.response.userHandle),
+                },
+            });
+        } catch (err) {
+            WebAuthnHandler.showErrorMessage(err);
+            return;
+        }
+
+        if (!loginFinishResponse.ok) {
+            throw new Error("Login failed with HTTP status code " + loginFinishResponse.status);
+        }
+
+        window.location.reload();
+    }
+}

+ 1 - 3
internal/ui/static/static.go

@@ -118,14 +118,12 @@ func GenerateJavascriptBundles() error {
 			"js/request_builder.js",
 			"js/modal_handler.js",
 			"js/app.js",
+			"js/webauthn_handler.js",
 			"js/bootstrap.js",
 		},
 		"service-worker": {
 			"js/service_worker.js",
 		},
-		"webauthn": {
-			"js/webauthn.js",
-		},
 	}
 
 	var prefixes = map[string]string{

+ 1 - 0
internal/ui/webauthn.go

@@ -13,6 +13,7 @@ import (
 
 	"github.com/go-webauthn/webauthn/protocol"
 	"github.com/go-webauthn/webauthn/webauthn"
+
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
 	"miniflux.app/v2/internal/http/cookie"