Clean up WebAuthn javascript code and remove JQuery code (#22697)
There were several issues with the WebAuthn registration and testing code and the style was very old javascript with jquery callbacks. This PR uses async and fetch to replace the JQuery code. Ref #22651 Signed-off-by: Andrew Thornton <art27@cantab.net> --------- Signed-off-by: Andrew Thornton <art27@cantab.net> Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
parent
c09f747b51
commit
036fb7861f
|
@ -6,6 +6,8 @@ package security
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/auth"
|
"code.gitea.io/gitea/models/auth"
|
||||||
wa "code.gitea.io/gitea/modules/auth/webauthn"
|
wa "code.gitea.io/gitea/modules/auth/webauthn"
|
||||||
|
@ -23,8 +25,8 @@ import (
|
||||||
func WebAuthnRegister(ctx *context.Context) {
|
func WebAuthnRegister(ctx *context.Context) {
|
||||||
form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm)
|
form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm)
|
||||||
if form.Name == "" {
|
if form.Name == "" {
|
||||||
ctx.Error(http.StatusConflict)
|
// Set name to the hexadecimal of the current time
|
||||||
return
|
form.Name = strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
cred, err := auth.GetWebAuthnCredentialByName(ctx.Doer.ID, form.Name)
|
cred, err := auth.GetWebAuthnCredentialByName(ctx.Doer.ID, form.Name)
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<h3 class="ui top attached header">
|
<h3 class="ui top attached header">
|
||||||
{{.locale.Tr "twofa"}}
|
{{.locale.Tr "twofa"}}
|
||||||
</h3>
|
</h3>
|
||||||
|
{{template "user/auth/webauthn_error" .}}
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
{{svg "octicon-key" 56}}
|
{{svg "octicon-key" 56}}
|
||||||
<h3>{{.locale.Tr "webauthn_insert_key"}}</h3>
|
<h3>{{.locale.Tr "webauthn_insert_key"}}</h3>
|
||||||
|
@ -18,5 +19,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{template "user/auth/webauthn_error" .}}
|
|
||||||
{{template "base/footer" .}}
|
{{template "base/footer" .}}
|
||||||
|
|
|
@ -1,22 +1,13 @@
|
||||||
<div class="ui small modal" id="webauthn-error">
|
<div id="webauthn-error" class="ui small gt-hidden">
|
||||||
|
<div class="content ui negative message gt-df gt-fc gt-gap-3">
|
||||||
<div class="header">{{.locale.Tr "webauthn_error"}}</div>
|
<div class="header">{{.locale.Tr "webauthn_error"}}</div>
|
||||||
<div class="content">
|
<div id="webauthn-error-msg"></div>
|
||||||
<div class="ui negative message">
|
<div class="gt-hidden" data-webauthn-error-msg="browser">{{.locale.Tr "webauthn_unsupported_browser"}}</div>
|
||||||
<div class="header">
|
<div class="gt-hidden" data-webauthn-error-msg="unknown">{{.locale.Tr "webauthn_error_unknown"}}</div>
|
||||||
{{.locale.Tr "webauthn_error"}}
|
<div class="gt-hidden" data-webauthn-error-msg="insecure">{{.locale.Tr "webauthn_error_insecure"}}</div>
|
||||||
</div>
|
<div class="gt-hidden" data-webauthn-error-msg="unable-to-process">{{.locale.Tr "webauthn_error_unable_to_process"}}</div>
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="browser"><p>{{.locale.Tr "webauthn_unsupported_browser"}}</div>
|
<div class="gt-hidden" data-webauthn-error-msg="duplicated">{{.locale.Tr "webauthn_error_duplicated"}}</div>
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="unknown"><p>{{.locale.Tr "webauthn_error_unknown"}}</div>
|
<div class="gt-hidden" data-webauthn-error-msg="empty">{{.locale.Tr "webauthn_error_empty"}}</div>
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="insecure"><p>{{.locale.Tr "webauthn_error_insecure"}}</div>
|
<div class="gt-hidden" data-webauthn-error-msg="timeout">{{.locale.Tr "webauthn_error_timeout"}}</div>
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="unable-to-process"><p>{{.locale.Tr "webauthn_error_unable_to_process"}}</div>
|
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="duplicated"><p>{{.locale.Tr "webauthn_error_duplicated"}}</div>
|
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="empty"><p>{{.locale.Tr "webauthn_error_empty"}}</div>
|
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="timeout"><p>{{.locale.Tr "webauthn_error_timeout"}}</div>
|
|
||||||
<div class="gt-hidden" data-webauthn-error-msg="general"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button onclick="window.location.reload()" class="success ui button gt-hidden webauthn_error_timeout">{{.locale.Tr "webauthn_reload"}}</button>
|
|
||||||
<button class="ui cancel button">{{.locale.Tr "cancel"}}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
<p>{{.locale.Tr "settings.webauthn_desc" | Str2html}}</p>
|
<p>{{.locale.Tr "settings.webauthn_desc" | Str2html}}</p>
|
||||||
|
{{template "user/auth/webauthn_error" .}}
|
||||||
<div class="ui key list">
|
<div class="ui key list">
|
||||||
{{range .WebAuthnCredentials}}
|
{{range .WebAuthnCredentials}}
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
@ -28,7 +29,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "user/auth/webauthn_error" .}}
|
|
||||||
|
|
||||||
<div class="ui g-modal-confirm delete modal" id="delete-registration">
|
<div class="ui g-modal-confirm delete modal" id="delete-registration">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
|
|
@ -699,6 +699,11 @@ a.label,
|
||||||
border: 1px solid var(--color-secondary);
|
border: 1px solid var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui.info.message .header,
|
||||||
|
.ui.blue.message .header {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
.ui.info.message,
|
.ui.info.message,
|
||||||
.ui.attached.info.message,
|
.ui.attached.info.message,
|
||||||
.ui.blue.message,
|
.ui.blue.message,
|
||||||
|
@ -708,6 +713,12 @@ a.label,
|
||||||
border-color: var(--color-info-border);
|
border-color: var(--color-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui.success.message .header,
|
||||||
|
.ui.positive.message .header,
|
||||||
|
.ui.green.message .header {
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
.ui.success.message,
|
.ui.success.message,
|
||||||
.ui.attached.success.message,
|
.ui.attached.success.message,
|
||||||
.ui.positive.message,
|
.ui.positive.message,
|
||||||
|
@ -717,6 +728,12 @@ a.label,
|
||||||
border-color: var(--color-success-border);
|
border-color: var(--color-success-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui.error.message .header,
|
||||||
|
.ui.negative.message .header,
|
||||||
|
.ui.red.message .header {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
.ui.error.message,
|
.ui.error.message,
|
||||||
.ui.attached.error.message,
|
.ui.attached.error.message,
|
||||||
.ui.red.message,
|
.ui.red.message,
|
||||||
|
@ -728,6 +745,11 @@ a.label,
|
||||||
border-color: var(--color-error-border);
|
border-color: var(--color-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui.warning.message .header,
|
||||||
|
.ui.yellow.message .header {
|
||||||
|
color: var(--color-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
.ui.warning.message,
|
.ui.warning.message,
|
||||||
.ui.attached.warning.message,
|
.ui.attached.warning.message,
|
||||||
.ui.yellow.message,
|
.ui.yellow.message,
|
||||||
|
|
|
@ -2405,11 +2405,6 @@
|
||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings .content > .header,
|
|
||||||
.settings .content .segment {
|
|
||||||
box-shadow: 0 1px 2px 0 var(--color-box-header);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings.webhooks .list > .item:not(:first-child),
|
.settings.webhooks .list > .item:not(:first-child),
|
||||||
.settings.githooks .list > .item:not(:first-child),
|
.settings.githooks .list > .item:not(:first-child),
|
||||||
.settings.actions .list > .item:not(:first-child) {
|
.settings.actions .list > .item:not(:first-child) {
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import $ from 'jquery';
|
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js';
|
||||||
import {encode, decode} from 'uint8-to-base64';
|
import {showElem, hideElem} from '../utils/dom.js';
|
||||||
import {hideElem, showElem} from '../utils/dom.js';
|
|
||||||
|
|
||||||
const {appSubUrl, csrfToken} = window.config;
|
const {appSubUrl, csrfToken} = window.config;
|
||||||
|
|
||||||
export function initUserAuthWebAuthn() {
|
export async function initUserAuthWebAuthn() {
|
||||||
if ($('.user.signin.webauthn-prompt').length === 0) {
|
hideElem('#webauthn-error');
|
||||||
|
|
||||||
|
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
|
||||||
|
if (!elPrompt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,49 +15,52 @@ export function initUserAuthWebAuthn() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$.getJSON(`${appSubUrl}/user/webauthn/assertion`, {})
|
const res = await fetch(`${appSubUrl}/user/webauthn/assertion`);
|
||||||
.done((makeAssertionOptions) => {
|
if (res.status !== 200) {
|
||||||
makeAssertionOptions.publicKey.challenge = decodeURLEncodedBase64(makeAssertionOptions.publicKey.challenge);
|
webAuthnError('unknown');
|
||||||
for (let i = 0; i < makeAssertionOptions.publicKey.allowCredentials.length; i++) {
|
|
||||||
makeAssertionOptions.publicKey.allowCredentials[i].id = decodeURLEncodedBase64(makeAssertionOptions.publicKey.allowCredentials[i].id);
|
|
||||||
}
|
|
||||||
navigator.credentials.get({
|
|
||||||
publicKey: makeAssertionOptions.publicKey
|
|
||||||
})
|
|
||||||
.then((credential) => {
|
|
||||||
verifyAssertion(credential);
|
|
||||||
}).catch((err) => {
|
|
||||||
// Try again... without the appid
|
|
||||||
if (makeAssertionOptions.publicKey.extensions && makeAssertionOptions.publicKey.extensions.appid) {
|
|
||||||
delete makeAssertionOptions.publicKey.extensions['appid'];
|
|
||||||
navigator.credentials.get({
|
|
||||||
publicKey: makeAssertionOptions.publicKey
|
|
||||||
})
|
|
||||||
.then((credential) => {
|
|
||||||
verifyAssertion(credential);
|
|
||||||
}).catch((err) => {
|
|
||||||
webAuthnError('general', err.message);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const options = await res.json();
|
||||||
|
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
|
||||||
|
for (const cred of options.publicKey.allowCredentials) {
|
||||||
|
cred.id = decodeURLEncodedBase64(cred.id);
|
||||||
|
}
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: options.publicKey
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await verifyAssertion(credential);
|
||||||
|
} catch (err) {
|
||||||
|
if (!options.publicKey.extensions?.appid) {
|
||||||
webAuthnError('general', err.message);
|
webAuthnError('general', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete options.publicKey.extensions.appid;
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: options.publicKey
|
||||||
});
|
});
|
||||||
}).fail(() => {
|
try {
|
||||||
webAuthnError('unknown');
|
await verifyAssertion(credential);
|
||||||
});
|
} catch (err) {
|
||||||
|
webAuthnError('general', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyAssertion(assertedCredential) {
|
async function verifyAssertion(assertedCredential) {
|
||||||
// Move data into Arrays incase it is super long
|
// Move data into Arrays incase it is super long
|
||||||
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
|
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
|
||||||
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
|
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
|
||||||
const rawId = new Uint8Array(assertedCredential.rawId);
|
const rawId = new Uint8Array(assertedCredential.rawId);
|
||||||
const sig = new Uint8Array(assertedCredential.response.signature);
|
const sig = new Uint8Array(assertedCredential.response.signature);
|
||||||
const userHandle = new Uint8Array(assertedCredential.response.userHandle);
|
const userHandle = new Uint8Array(assertedCredential.response.userHandle);
|
||||||
$.ajax({
|
|
||||||
url: `${appSubUrl}/user/webauthn/assertion`,
|
const res = await fetch(`${appSubUrl}/user/webauthn/assertion`, {
|
||||||
type: 'POST',
|
method: 'POST',
|
||||||
data: JSON.stringify({
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
id: assertedCredential.id,
|
id: assertedCredential.id,
|
||||||
rawId: encodeURLEncodedBase64(rawId),
|
rawId: encodeURLEncodedBase64(rawId),
|
||||||
type: assertedCredential.type,
|
type: assertedCredential.type,
|
||||||
|
@ -67,50 +72,31 @@ function verifyAssertion(assertedCredential) {
|
||||||
userHandle: encodeURLEncodedBase64(userHandle),
|
userHandle: encodeURLEncodedBase64(userHandle),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
contentType: 'application/json; charset=utf-8',
|
});
|
||||||
dataType: 'json',
|
if (res.status === 500) {
|
||||||
success: (resp) => {
|
|
||||||
if (resp && resp['redirect']) {
|
|
||||||
window.location.href = resp['redirect'];
|
|
||||||
} else {
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: (xhr) => {
|
|
||||||
if (xhr.status === 500) {
|
|
||||||
webAuthnError('unknown');
|
webAuthnError('unknown');
|
||||||
return;
|
return;
|
||||||
}
|
} else if (res.status !== 200) {
|
||||||
webAuthnError('unable-to-process');
|
webAuthnError('unable-to-process');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
const reply = await res.json();
|
||||||
|
|
||||||
|
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode an ArrayBuffer into a URLEncoded base64 string.
|
async function webauthnRegistered(newCredential) {
|
||||||
function encodeURLEncodedBase64(value) {
|
|
||||||
return encode(value)
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dccode a URLEncoded base64 to an ArrayBuffer string.
|
|
||||||
function decodeURLEncodedBase64(value) {
|
|
||||||
return decode(value
|
|
||||||
.replace(/_/g, '/')
|
|
||||||
.replace(/-/g, '+'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function webauthnRegistered(newCredential) {
|
|
||||||
const attestationObject = new Uint8Array(newCredential.response.attestationObject);
|
const attestationObject = new Uint8Array(newCredential.response.attestationObject);
|
||||||
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
|
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
|
||||||
const rawId = new Uint8Array(newCredential.rawId);
|
const rawId = new Uint8Array(newCredential.rawId);
|
||||||
|
|
||||||
return $.ajax({
|
const res = await fetch(`${appSubUrl}/user/settings/security/webauthn/register`, {
|
||||||
url: `${appSubUrl}/user/settings/security/webauthn/register`,
|
method: 'POST',
|
||||||
type: 'POST',
|
headers: {
|
||||||
headers: {'X-Csrf-Token': csrfToken},
|
'X-Csrf-Token': csrfToken,
|
||||||
data: JSON.stringify({
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
id: newCredential.id,
|
id: newCredential.id,
|
||||||
rawId: encodeURLEncodedBase64(rawId),
|
rawId: encodeURLEncodedBase64(rawId),
|
||||||
type: newCredential.type,
|
type: newCredential.type,
|
||||||
|
@ -119,48 +105,47 @@ function webauthnRegistered(newCredential) {
|
||||||
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
|
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
dataType: 'json',
|
});
|
||||||
contentType: 'application/json; charset=utf-8',
|
|
||||||
}).then(() => {
|
if (res.status === 409) {
|
||||||
window.location.reload();
|
|
||||||
}).fail((xhr) => {
|
|
||||||
if (xhr.status === 409) {
|
|
||||||
webAuthnError('duplicated');
|
webAuthnError('duplicated');
|
||||||
return;
|
return;
|
||||||
}
|
} else if (res.status !== 201) {
|
||||||
webAuthnError('unknown');
|
webAuthnError('unknown');
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function webAuthnError(errorType, message) {
|
function webAuthnError(errorType, message) {
|
||||||
hideElem($('#webauthn-error [data-webauthn-error-msg]'));
|
const elErrorMsg = document.getElementById(`webauthn-error-msg`);
|
||||||
const $errorGeneral = $(`#webauthn-error [data-webauthn-error-msg=general]`);
|
|
||||||
if (errorType === 'general') {
|
if (errorType === 'general') {
|
||||||
showElem($errorGeneral);
|
elErrorMsg.textContent = message || 'unknown error';
|
||||||
$errorGeneral.text(message || 'unknown error');
|
|
||||||
} else {
|
} else {
|
||||||
const $errorTyped = $(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
|
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
|
||||||
if ($errorTyped.length) {
|
if (elTypedError) {
|
||||||
showElem($errorTyped);
|
elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
showElem($errorGeneral);
|
elErrorMsg.textContent = `unknown error type: ${errorType}${message ? ` ${message}` : ''}`;
|
||||||
$errorGeneral.text(`unknown error type: ${errorType}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$('#webauthn-error').modal('show');
|
|
||||||
|
showElem('#webauthn-error');
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectWebAuthnSupport() {
|
function detectWebAuthnSupport() {
|
||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
$('#register-button').prop('disabled', true);
|
document.getElementById('register-button').disabled = true;
|
||||||
$('#login-button').prop('disabled', true);
|
document.getElementById('login-button').disabled = true;
|
||||||
webAuthnError('insecure');
|
webAuthnError('insecure');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.PublicKeyCredential !== 'function') {
|
if (typeof window.PublicKeyCredential !== 'function') {
|
||||||
$('#register-button').prop('disabled', true);
|
document.getElementById('register-button').disabled = true;
|
||||||
$('#login-button').prop('disabled', true);
|
document.getElementById('login-button').disabled = true;
|
||||||
webAuthnError('browser');
|
webAuthnError('browser');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -169,12 +154,14 @@ function detectWebAuthnSupport() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initUserAuthWebAuthnRegister() {
|
export function initUserAuthWebAuthnRegister() {
|
||||||
if ($('#register-webauthn').length === 0) {
|
const elRegister = document.getElementById('register-webauthn');
|
||||||
|
if (!elRegister) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#webauthn-error').modal({allowMultiple: false});
|
hideElem('#webauthn-error');
|
||||||
$('#register-webauthn').on('click', (e) => {
|
|
||||||
|
elRegister.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!detectWebAuthnSupport()) {
|
if (!detectWebAuthnSupport()) {
|
||||||
return;
|
return;
|
||||||
|
@ -183,40 +170,48 @@ export function initUserAuthWebAuthnRegister() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function webAuthnRegisterRequest() {
|
async function webAuthnRegisterRequest() {
|
||||||
if ($('#nickname').val() === '') {
|
const elNickname = document.getElementById('nickname');
|
||||||
webAuthnError('empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$.post(`${appSubUrl}/user/settings/security/webauthn/request_register`, {
|
|
||||||
_csrf: csrfToken,
|
|
||||||
name: $('#nickname').val(),
|
|
||||||
}).done((makeCredentialOptions) => {
|
|
||||||
$('#nickname').closest('div.field').removeClass('error');
|
|
||||||
|
|
||||||
makeCredentialOptions.publicKey.challenge = decodeURLEncodedBase64(makeCredentialOptions.publicKey.challenge);
|
const body = new FormData();
|
||||||
makeCredentialOptions.publicKey.user.id = decodeURLEncodedBase64(makeCredentialOptions.publicKey.user.id);
|
body.append('name', elNickname.value);
|
||||||
if (makeCredentialOptions.publicKey.excludeCredentials) {
|
|
||||||
for (let i = 0; i < makeCredentialOptions.publicKey.excludeCredentials.length; i++) {
|
|
||||||
makeCredentialOptions.publicKey.excludeCredentials[i].id = decodeURLEncodedBase64(makeCredentialOptions.publicKey.excludeCredentials[i].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.credentials.create({
|
const res = await fetch(`${appSubUrl}/user/settings/security/webauthn/request_register`, {
|
||||||
publicKey: makeCredentialOptions.publicKey
|
method: 'POST',
|
||||||
}).then(webauthnRegistered)
|
headers: {
|
||||||
.catch((err) => {
|
'X-Csrf-Token': csrfToken,
|
||||||
if (!err) {
|
},
|
||||||
webAuthnError('unknown');
|
body,
|
||||||
return;
|
|
||||||
}
|
|
||||||
webAuthnError('general', err.message);
|
|
||||||
});
|
});
|
||||||
}).fail((xhr) => {
|
|
||||||
if (xhr.status === 409) {
|
if (res.status === 409) {
|
||||||
webAuthnError('duplicated');
|
webAuthnError('duplicated');
|
||||||
return;
|
return;
|
||||||
}
|
} else if (res.status !== 200) {
|
||||||
webAuthnError('unknown');
|
webAuthnError('unknown');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await res.json();
|
||||||
|
elNickname.closest('div.field').classList.remove('error');
|
||||||
|
|
||||||
|
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
|
||||||
|
options.publicKey.user.id = decodeURLEncodedBase64(options.publicKey.user.id);
|
||||||
|
if (options.publicKey.excludeCredentials) {
|
||||||
|
for (const cred of options.publicKey.excludeCredentials) {
|
||||||
|
cred.id = decodeURLEncodedBase64(cred.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let credential;
|
||||||
|
try {
|
||||||
|
credential = await navigator.credentials.create({
|
||||||
|
publicKey: options.publicKey
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
webAuthnError('unknown', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webauthnRegistered(credential);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {encode, decode} from 'uint8-to-base64';
|
||||||
|
|
||||||
// transform /path/to/file.ext to file.ext
|
// transform /path/to/file.ext to file.ext
|
||||||
export function basename(path = '') {
|
export function basename(path = '') {
|
||||||
return path ? path.replace(/^.*\//, '') : '';
|
return path ? path.replace(/^.*\//, '') : '';
|
||||||
|
@ -135,3 +137,17 @@ export function toAbsoluteUrl(url) {
|
||||||
return `${window.location.origin}${url}`;
|
return `${window.location.origin}${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encode an ArrayBuffer into a URLEncoded base64 string.
|
||||||
|
export function encodeURLEncodedBase64(arrayBuffer) {
|
||||||
|
return encode(arrayBuffer)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode a URLEncoded base64 to an ArrayBuffer string.
|
||||||
|
export function decodeURLEncodedBase64(base64url) {
|
||||||
|
return decode(base64url
|
||||||
|
.replace(/_/g, '/')
|
||||||
|
.replace(/-/g, '+'));
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {expect, test} from 'vitest';
|
||||||
import {
|
import {
|
||||||
basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
|
basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
|
||||||
parseUrl, translateMonth, translateDay, blobToDataURI,
|
parseUrl, translateMonth, translateDay, blobToDataURI,
|
||||||
toAbsoluteUrl,
|
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
test('basename', () => {
|
test('basename', () => {
|
||||||
|
@ -132,3 +132,9 @@ test('toAbsoluteUrl', () => {
|
||||||
|
|
||||||
expect(() => toAbsoluteUrl('path')).toThrowError('unsupported');
|
expect(() => toAbsoluteUrl('path')).toThrowError('unsupported');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
|
||||||
|
expect(encodeURLEncodedBase64(decodeURLEncodedBase64('foo'))).toEqual('foo'); // No = padding
|
||||||
|
expect(encodeURLEncodedBase64(decodeURLEncodedBase64('a-minus'))).toEqual('a-minus');
|
||||||
|
expect(encodeURLEncodedBase64(decodeURLEncodedBase64('_underscorc'))).toEqual('_underscorc');
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue