summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoe Carstairs <65492573+Sycamost@users.noreply.github.com>2023-12-21 21:22:47 +0000
committerJoe Carstairs <jcarstairs@scottlogic.com>2024-01-29 10:46:46 +0000
commit2d0634cdc3d00b3e55cf773ff03c7dc841112d36 (patch)
tree424f0b9ce2907e3d58336a2b25233bf0f9bead9d /src
parentf44b2a82b74337c6424c576fdbf4c1376166e553 (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.ts23
-rw-r--r--src/lib/api.ts69
-rw-r--r--src/pages/[locale]/sign-up/index.astro175
-rw-r--r--src/pages/[locale]/sign-up/success.astro42
-rw-r--r--src/scripts/wc/paypal-product-buttons.ts25
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}`);
}