diff options
-rw-r--r-- | routers/web/user/setting/security/webauthn.go | 6 | ||||
-rw-r--r-- | templates/user/auth/webauthn.tmpl | 2 | ||||
-rw-r--r-- | templates/user/auth/webauthn_error.tmpl | 31 | ||||
-rw-r--r-- | templates/user/settings/security/webauthn.tmpl | 2 | ||||
-rw-r--r-- | web_src/css/base.css | 22 | ||||
-rw-r--r-- | web_src/css/repo.css | 5 | ||||
-rw-r--r-- | web_src/js/features/user-auth-webauthn.js | 263 | ||||
-rw-r--r-- | web_src/js/utils.js | 16 | ||||
-rw-r--r-- | web_src/js/utils.test.js | 8 |
9 files changed, 191 insertions, 164 deletions
diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 0054318867..826562f157 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -6,6 +6,8 @@ package security import ( "errors" "net/http" + "strconv" + "time" "code.gitea.io/gitea/models/auth" wa "code.gitea.io/gitea/modules/auth/webauthn" @@ -23,8 +25,8 @@ import ( func WebAuthnRegister(ctx *context.Context) { form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm) if form.Name == "" { - ctx.Error(http.StatusConflict) - return + // Set name to the hexadecimal of the current time + form.Name = strconv.FormatInt(time.Now().UnixNano(), 16) } cred, err := auth.GetWebAuthnCredentialByName(ctx.Doer.ID, form.Name) diff --git a/templates/user/auth/webauthn.tmpl b/templates/user/auth/webauthn.tmpl index ea773d2566..f1c4f29fd9 100644 --- a/templates/user/auth/webauthn.tmpl +++ b/templates/user/auth/webauthn.tmpl @@ -5,6 +5,7 @@ <h3 class="ui top attached header"> {{.locale.Tr "twofa"}} </h3> + {{template "user/auth/webauthn_error" .}} <div class="ui attached segment"> {{svg "octicon-key" 56}} <h3>{{.locale.Tr "webauthn_insert_key"}}</h3> @@ -18,5 +19,4 @@ </div> </div> </div> -{{template "user/auth/webauthn_error" .}} {{template "base/footer" .}} diff --git a/templates/user/auth/webauthn_error.tmpl b/templates/user/auth/webauthn_error.tmpl index b6467de1aa..f90882ef12 100644 --- a/templates/user/auth/webauthn_error.tmpl +++ b/templates/user/auth/webauthn_error.tmpl @@ -1,22 +1,13 @@ -<div class="ui small modal" id="webauthn-error"> - <div class="header">{{.locale.Tr "webauthn_error"}}</div> - <div class="content"> - <div class="ui negative message"> - <div class="header"> - {{.locale.Tr "webauthn_error"}} - </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="unknown"><p>{{.locale.Tr "webauthn_error_unknown"}}</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="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 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 id="webauthn-error-msg"></div> + <div class="gt-hidden" data-webauthn-error-msg="browser">{{.locale.Tr "webauthn_unsupported_browser"}}</div> + <div class="gt-hidden" data-webauthn-error-msg="unknown">{{.locale.Tr "webauthn_error_unknown"}}</div> + <div class="gt-hidden" data-webauthn-error-msg="insecure">{{.locale.Tr "webauthn_error_insecure"}}</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="duplicated">{{.locale.Tr "webauthn_error_duplicated"}}</div> + <div class="gt-hidden" data-webauthn-error-msg="empty">{{.locale.Tr "webauthn_error_empty"}}</div> + <div class="gt-hidden" data-webauthn-error-msg="timeout">{{.locale.Tr "webauthn_error_timeout"}}</div> </div> </div> diff --git a/templates/user/settings/security/webauthn.tmpl b/templates/user/settings/security/webauthn.tmpl index 59022eb1c9..e541f764bc 100644 --- a/templates/user/settings/security/webauthn.tmpl +++ b/templates/user/settings/security/webauthn.tmpl @@ -3,6 +3,7 @@ </h4> <div class="ui attached segment"> <p>{{.locale.Tr "settings.webauthn_desc" | Str2html}}</p> + {{template "user/auth/webauthn_error" .}} <div class="ui key list"> {{range .WebAuthnCredentials}} <div class="item"> @@ -28,7 +29,6 @@ </div> </div> -{{template "user/auth/webauthn_error" .}} <div class="ui g-modal-confirm delete modal" id="delete-registration"> <div class="header"> diff --git a/web_src/css/base.css b/web_src/css/base.css index 49bdfed1b7..b352e6d98e 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -699,6 +699,11 @@ a.label, border: 1px solid var(--color-secondary); } +.ui.info.message .header, +.ui.blue.message .header { + color: var(--color-blue); +} + .ui.info.message, .ui.attached.info.message, .ui.blue.message, @@ -708,6 +713,12 @@ a.label, 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.attached.success.message, .ui.positive.message, @@ -717,6 +728,12 @@ a.label, 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.attached.error.message, .ui.red.message, @@ -728,6 +745,11 @@ a.label, border-color: var(--color-error-border); } +.ui.warning.message .header, +.ui.yellow.message .header { + color: var(--color-yellow); +} + .ui.warning.message, .ui.attached.warning.message, .ui.yellow.message, diff --git a/web_src/css/repo.css b/web_src/css/repo.css index b2b544a7ea..4e300be878 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2405,11 +2405,6 @@ 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.githooks .list > .item:not(:first-child), .settings.actions .list > .item:not(:first-child) { diff --git a/web_src/js/features/user-auth-webauthn.js b/web_src/js/features/user-auth-webauthn.js index f30017afc6..8085313d22 100644 --- a/web_src/js/features/user-auth-webauthn.js +++ b/web_src/js/features/user-auth-webauthn.js @@ -1,11 +1,13 @@ -import $ from 'jquery'; -import {encode, decode} from 'uint8-to-base64'; -import {hideElem, showElem} from '../utils/dom.js'; +import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js'; +import {showElem, hideElem} from '../utils/dom.js'; const {appSubUrl, csrfToken} = window.config; -export function initUserAuthWebAuthn() { - if ($('.user.signin.webauthn-prompt').length === 0) { +export async function initUserAuthWebAuthn() { + hideElem('#webauthn-error'); + + const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); + if (!elPrompt) { return; } @@ -13,49 +15,52 @@ export function initUserAuthWebAuthn() { return; } - $.getJSON(`${appSubUrl}/user/webauthn/assertion`, {}) - .done((makeAssertionOptions) => { - makeAssertionOptions.publicKey.challenge = decodeURLEncodedBase64(makeAssertionOptions.publicKey.challenge); - 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; - } - webAuthnError('general', err.message); - }); - }).fail(() => { - webAuthnError('unknown'); + const res = await fetch(`${appSubUrl}/user/webauthn/assertion`); + if (res.status !== 200) { + webAuthnError('unknown'); + 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); + return; + } + delete options.publicKey.extensions.appid; + const credential = await navigator.credentials.get({ + publicKey: options.publicKey }); + try { + 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 const authData = new Uint8Array(assertedCredential.response.authenticatorData); const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON); const rawId = new Uint8Array(assertedCredential.rawId); const sig = new Uint8Array(assertedCredential.response.signature); const userHandle = new Uint8Array(assertedCredential.response.userHandle); - $.ajax({ - url: `${appSubUrl}/user/webauthn/assertion`, - type: 'POST', - data: JSON.stringify({ + + const res = await fetch(`${appSubUrl}/user/webauthn/assertion`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: JSON.stringify({ id: assertedCredential.id, rawId: encodeURLEncodedBase64(rawId), type: assertedCredential.type, @@ -67,50 +72,31 @@ function verifyAssertion(assertedCredential) { userHandle: encodeURLEncodedBase64(userHandle), }, }), - contentType: 'application/json; charset=utf-8', - dataType: 'json', - success: (resp) => { - if (resp && resp['redirect']) { - window.location.href = resp['redirect']; - } else { - window.location.href = '/'; - } - }, - error: (xhr) => { - if (xhr.status === 500) { - webAuthnError('unknown'); - return; - } - webAuthnError('unable-to-process'); - } }); -} - -// Encode an ArrayBuffer into a URLEncoded base64 string. -function encodeURLEncodedBase64(value) { - return encode(value) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} + if (res.status === 500) { + webAuthnError('unknown'); + return; + } else if (res.status !== 200) { + webAuthnError('unable-to-process'); + return; + } + const reply = await res.json(); -// Dccode a URLEncoded base64 to an ArrayBuffer string. -function decodeURLEncodedBase64(value) { - return decode(value - .replace(/_/g, '/') - .replace(/-/g, '+')); + window.location.href = reply?.redirect ?? `${appSubUrl}/`; } -function webauthnRegistered(newCredential) { +async function webauthnRegistered(newCredential) { const attestationObject = new Uint8Array(newCredential.response.attestationObject); const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); const rawId = new Uint8Array(newCredential.rawId); - return $.ajax({ - url: `${appSubUrl}/user/settings/security/webauthn/register`, - type: 'POST', - headers: {'X-Csrf-Token': csrfToken}, - data: JSON.stringify({ + const res = await fetch(`${appSubUrl}/user/settings/security/webauthn/register`, { + method: 'POST', + headers: { + 'X-Csrf-Token': csrfToken, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ id: newCredential.id, rawId: encodeURLEncodedBase64(rawId), type: newCredential.type, @@ -119,48 +105,47 @@ function webauthnRegistered(newCredential) { clientDataJSON: encodeURLEncodedBase64(clientDataJSON), }, }), - dataType: 'json', - contentType: 'application/json; charset=utf-8', - }).then(() => { - window.location.reload(); - }).fail((xhr) => { - if (xhr.status === 409) { - webAuthnError('duplicated'); - return; - } - webAuthnError('unknown'); }); + + if (res.status === 409) { + webAuthnError('duplicated'); + return; + } else if (res.status !== 201) { + webAuthnError('unknown'); + return; + } + + window.location.reload(); } function webAuthnError(errorType, message) { - hideElem($('#webauthn-error [data-webauthn-error-msg]')); - const $errorGeneral = $(`#webauthn-error [data-webauthn-error-msg=general]`); + const elErrorMsg = document.getElementById(`webauthn-error-msg`); + if (errorType === 'general') { - showElem($errorGeneral); - $errorGeneral.text(message || 'unknown error'); + elErrorMsg.textContent = message || 'unknown error'; } else { - const $errorTyped = $(`#webauthn-error [data-webauthn-error-msg=${errorType}]`); - if ($errorTyped.length) { - showElem($errorTyped); + const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`); + if (elTypedError) { + elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`; } else { - showElem($errorGeneral); - $errorGeneral.text(`unknown error type: ${errorType}`); + elErrorMsg.textContent = `unknown error type: ${errorType}${message ? ` ${message}` : ''}`; } } - $('#webauthn-error').modal('show'); + + showElem('#webauthn-error'); } function detectWebAuthnSupport() { if (!window.isSecureContext) { - $('#register-button').prop('disabled', true); - $('#login-button').prop('disabled', true); + document.getElementById('register-button').disabled = true; + document.getElementById('login-button').disabled = true; webAuthnError('insecure'); return false; } if (typeof window.PublicKeyCredential !== 'function') { - $('#register-button').prop('disabled', true); - $('#login-button').prop('disabled', true); + document.getElementById('register-button').disabled = true; + document.getElementById('login-button').disabled = true; webAuthnError('browser'); return false; } @@ -169,12 +154,14 @@ function detectWebAuthnSupport() { } export function initUserAuthWebAuthnRegister() { - if ($('#register-webauthn').length === 0) { + const elRegister = document.getElementById('register-webauthn'); + if (!elRegister) { return; } - $('#webauthn-error').modal({allowMultiple: false}); - $('#register-webauthn').on('click', (e) => { + hideElem('#webauthn-error'); + + elRegister.addEventListener('click', (e) => { e.preventDefault(); if (!detectWebAuthnSupport()) { return; @@ -183,40 +170,48 @@ export function initUserAuthWebAuthnRegister() { }); } -function webAuthnRegisterRequest() { - if ($('#nickname').val() === '') { - webAuthnError('empty'); +async function webAuthnRegisterRequest() { + const elNickname = document.getElementById('nickname'); + + const body = new FormData(); + body.append('name', elNickname.value); + + const res = await fetch(`${appSubUrl}/user/settings/security/webauthn/request_register`, { + method: 'POST', + headers: { + 'X-Csrf-Token': csrfToken, + }, + body, + }); + + if (res.status === 409) { + webAuthnError('duplicated'); + return; + } else if (res.status !== 200) { + webAuthnError('unknown'); 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); - makeCredentialOptions.publicKey.user.id = decodeURLEncodedBase64(makeCredentialOptions.publicKey.user.id); - 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({ - publicKey: makeCredentialOptions.publicKey - }).then(webauthnRegistered) - .catch((err) => { - if (!err) { - webAuthnError('unknown'); - return; - } - webAuthnError('general', err.message); - }); - }).fail((xhr) => { - if (xhr.status === 409) { - webAuthnError('duplicated'); - 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); } - webAuthnError('unknown'); - }); + } + + let credential; + try { + credential = await navigator.credentials.create({ + publicKey: options.publicKey + }); + } catch (err) { + webAuthnError('unknown', err); + return; + } + + webauthnRegistered(credential); } diff --git a/web_src/js/utils.js b/web_src/js/utils.js index 6bee4f0836..4f9ad452f6 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -1,3 +1,5 @@ +import {encode, decode} from 'uint8-to-base64'; + // transform /path/to/file.ext to file.ext export function basename(path = '') { return path ? path.replace(/^.*\//, '') : ''; @@ -135,3 +137,17 @@ export function toAbsoluteUrl(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, '+')); +} diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 2f9e5fb47d..cf73b63b99 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -2,7 +2,7 @@ import {expect, test} from 'vitest'; import { basename, extname, isObject, stripTags, joinPaths, parseIssueHref, parseUrl, translateMonth, translateDay, blobToDataURI, - toAbsoluteUrl, + toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, } from './utils.js'; test('basename', () => { @@ -132,3 +132,9 @@ test('toAbsoluteUrl', () => { 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'); +}); |