password reset

This commit is contained in:
Evert Prants 2024-05-20 20:25:46 +03:00
parent 1cdd511ac9
commit fcfe39e78a
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
18 changed files with 462 additions and 91 deletions

19
.env.example Normal file
View File

@ -0,0 +1,19 @@
PUBLIC_URL=http://localhost:5173
PUBLIC_SITE_NAME=Amanita SSO
DATABASE_HOST=localhost
DATABASE_DB=icyauth
DATABASE_USER=icyauth
DATABASE_PASS=icyauth
SESSION_SECRET=32 char key
CHALLENGE_SECRET=64 char key
JWT_ALGORITHM=RS256
JWT_EXPIRATION=8h
JWT_ISSUER=http://localhost:5173
EMAIL_ENABLED=true
EMAIL_FROM=no-reply@icynet.eu
EMAIL_SMTP_HOST=mail.icynet.eu
EMAIL_SMTP_PORT=587
EMAIL_SMTP_SECURE=false
EMAIL_SMTP_USER=
EMAIL_SMTP_PASS=
REGISTRATIONS=true

View File

@ -1,38 +1,15 @@
# create-svelte
# sso-core
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
This is a SvelteKit-powered authentication service.
## Creating a project
## Set up
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
1. Install dependenices - `npm install`.
2. Configure the environment - `cp .env.example .env`.
3. Generate secrets and stuff:
1. Session secret - `node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))'`.
2. Challenge secret - `node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'`.
3. Generate JWT keys in the `private` directory - `openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048`.
4. Also make the public key - `openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem`.
4. Build the application - `npm run build`.
5. Run the application - `node -r dotenv/config build`.

32
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@sveltejs/adapter-node": "^5.0.1",
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.10",
"jose": "^5.3.0",
"mime-types": "^2.1.35",
@ -20,6 +21,7 @@
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
"sveltekit-i18n": "^2.4.2",
"sveltekit-rate-limiter": "^0.5.1",
"uuid": "^9.0.1"
},
"devDependencies": {
@ -947,6 +949,14 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
"node_modules/@isaacs/ttlcache": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
"integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@ -2273,6 +2283,17 @@
"node": ">=6.0.0"
}
},
"node_modules/dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dreamopt": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz",
@ -5030,6 +5051,17 @@
"svelte": ">=3.49.0"
}
},
"node_modules/sveltekit-rate-limiter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/sveltekit-rate-limiter/-/sveltekit-rate-limiter-0.5.1.tgz",
"integrity": "sha512-Q2C7mT9PdoL6v3VXgxngyXiEg2i3Dp0iVjVvKi722lroTM7oHxAJsmj66607BiSw8mdQk1Me6nhE6uRXrkDVIg==",
"dependencies": {
"@isaacs/ttlcache": "^1.4.1"
},
"peerDependencies": {
"@sveltejs/kit": "1.x || 2.x"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",

View File

@ -41,6 +41,7 @@
"@sveltejs/adapter-node": "^5.0.1",
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.10",
"jose": "^5.3.0",
"mime-types": "^2.1.35",
@ -50,6 +51,7 @@
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
"sveltekit-i18n": "^2.4.2",
"sveltekit-rate-limiter": "^0.5.1",
"uuid": "^9.0.1"
}
}

View File

@ -0,0 +1,19 @@
<div class="button-row">
<slot />
</div>
<style>
.button-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.button-row > :global(*):not(:first-child)::before {
content: '·';
display: inline-block;
margin-right: 8px;
}
</style>

View File

@ -1,3 +1,8 @@
<script>
import { t } from '$lib/i18n';
import Button from './Button.svelte';
</script>
<form action="/account?/logout" method="POST">
<button type="submit" class="btn btn-link">Log out</button>
<Button type="submit" variant="link">{$t('account.logout')}</Button>
</form>

View File

@ -14,10 +14,16 @@
"currentPassword": "Current password",
"newPassword": "New password",
"repeatNewPassword": "Repeat new password",
"forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password",
"setNewPassword": "Set a new password",
"passwordHint": "At least 8 characters, a capital letter and a number required.",
"submit": "Submit",
"changeSuccess": "Account settings changed successfully!",
"activateSuccess": "Your account has been activated successfully! You may now log in.",
"passwordSetSuccess": "Your new password has been set successfully! You may now log in.",
"passwordResetSucces": "If there is an account with that email address, we have sent a password reset email to it.",
"logout": "Log out",
"avatar": {
"title": "Profile avatar",
"change": "Change avatar",
@ -51,6 +57,7 @@
"invalidEmail": "The email address is invalid.",
"passwordRequired": "The password is required.",
"passwordMismatch": "The passwords do not match!",
"passwordSame": "Your new password cannot be your current password.",
"invalidPassword": "The provided password is invalid.",
"invalidDisplayName": "The provided display name is invalid.",
"otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries.",
@ -63,6 +70,7 @@
"enabled": "Two-factor authentication is enabled",
"disabled": "Your account does not have two-factor authentication enabled.",
"activated": "Two-factor authentication has been activated successfully!",
"deactivated": "Two-factor authentication has been deactivated successfully.",
"scan": "Scan this QR code with the authenticator app of your choice",
"code": "Enter the code displayed on the authenticator app",
"return": "Return to account management",

View File

@ -5,6 +5,7 @@ import { TimeOTP } from './users/totp';
export interface ChallengeBody<T> {
aud: string;
exp: number;
data: T;
}
@ -13,8 +14,11 @@ export class Challenge {
challenge: TChallenge,
recipient: string
): Promise<string> {
// 5-minute expiration on all challenges.
const expiration = Date.now() + 5 * 60 * 1000;
const body = <ChallengeBody<TChallenge>>{
aud: recipient,
exp: expiration,
data: challenge
};
return CryptoUtils.encryptChallenge(body);
@ -26,8 +30,8 @@ export class Challenge {
code: string,
secret: string
) {
const { aud, data }: ChallengeBody<TRes> = await CryptoUtils.decryptChallenge(challenge);
if (aud !== recipient) {
const { aud, data, exp }: ChallengeBody<TRes> = await CryptoUtils.decryptChallenge(challenge);
if (exp < Date.now() || aud !== recipient) {
throw new Error('Invalid challenge');
}

View File

@ -62,7 +62,11 @@ export class CryptoUtils {
}
static safeCompare(token: string, token2: string) {
try {
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2));
} catch {
return false;
}
}
static sha256hash(input: string) {

View File

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { and, eq, gt, isNull, or } from 'drizzle-orm';
import { CryptoUtils } from '../crypto-utils';
import { db, userToken, type UserToken } from '../drizzle';
@ -25,4 +25,19 @@ export class UserTokens {
const removeBy = typeof token === 'string' ? token : token.token;
await db.delete(userToken).where(eq(userToken.token, removeBy));
}
static async getByToken(token: string, type: (typeof userToken.$inferSelect)['type']) {
const [returned] = await db
.select()
.from(userToken)
.where(
and(
eq(userToken.token, token),
eq(userToken.type, type),
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date()))
)
)
.limit(1);
return returned;
}
}

View File

@ -155,8 +155,7 @@ export const actions = {
}
const formData = Object.fromEntries(await request.formData());
if (!(formData.file as File).name || (formData.file as File).name === 'undefined') {
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
return fail(400, {
error: true,
message: 'You must provide a file to upload'

View File

@ -2,7 +2,7 @@ import { Challenge, type ChallengeBody } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils';
import type { User } from '$lib/server/drizzle';
import { Users } from '$lib/server/users';
import { UserTokens, Users } from '$lib/server/users';
import { TimeOTP } from '$lib/server/users/totp.js';
import { fail, redirect } from '@sveltejs/kit';
import * as QRCode from 'qrcode';
@ -12,6 +12,7 @@ interface ActivateChallenge {
}
interface ActivateRequest {
action: 'deactivate' | 'activate';
challenge: string;
otpCode?: string;
}
@ -21,7 +22,7 @@ const issueActivateChallenge = async (subject: User) => {
const challenge = await Challenge.issueChallenge<ActivateChallenge>({ secret }, subject.uuid);
const uri = TimeOTP.getUri(secret, subject.username);
const qr = await QRCode.toDataURL(uri);
return { challenge, qr, uri };
return { challenge, qr, uri, action: 'activate' };
};
export const actions = {
@ -43,25 +44,63 @@ export const actions = {
if (
!otpCode ||
decoded?.exp < Date.now() ||
decoded?.aud !== currentUser.uuid ||
!decoded?.data?.secret ||
!TimeOTP.validate(decoded.data.secret, otpCode)
) {
return fail(400, { invalid: true });
return fail(400, { invalid: true, action: 'activate' });
}
await TimeOTP.saveUserOtp(currentUser, decoded.data.secret);
// TODO: audit log
return { success: true };
return { success: true, action: 'activate' };
},
deactivate: async ({ locals }) => {
deactivate: async ({ request, locals }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(303, '/login');
}
const body = await request.formData();
const { challenge, otpCode } = Changesets.take<ActivateRequest>(['challenge', 'otpCode'], body);
const userOtp = await TimeOTP.getUserOtp(currentUser);
if (!userOtp) {
// Invalid request, no OTP activated...
return redirect(303, '/account');
}
const secretHash = CryptoUtils.sha256hash(userOtp.token).toString('hex');
if (!challenge) {
const issued = await Challenge.issueChallenge<ActivateChallenge>(
{ secret: secretHash },
currentUser.uuid
);
return { challenge: issued, action: 'deactivate' };
}
const decoded = await CryptoUtils.decryptChallenge<ChallengeBody<ActivateChallenge>>(challenge);
if (
!otpCode ||
decoded?.exp < Date.now() ||
decoded?.aud !== currentUser.uuid ||
decoded?.data?.secret !== secretHash ||
!TimeOTP.validate(userOtp.token, otpCode)
) {
return fail(400, { invalid: true, action: 'deactivate' });
}
await UserTokens.remove(userOtp);
// TODO: audit log
return { success: true, action: 'deactivate' };
}
};

View File

@ -1,6 +1,8 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte';
import MainContainer from '$lib/components/MainContainer.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
@ -12,47 +14,54 @@
export let form: ActionData;
</script>
<svelte:head>
<title>{$t('account.otp.title')} - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer>
<h1>{PUBLIC_SITE_NAME}</h1>
<h2>{$t('account.otp.title')}</h2>
<ColumnView>
{#if form?.success}
<Alert type="success">{$t('account.otp.activated')}</Alert>
<br />
<Alert type="success">{$t(`account.otp.${form?.action || 'activate'}d`)}</Alert>
<a href="/account">{$t('account.otp.return')}</a>
{:else if form?.challenge}
<form action="?/activate" method="POST">
<form action="?/{form?.action || 'activate'}" method="POST">
<FormWrapper>
<input value={form.challenge} type="hidden" name="challenge" />
{#if form?.action !== 'deactivate'}
<FormSection title={$t('account.otp.scan')}>
<div class="qr-wrapper">
<img src={form?.qr} alt={form?.uri} />
</div>
<input value={form.challenge} type="hidden" name="challenge" />
</FormSection>
{/if}
<FormControl>
<label for="form-otpCode">{$t('account.otp.code')}</label>
<input type="text" name="otpCode" id="form-otpCode" autocomplete="off" />
</FormControl>
<Button type="submit" variant="primary">{$t('account.submit')}</Button>
</FormWrapper>
</form>
{:else if form?.invalid}
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
<br />
<form action="?/activate" method="POST">
<form action="?/{form?.action || 'activate'}" method="POST">
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
</form>
{:else if data?.otpEnabled}
<Alert type="success">{$t('account.otp.enabled')}</Alert>
<br />
<form action="?/deactivate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
</form>
{:else}
<Alert type="default">{$t('account.otp.disabled')}</Alert>
<br />
<form action="?/activate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
</form>
{/if}
</ColumnView>
</MainContainer>

View File

@ -2,7 +2,8 @@ import { Challenge } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface LoginParams {
email: string;
@ -15,8 +16,15 @@ interface LoginChallenge {
email: string;
}
const limiter = new RateLimiter({
IP: [6, '45s']
});
export const actions = {
default: async ({ request, locals, url }) => {
default: async (event) => {
const { request, locals, url } = event;
if (await limiter.isLimited(event)) throw error(429);
// Redirect
const redirectUrl = url.searchParams.has('redirectTo')
? (url.searchParams.get('redirectTo') as string)

View File

@ -9,6 +9,7 @@
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
import ButtonRow from '$lib/components/ButtonRow.svelte';
export let data: PageData;
export let form: ActionData;
@ -61,8 +62,11 @@
/>
</FormControl>
{/if}
<ButtonRow>
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
<div><a href="/register">{$t('account.register.title')}</a></div>
<div><a href="/login/password">{$t('account.forgotPassword')}</a></div>
</ButtonRow>
</FormWrapper>
</form>

View File

@ -0,0 +1,118 @@
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { UserTokens } from '$lib/server/users/tokens.js';
import { emailRegex, passwordRegex } from '$lib/validators.js';
import { error, redirect } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface PasswordRequest {
newPassword: string;
repeatPassword: string;
}
const failDelay = () =>
new Promise((resolve) => setTimeout(resolve, 2000 + (Math.random() * 2000 - 1000)));
const limiter = new RateLimiter({
IP: [3, '45s']
});
export const actions = {
sendEmail: async (event) => {
const { locals, request } = event;
if (await limiter.isLimited(event)) throw error(429);
if (locals.session.data?.user) {
return redirect(303, '/');
}
const body = await request.formData();
const { email } = Changesets.take<{ email: string }>(['email'], body);
if (!email || !emailRegex.test(email)) {
return { errors: ['invalidEmail'] };
}
const user = await Users.getByLogin(email);
if (!user || user.activated === 0) {
await failDelay();
return { success: true };
}
try {
await Users.sendPasswordEmail(user);
} catch {
// Ignore errors
await failDelay();
}
return { success: 'sent' };
},
setPassword: async ({ locals, request, url }) => {
if (locals.session.data?.user) {
return redirect(303, '/');
}
const token = url.searchParams.get('token');
if (!token) {
return {
errors: ['invalidPasswordToken']
};
}
const exists = await UserTokens.getByToken(token, 'password');
if (!exists?.userId) {
return {
errors: ['invalidPasswordToken']
};
}
const user = await Users.getById(exists.userId as number);
if (!user?.id || user.activated === 0) {
return {
errors: ['invalidPasswordToken']
};
}
const body = await request.formData();
const { newPassword, repeatPassword } = Changesets.take<PasswordRequest>(
['newPassword', 'repeatPassword'],
body
);
if (!newPassword || !passwordRegex.test(newPassword)) {
return { errors: ['invalidPassword'] };
}
if (newPassword !== repeatPassword) {
return { errors: ['passwordMismatch'] };
}
if (await Users.validatePassword(user, newPassword)) {
return { errors: ['passwordSame'] };
}
const hashed = await Users.hashPassword(newPassword);
await Users.update(user, { password: hashed });
await UserTokens.remove(exists);
return { success: 'set' };
}
};
export const load = async ({ locals, url }) => {
if (locals.session.data?.user) {
return redirect(301, '/');
}
const token = url.searchParams.get('token');
if (token) {
const exists = await UserTokens.getByToken(token, 'password');
if (!exists) {
return redirect(301, '/login/password');
}
return { setter: true };
}
return { setter: false };
};

View File

@ -0,0 +1,101 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte';
import SideContainer from '$lib/components/SideContainer.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
import type { ActionData, PageData, SubmitFunction } from './$types';
export let data: PageData;
export let form: ActionData;
let internalErrors: string[] = [];
let submitted = false;
$: errors = [...internalErrors, ...(form?.errors?.length ? form.errors : [])];
$: actionUrl = data.setter
? `?/setPassword&token=${$page.url.searchParams.get('token')}`
: '?/sendEmail';
$: pageTitle = data.setter ? 'setNewPassword' : 'resetPassword';
const enhanceFn: SubmitFunction = ({ formData, cancel }) => {
internalErrors.length = 0;
const pwd = formData.get('newPassword') as string;
const repeat = formData.get('repeatPassword') as string;
if (pwd && pwd !== repeat) {
internalErrors.push('passwordMismatch');
return cancel();
}
submitted = true;
return async ({ update }) => {
await update();
submitted = false;
};
};
</script>
<svelte:head>
<title>{$t(`account.${pageTitle}`)} - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<SideContainer>
<h1>{PUBLIC_SITE_NAME}</h1>
<h2>{$t(`account.${pageTitle}`)}</h2>
<ColumnView>
{#if form?.success}
<Alert type="success"
>{$t(
`account.${form.success === 'set' ? 'passwordSetSuccess' : 'passwordResetSucces'}`
)}</Alert
>
<a href="/login">{$t('account.login.title')}</a>
{:else}
<form action={actionUrl} method="POST" use:enhance={enhanceFn}>
<FormWrapper>
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
{#if data.setter}
<FormControl>
<label for="password-newPassword">{$t('account.newPassword')}</label>
<input
type="password"
name="newPassword"
id="password-newPassword"
autocomplete="new-password"
/>
<span>{$t('account.passwordHint')}</span>
</FormControl>
<FormControl>
<label for="password-repeatPassword">{$t('account.repeatPassword')}</label>
<input
type="password"
name="repeatPassword"
id="password-repeatPassword"
autocomplete="new-password"
/>
</FormControl>
{:else}
<FormControl>
<label for="password-email">{$t('account.email')}</label>
<input type="email" name="email" id="password-email" autocomplete="email" />
</FormControl>
{/if}
<Button type="submit" variant="primary" disabled={submitted}
>{$t('account.submit')}</Button
>
</FormWrapper>
</form>
{/if}
</ColumnView>
</SideContainer>

View File

@ -2,7 +2,8 @@ import { REGISTRATIONS } from '$env/static/private';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
import { fail, redirect } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface RegisterData {
username: string;
@ -13,8 +14,15 @@ interface RegisterData {
const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password'];
const limiter = new RateLimiter({
IP: [6, 'm']
});
export const actions = {
default: async ({ request, locals }) => {
default: async (event) => {
const { request, locals } = event;
if (await limiter.isLimited(event)) throw error(429);
// Logged in users cannot make more accounts
if (locals.session.data?.user || REGISTRATIONS === 'false') {
return redirect(303, '/');