mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	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>  </details> * Error message when webauthn is unavailable <details>  </details>
This commit is contained in:
		| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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"}} | ||||||
|   | |||||||
| @@ -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 { | ||||||
|   | |||||||
| @@ -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); |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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')); | ||||||
| }); | }); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 wxiaoguang
					wxiaoguang