diff options
Diffstat (limited to 'web_src/js/features/user-auth-webauthn.ts')
-rw-r--r-- | web_src/js/features/user-auth-webauthn.ts | 257 |
1 files changed, 257 insertions, 0 deletions
diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts new file mode 100644 index 0000000000..7b7508c4f1 --- /dev/null +++ b/web_src/js/features/user-auth-webauthn.ts @@ -0,0 +1,257 @@ +import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts'; +import {showElem} from '../utils/dom.ts'; +import {GET, POST} from '../modules/fetch.ts'; + +const {appSubUrl} = window.config; + +export async function initUserAuthWebAuthn() { + if (!detectWebAuthnSupport()) { + return; + } + + const elSignInPasskeyBtn = document.querySelector('.signin-passkey'); + if (elSignInPasskeyBtn) { + elSignInPasskeyBtn.addEventListener('click', loginPasskey); + } + + const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); + if (elPrompt) { + login2FA(); + } +} + +async function loginPasskey() { + const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`); + if (!res.ok) { + 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); + } + + try { + const credential = await navigator.credentials.get({ + publicKey: options.publicKey, + }); + + // Move data into Arrays in case it is super long + const authData = new Uint8Array(credential.response.authenticatorData); + const clientDataJSON = new Uint8Array(credential.response.clientDataJSON); + const rawId = new Uint8Array(credential.rawId); + const sig = new Uint8Array(credential.response.signature); + const userHandle = new Uint8Array(credential.response.userHandle); + + const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, { + data: { + id: credential.id, + rawId: encodeURLEncodedBase64(rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + authenticatorData: encodeURLEncodedBase64(authData), + clientDataJSON: encodeURLEncodedBase64(clientDataJSON), + signature: encodeURLEncodedBase64(sig), + userHandle: encodeURLEncodedBase64(userHandle), + }, + }, + }); + if (res.status === 500) { + webAuthnError('unknown'); + return; + } else if (!res.ok) { + webAuthnError('unable-to-process'); + return; + } + const reply = await res.json(); + + window.location.href = reply?.redirect ?? `${appSubUrl}/`; + } catch (err) { + webAuthnError('general', err.message); + } +} + +async function login2FA() { + const res = await GET(`${appSubUrl}/user/webauthn/assertion`); + if (!res.ok) { + 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); + } + + try { + const credential = await navigator.credentials.get({ + publicKey: options.publicKey, + }); + await verifyAssertion(credential); + } catch (err) { + if (!options.publicKey.extensions?.appid) { + webAuthnError('general', err.message); + return; + } + delete options.publicKey.extensions.appid; + try { + const credential = await navigator.credentials.get({ + publicKey: options.publicKey, + }); + await verifyAssertion(credential); + } catch (err) { + webAuthnError('general', err.message); + } + } +} + +async function verifyAssertion(assertedCredential) { + // Move data into Arrays in case 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); + + const res = await POST(`${appSubUrl}/user/webauthn/assertion`, { + data: { + id: assertedCredential.id, + rawId: encodeURLEncodedBase64(rawId), + type: assertedCredential.type, + clientExtensionResults: assertedCredential.getClientExtensionResults(), + response: { + authenticatorData: encodeURLEncodedBase64(authData), + clientDataJSON: encodeURLEncodedBase64(clientDataJSON), + signature: encodeURLEncodedBase64(sig), + userHandle: encodeURLEncodedBase64(userHandle), + }, + }, + }); + if (res.status === 500) { + webAuthnError('unknown'); + return; + } else if (!res.ok) { + webAuthnError('unable-to-process'); + return; + } + const reply = await res.json(); + + window.location.href = reply?.redirect ?? `${appSubUrl}/`; +} + +async function webauthnRegistered(newCredential) { + const attestationObject = new Uint8Array(newCredential.response.attestationObject); + const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); + const rawId = new Uint8Array(newCredential.rawId); + + const res = await POST(`${appSubUrl}/user/settings/security/webauthn/register`, { + data: { + id: newCredential.id, + rawId: encodeURLEncodedBase64(rawId), + type: newCredential.type, + response: { + attestationObject: encodeURLEncodedBase64(attestationObject), + clientDataJSON: encodeURLEncodedBase64(clientDataJSON), + }, + }, + }); + + if (res.status === 409) { + webAuthnError('duplicated'); + return; + } else if (res.status !== 201) { + webAuthnError('unknown'); + return; + } + + window.location.reload(); +} + +function webAuthnError(errorType, message) { + const elErrorMsg = document.querySelector(`#webauthn-error-msg`); + + if (errorType === 'general') { + elErrorMsg.textContent = message || 'unknown error'; + } else { + const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`); + if (elTypedError) { + elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`; + } else { + elErrorMsg.textContent = `unknown error type: ${errorType}${message ? ` ${message}` : ''}`; + } + } + + showElem('#webauthn-error'); +} + +function detectWebAuthnSupport() { + if (!window.isSecureContext) { + webAuthnError('insecure'); + return false; + } + + if (typeof window.PublicKeyCredential !== 'function') { + webAuthnError('browser'); + return false; + } + + return true; +} + +export function initUserAuthWebAuthnRegister() { + const elRegister = document.querySelector('#register-webauthn'); + if (!elRegister) { + return; + } + if (!detectWebAuthnSupport()) { + elRegister.disabled = true; + return; + } + elRegister.addEventListener('click', async (e) => { + e.preventDefault(); + await webAuthnRegisterRequest(); + }); +} + +async function webAuthnRegisterRequest() { + const elNickname = document.querySelector('#nickname'); + + const formData = new FormData(); + formData.append('name', elNickname.value); + + const res = await POST(`${appSubUrl}/user/settings/security/webauthn/request_register`, { + data: formData, + }); + + if (res.status === 409) { + webAuthnError('duplicated'); + return; + } else if (!res.ok) { + 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); + } + } + + try { + const credential = await navigator.credentials.create({ + publicKey: options.publicKey, + }); + await webauthnRegistered(credential); + } catch (err) { + webAuthnError('unknown', err); + } +} |