diff options
| author | Joe Carstairs <65492573+Sycamost@users.noreply.github.com> | 2023-12-21 21:22:47 +0000 |
|---|---|---|
| committer | Joe Carstairs <jcarstairs@scottlogic.com> | 2024-01-29 10:46:46 +0000 |
| commit | 2d0634cdc3d00b3e55cf773ff03c7dc841112d36 (patch) | |
| tree | 424f0b9ce2907e3d58336a2b25233bf0f9bead9d /src | |
| parent | f44b2a82b74337c6424c576fdbf4c1376166e553 (diff) | |
33 / Users can sign up and log in with WebAuthn (#39)
* Installs @vercel/postgres
* Installs @simplewebauthn/server
* Installs @simplewebauthn/browser
* Git-ignores all files starting with .env
* Reorganises folders in API
* Defines User type
* Defines Subscription type
* Defines Authenticator type
* Sets up table definition file
* Can get user from database
* Can add user to database
* Can get user's current challenge
* Can set user's current challenge
* Can get user's authenticators from database
* Can get authenticator by ID from database
* Can add user authenticator to database
* Can update authenticator in database
* Defines Relying Party information
* Can generate registration options
* Can verify registration response
* Defines registration API endpoint
* Defines user API endpoint
* Reorganises API functions on frontend
* Can access authentication API functions on frontend
* Can generate authentication options
* Can verify authentication response
* Documents the registration flow
* Fix dev_csso
* Form styling
* WIP adds sign up page
Diffstat (limited to 'src')
| -rw-r--r-- | src/i18n/translations/pages/signUp.ts | 23 | ||||
| -rw-r--r-- | src/lib/api.ts | 69 | ||||
| -rw-r--r-- | src/pages/[locale]/sign-up/index.astro | 175 | ||||
| -rw-r--r-- | src/pages/[locale]/sign-up/success.astro | 42 | ||||
| -rw-r--r-- | src/scripts/wc/paypal-product-buttons.ts | 25 |
5 files changed, 314 insertions, 20 deletions
diff --git a/src/i18n/translations/pages/signUp.ts b/src/i18n/translations/pages/signUp.ts new file mode 100644 index 0000000..15a9dcf --- /dev/null +++ b/src/i18n/translations/pages/signUp.ts @@ -0,0 +1,23 @@ +import type { TranslationsDictionary } from '$types/TranslationsDictionary'; + +const tPage = { + title: { + sco: () => 'Sign up', + 'en-GB': () => 'Sign up', + }, + 'label-email': { + sco: () => 'Email', + 'en-GB': () => 'Email', + }, + 'label-display-name': { + sco: () => 'Name', + 'en-GB': () => 'Name', + }, + submit: { + sco: () => 'Sign up', + 'en-GB': () => 'Sign up', + }, +}; + +type Raw = typeof tPage; +export default tPage as TranslationsDictionary<Raw>; diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..35e3426 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,69 @@ +import type { + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/typescript-types'; + +const backendUrl = '/api'; + +export async function captureOrder(orderId: string) { + const body = JSON.stringify({ + id: orderId, + isApproved: true, + }); + + const headers = { + 'Content-Type': 'application/json', + }; + + return await fetch(`${backendUrl}/payments/order`, { + method: 'PATCH', + body, + headers, + }); +} + +export async function createOrder(params: CreateOrderParams) { + return await fetch(`${backendUrl}/payments/order`, { + method: 'POST', + body: JSON.stringify(params), + headers: { 'Content-Type': 'application/json' }, + }); +} + +type CreateOrderParams = { + productDescription: string; + shortDescription: string; + totalPrice: string; +}; + +export async function getRegistrationOptions(params: GetRegistrationOptionsParams) { + const queryString = new URLSearchParams(params).toString(); + // If last param ends with, e.g., `.com` as in an email address, the `.com` + // will be interpreted as a file extension, and this will break everything. + // Hack to avoid this bug. + return await fetch(`${backendUrl}/auth/registration?${queryString}&hack=toavoidbug`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); +} + +type GetRegistrationOptionsParams = { + userId: string; + displayName: string; +}; + +export type GetRegistrationOptionsResponseBody = PublicKeyCredentialCreationOptionsJSON; + +export async function verifyRegistrationResponse(params: VerifyRegistrationResponseParams) { + return await fetch(`${backendUrl}/auth/registration`, { + method: 'POST', + body: JSON.stringify(params), + headers: { 'Content-Type': 'application/json' }, + }); +} + +type VerifyRegistrationResponseParams = { + userId: string; + displayName: string; + registrationResponse: RegistrationResponseJSON; +}; diff --git a/src/pages/[locale]/sign-up/index.astro b/src/pages/[locale]/sign-up/index.astro new file mode 100644 index 0000000..204dbcc --- /dev/null +++ b/src/pages/[locale]/sign-up/index.astro @@ -0,0 +1,175 @@ +--- +import translate from '$i18n/translate'; +import tPage from '$i18n/translations/pages/signUp'; +import Page from '$layouts/Page.astro'; +import { getLocaleFromPathOrThrow } from '$lib/getLocaleFromPath'; +import localeStaticPaths from '$lib/localeStaticPaths'; + +export async function getStaticPaths() { + return localeStaticPaths; +} + +const locale = getLocaleFromPathOrThrow(Astro.url.pathname); +const t = translate(locale); +--- + +<Page title={t(tPage, { key: 'title' })}> + <section> + <h1>Sign up</h1> + + <form id="sign-up-form"> + <label for="email">{t(tPage, { key: 'label-email' })}</label> + <input type="email" required id="email" /> + + <label for="display-name">{t(tPage, { key: 'label-display-name' })}</label> + <input type="text" required id="display-name" /> + + <button type="submit">{t(tPage, { key: 'submit' })}</button> + </form> + </section> +</Page> + +<script> + import type { GetRegistrationOptionsResponseBody } from '$lib/api'; + import type Locale from '$types/Locale'; + + import * as api from '$lib/api'; + import { startRegistration } from '@simplewebauthn/browser'; + + const form = document.getElementById('sign-up-form') as HTMLFormElement; + const displayNameInput = document.getElementById('display-name') as HTMLInputElement; + const emailInput = document.getElementById('email') as HTMLInputElement; + const locale = document.getElementsByTagName('html')[0].lang as Locale; + + const successPageUrl = `${locale}/sign-up/success`; + + form.addEventListener('submit', async (event: SubmitEvent) => { + // Prevent default behaviour of submit button (which includes refreshing the page) + event.preventDefault(); + + let registrationOptions; + try { + registrationOptions = await getRegistrationOptions(); + } catch (err) { + reportError('Failed to get registration options.', err); + return; + } + + let registrationResponse; + try { + registrationResponse = await startRegistration(registrationOptions); + } catch (err) { + reportError('Failed to start registration on the client.', err); + return; + } + + let isSuccessfullyRegistered; + try { + isSuccessfullyRegistered = await verifyRegistrationResponse({ + userId: registrationOptions.user.id, + displayName: registrationOptions.user.displayName, + registrationResponse, + }); + } catch (err) { + reportError('Failed to verify the registration response.', err); + return; + } + + if (!isSuccessfullyRegistered) { + reportError( + ` + Failed to verify your registration. Have another go, or contact us at + lallans@hotmail.co.uk if the issue persists. + `, + null + ); + } + + const url = new URL(successPageUrl); + url.searchParams.set('displayName', registrationOptions.user.displayName); + url.searchParams.set('username', registrationOptions.user.name); + + console.info(`Redirecting to the success page ${url}...`); + window.location.href = url.toString(); + return; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function reportError(context: string, err: any) { + console.error(context, err); + window.alert(` + Encountered an unexpected error. Please try again, or, if the error + persists, contact lallans@hotmail.co.uk. + + ${context} + + ${err?.toString() ?? ''} + `); + } + + async function getRegistrationOptions(): Promise<GetRegistrationOptionsResponseBody> { + const response = await api.getRegistrationOptions({ + displayName: displayNameInput.value, + userId: emailInput.value, + }); + + if (response.status === 409 /* CONFLICT: user ID already exists in database */) { + // TODO: go to a nice localised error page instead of showing an alert + window.alert('Username already exists! Log in or choose a different username.'); + + // TODO: obviously this is stupid. Do something non-stupid instead + return null as unknown as GetRegistrationOptionsResponseBody; + } + + if (!response.ok) { + return Promise.reject(` + Tried to get registration options on API, but got an unsuccessful + response: ${response.status} ${response.statusText} ${await response.text()} + `); + } + + const body = await response.json(); + if (!body) { + return Promise.reject('Tried to get registration options on API, but got an empty reponse.'); + } + + return body as GetRegistrationOptionsResponseBody; + } + + async function verifyRegistrationResponse( + params: Parameters<typeof api.verifyRegistrationResponse>[0] + ): Promise<boolean> { + const response = await api.verifyRegistrationResponse(params); + + if (response.status === 409 /* CONFLICT: user ID already exists*/) { + // TODO: go to a nice localised error page instead of showing an alert + window.alert( + 'Username or ID already exists! Try logging in or choosing a different username.' + ); + + // TODO: obviously this is stupid. Do something non-stupid instead + return null as unknown as boolean; + } + + if (!response.ok) { + return Promise.reject(` + Tried to verify registration response on API, but got an unsuccessful + response: ${response.status} ${response.statusText} ${await response.text()} + `); + } + + const body = await response.json(); + if (body === true) { + return true; + } + + if (body === false) { + return false; + } + + return Promise.reject(` + Tried to verify registration response on API. Expected response to be + 'true' or 'false', but got: ${body} + `); + } +</script> diff --git a/src/pages/[locale]/sign-up/success.astro b/src/pages/[locale]/sign-up/success.astro new file mode 100644 index 0000000..b23993e --- /dev/null +++ b/src/pages/[locale]/sign-up/success.astro @@ -0,0 +1,42 @@ +--- +import localeStaticPaths from '$lib/localeStaticPaths'; + +export async function getStaticPaths() { + return localeStaticPaths; +} +--- + +<!-- TODO: localise this --> +<h1>Successfully signed up</h1> + +<script> + const errorMsg = new HTMLParagraphElement(); + errorMsg.append('Something went wrong. Please send an error report to '); + const lallansEmailAnchor = new HTMLAnchorElement(); + lallansEmailAnchor.href = 'mailto:lallans@hotmail.co.uk'; + lallansEmailAnchor.textContent = 'lallans@hotmail.co.uk'; + errorMsg.append(lallansEmailAnchor); + errorMsg.append('.'); + + const heading = document.getElementsByTagName('h1')[0]; + + const url = new URL(window.location.href); + + const displayName = url.searchParams.get('displayName'); + const username = url.searchParams.get('username'); + if (!displayName || !username) { + heading.after(errorMsg); + } + + const successMsg = [new HTMLParagraphElement(), new HTMLParagraphElement()]; + // TODO: localise this + successMsg[0].textContent = ` + Congratulations, ${displayName}! You have successfully registered a new + account with us. + `; + successMsg[1].textContent = ` + Your new unique username is ${username}. Use this next time you want to log in. + `; + heading.after(...successMsg); +</script> +<!-- TODO: 'go back to where you were' link --> diff --git a/src/scripts/wc/paypal-product-buttons.ts b/src/scripts/wc/paypal-product-buttons.ts index ca06524..b2e7e35 100644 --- a/src/scripts/wc/paypal-product-buttons.ts +++ b/src/scripts/wc/paypal-product-buttons.ts @@ -1,6 +1,7 @@ -import { type PayPalNamespace, loadScript } from '@paypal/paypal-js'; +import type { PayPalNamespace } from '@paypal/paypal-js'; -const backendUrl = '/api'; +import { loadScript } from '@paypal/paypal-js'; +import * as api from '$lib/api'; class PaypalProductButtons extends HTMLElement { constructor() { @@ -67,18 +68,10 @@ class PaypalProductButtons extends HTMLElement { return constructButtons({ // This function is called to set up the transaction details without actually carrying it out async createOrder() { - const body = JSON.stringify({ - productDescription, - shortDescription, - totalPrice, - }); - const headers = { - 'Content-Type': 'application/json', - }; let response: Response; try { - response = await fetch(`${backendUrl}/order`, { method: 'POST', body, headers }); + response = await api.createOrder({ productDescription, shortDescription, totalPrice }); } catch (err) { throw new Error(`Failed to create order. ${err}`); } @@ -100,17 +93,9 @@ class PaypalProductButtons extends HTMLElement { // This function is called after payment is confirmed async onApprove(data) { - const body = JSON.stringify({ - id: data.orderID, - isApproved: true, - }); - const headers = { - 'Content-Type': 'application/json', - }; - let response: Response; try { - response = await fetch(`${backendUrl}/order`, { method: 'PATCH', body, headers }); + response = await api.captureOrder(data.orderID); } catch (err) { throw new Error(`Failed to capture order. ${err}`); } |
