Refactor WebAuthn Javascript code

This commit is contained in:
Frédéric Guillot 2023-11-06 17:28:25 +00:00
parent a75256bed5
commit 2b8342fcd5
9 changed files with 226 additions and 218 deletions

View file

@ -44,9 +44,6 @@
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script> <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> <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> </head>
<body <body
{{ if .csrf }}data-csrf-token="{{ .csrf }}"{{ end }} {{ if .csrf }}data-csrf-token="{{ .csrf }}"{{ end }}

View file

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

View file

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

View file

@ -1,4 +1,4 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", () => {
handleSubmitButtons(); handleSubmitButtons();
if (!document.querySelector("body[data-disable-keyboard-shortcuts=true]")) { if (!document.querySelector("body[data-disable-keyboard-shortcuts=true]")) {
@ -48,6 +48,37 @@ document.addEventListener("DOMContentLoaded", function () {
let touchHandler = new TouchHandler(); let touchHandler = new TouchHandler();
touchHandler.listen(); 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-save-entry]", (event) => handleSaveEntry(event.target));
onClick("a[data-toggle-bookmark]", (event) => handleBookmark(event.target)); onClick("a[data-toggle-bookmark]", (event) => handleBookmark(event.target));
onClick("a[data-fetch-content-entry]", () => handleFetchOriginalContent()); 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]"); const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]");
elements.forEach((element) => { elements.forEach((element) => {
// we set the current time of media players if (element.dataset.lastPosition) {
if (element.dataset.lastPosition){ element.currentTime = element.dataset.lastPosition; } element.currentTime = element.dataset.lastPosition;
}
element.ontimeupdate = () => handlePlayerProgressionSave(element); element.ontimeupdate = () => handlePlayerProgressionSave(element);
}); });
}); });

View file

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

View file

@ -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));
}
});

View file

@ -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();
}
}

View file

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

View file

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