Fix webauthn regression and improve code (#25113)

Follow:

* #22697

There are some bugs in #22697:

* https://github.com/go-gitea/gitea/pull/22697#issuecomment-1577957966
* the webauthn failure message is never shown and causes console error
* The `document.getElementById('register-button')` and
`document.getElementById('login-button')` is wrong
    * there is no such element in code
    * it causes JS error when a browser doesn't provide webauthn
    * the end user can't see the real error message

These bugs are fixed in this PR.

Other changes:

* Use simple HTML/CSS layouts, no need to use too many `gt-` patches
* Make the webauthn page have correct "page-content" layout
* The "data-webauthn-error-msg" elements are only used to provide locale
texts, so move them into a single "gt-hidden", then no need to repeat a
lot of "gt-hidden" in code
* The `{{.CsrfTokenHtml}}`  is a no-op because there is no form
* Many `hideElem('#webauthn-error')` in code is no-op because the
`webauthn-error` already has "gt-hidden" by default
* Make the tests for "URLEncodedBase64" really test with concrete cases.


Screenshots:

* Error message when webauthn fails (before, there is no error message):

<details>


![image](https://github.com/go-gitea/gitea/assets/2114189/93cf9559-d93b-4f06-9d98-0f7032d9c65b)

</details>

* Error message when webauthn is unavailable 

<details>


![image](https://github.com/go-gitea/gitea/assets/2114189/ffc0fcd9-b93b-4418-979c-c89bb627aaf2)

</details>
This commit is contained in:
wxiaoguang 2023-06-07 19:20:18 +08:00 committed by GitHub
parent 58536093b3
commit 027014d7de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 57 additions and 66 deletions

View file

@ -1,18 +1,19 @@
{{template "base/head" .}} {{template "base/head" .}}
<div class="user signin webauthn-prompt"> <div role="main" aria-label="{{.Title}}" class="page-content user signin webauthn-prompt">
<div class="ui middle centered very relaxed page grid"> <div class="ui page grid">
<div class="column center aligned"> <div class="column center aligned">
<h3 class="ui top attached header">
{{.locale.Tr "twofa"}}
</h3>
{{template "user/auth/webauthn_error" .}} {{template "user/auth/webauthn_error" .}}
<h3 class="ui top attached header">{{.locale.Tr "twofa"}}</h3>
<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>
{{template "base/alert" .}} {{template "base/alert" .}}
<p>{{.locale.Tr "webauthn_sign_in"}}</p> <p>{{.locale.Tr "webauthn_sign_in"}}</p>
</div> </div>
<div class="ui attached segment"><div class="ui active indeterminate inline loader"></div> {{.locale.Tr "webauthn_press_button"}} </div> <div class="ui attached segment">
<div class="ui active indeterminate inline loader"></div>
{{.locale.Tr "webauthn_press_button"}}
</div>
<div class="ui attached segment"> <div class="ui attached segment">
<a href="{{AppSubUrl}}/user/two_factor">{{.locale.Tr "webauthn_use_twofa"}}</a> <a href="{{AppSubUrl}}/user/two_factor">{{.locale.Tr "webauthn_use_twofa"}}</a>
</div> </div>

View file

@ -1,13 +1,13 @@
<div id="webauthn-error" class="ui small gt-hidden"> <div id="webauthn-error" class="ui negative message 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 id="webauthn-error-msg"></div> <div id="webauthn-error-msg" class="gt-pt-3"></div>
<div class="gt-hidden" data-webauthn-error-msg="browser">{{.locale.Tr "webauthn_unsupported_browser"}}</div> <div class="gt-hidden">
<div class="gt-hidden" data-webauthn-error-msg="unknown">{{.locale.Tr "webauthn_error_unknown"}}</div> <div data-webauthn-error-msg="browser">{{.locale.Tr "webauthn_unsupported_browser"}}</div>
<div class="gt-hidden" data-webauthn-error-msg="insecure">{{.locale.Tr "webauthn_error_insecure"}}</div> <div data-webauthn-error-msg="unknown">{{.locale.Tr "webauthn_error_unknown"}}</div>
<div class="gt-hidden" data-webauthn-error-msg="unable-to-process">{{.locale.Tr "webauthn_error_unable_to_process"}}</div> <div data-webauthn-error-msg="insecure">{{.locale.Tr "webauthn_error_insecure"}}</div>
<div class="gt-hidden" data-webauthn-error-msg="duplicated">{{.locale.Tr "webauthn_error_duplicated"}}</div> <div data-webauthn-error-msg="unable-to-process">{{.locale.Tr "webauthn_error_unable_to_process"}}</div>
<div class="gt-hidden" data-webauthn-error-msg="empty">{{.locale.Tr "webauthn_error_empty"}}</div> <div data-webauthn-error-msg="duplicated">{{.locale.Tr "webauthn_error_duplicated"}}</div>
<div class="gt-hidden" data-webauthn-error-msg="timeout">{{.locale.Tr "webauthn_error_timeout"}}</div> <div data-webauthn-error-msg="empty">{{.locale.Tr "webauthn_error_empty"}}</div>
<div data-webauthn-error-msg="timeout">{{.locale.Tr "webauthn_error_timeout"}}</div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,4 @@
<h4 class="ui top attached header"> <h4 class="ui top attached header">{{.locale.Tr "settings.webauthn"}}</h4>
{{.locale.Tr "settings.webauthn"}}
</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" .}} {{template "user/auth/webauthn_error" .}}
@ -20,7 +18,6 @@
{{end}} {{end}}
</div> </div>
<div class="ui form"> <div class="ui form">
{{.CsrfTokenHtml}}
<div class="required field"> <div class="required field">
<label for="nickname">{{.locale.Tr "settings.webauthn_nickname"}}</label> <label for="nickname">{{.locale.Tr "settings.webauthn_nickname"}}</label>
<input id="nickname" name="nickname" type="text" required> <input id="nickname" name="nickname" type="text" required>
@ -29,7 +26,6 @@
</div> </div>
</div> </div>
<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">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}

View file

@ -394,10 +394,6 @@ textarea:focus,
margin: 0; margin: 0;
} }
.user.signin.webauthn-prompt {
margin-top: 15px;
}
.repository.new.repo form, .repository.new.repo form,
.repository.new.migrate form, .repository.new.migrate form,
.repository.new.fork form { .repository.new.fork form {

View file

@ -1,11 +1,9 @@
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js'; import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js';
import {showElem, hideElem} from '../utils/dom.js'; import {showElem} from '../utils/dom.js';
const {appSubUrl, csrfToken} = window.config; const {appSubUrl, csrfToken} = window.config;
export async function initUserAuthWebAuthn() { export async function initUserAuthWebAuthn() {
hideElem('#webauthn-error');
const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
if (!elPrompt) { if (!elPrompt) {
return; return;
@ -25,10 +23,10 @@ export async function initUserAuthWebAuthn() {
for (const cred of options.publicKey.allowCredentials) { for (const cred of options.publicKey.allowCredentials) {
cred.id = decodeURLEncodedBase64(cred.id); cred.id = decodeURLEncodedBase64(cred.id);
} }
try {
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({
publicKey: options.publicKey publicKey: options.publicKey
}); });
try {
await verifyAssertion(credential); await verifyAssertion(credential);
} catch (err) { } catch (err) {
if (!options.publicKey.extensions?.appid) { if (!options.publicKey.extensions?.appid) {
@ -36,10 +34,10 @@ export async function initUserAuthWebAuthn() {
return; return;
} }
delete options.publicKey.extensions.appid; delete options.publicKey.extensions.appid;
try {
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({
publicKey: options.publicKey publicKey: options.publicKey
}); });
try {
await verifyAssertion(credential); await verifyAssertion(credential);
} catch (err) { } catch (err) {
webAuthnError('general', err.message); webAuthnError('general', err.message);
@ -137,15 +135,11 @@ function webAuthnError(errorType, message) {
function detectWebAuthnSupport() { function detectWebAuthnSupport() {
if (!window.isSecureContext) { if (!window.isSecureContext) {
document.getElementById('register-button').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') {
document.getElementById('register-button').disabled = true;
document.getElementById('login-button').disabled = true;
webAuthnError('browser'); webAuthnError('browser');
return false; return false;
} }
@ -158,15 +152,13 @@ export function initUserAuthWebAuthnRegister() {
if (!elRegister) { if (!elRegister) {
return; return;
} }
hideElem('#webauthn-error');
elRegister.addEventListener('click', (e) => {
e.preventDefault();
if (!detectWebAuthnSupport()) { if (!detectWebAuthnSupport()) {
elRegister.disabled = true;
return; return;
} }
webAuthnRegisterRequest(); elRegister.addEventListener('click', async (e) => {
e.preventDefault();
await webAuthnRegisterRequest();
}); });
} }
@ -203,15 +195,12 @@ async function webAuthnRegisterRequest() {
} }
} }
let credential;
try { try {
credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
publicKey: options.publicKey publicKey: options.publicKey
}); });
await webauthnRegistered(credential);
} catch (err) { } catch (err) {
webAuthnError('unknown', err); webAuthnError('unknown', err);
return;
} }
webauthnRegistered(credential);
} }

View file

@ -133,8 +133,17 @@ test('toAbsoluteUrl', () => {
expect(() => toAbsoluteUrl('path')).toThrowError('unsupported'); expect(() => toAbsoluteUrl('path')).toThrowError('unsupported');
}); });
const uint8array = (s) => new TextEncoder().encode(s);
test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
expect(encodeURLEncodedBase64(decodeURLEncodedBase64('foo'))).toEqual('foo'); // No = padding expect(encodeURLEncodedBase64(uint8array('AA?'))).toEqual('QUE_'); // standard base64: "QUE/"
expect(encodeURLEncodedBase64(decodeURLEncodedBase64('a-minus'))).toEqual('a-minus'); expect(encodeURLEncodedBase64(uint8array('AA~'))).toEqual('QUF-'); // standard base64: "QUF+"
expect(encodeURLEncodedBase64(decodeURLEncodedBase64('_underscorc'))).toEqual('_underscorc');
expect(decodeURLEncodedBase64('QUE/')).toEqual(uint8array('AA?'));
expect(decodeURLEncodedBase64('QUF+')).toEqual(uint8array('AA~'));
expect(decodeURLEncodedBase64('QUE_')).toEqual(uint8array('AA?'));
expect(decodeURLEncodedBase64('QUF-')).toEqual(uint8array('AA~'));
expect(encodeURLEncodedBase64(uint8array('a'))).toEqual('YQ'); // standard base64: "YQ=="
expect(decodeURLEncodedBase64('YQ')).toEqual(uint8array('a'));
expect(decodeURLEncodedBase64('YQ==')).toEqual(uint8array('a'));
}); });