mirror of
https://github.com/miniflux/v2.git
synced 2025-07-22 17:18:37 +00:00
Add WebAuthn / Passkey integration
This is a rebase of #1618 in which @dave-atx added WebAuthn support. Closes #1618
This commit is contained in:
parent
62188b49f0
commit
62ef8ed57a
42 changed files with 1357 additions and 33 deletions
196
internal/ui/static/js/webauthn.js
Normal file
196
internal/ui/static/js/webauthn.js
Normal file
|
@ -0,0 +1,196 @@
|
|||
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));
|
||||
}
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue