From fcfe39e78adc35fecb683845abf183803ab3f7c0 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Mon, 20 May 2024 20:25:46 +0300 Subject: [PATCH] password reset --- .env.example | 19 +++ README.md | 47 ++----- package-lock.json | 32 +++++ package.json | 2 + src/lib/components/ButtonRow.svelte | 19 +++ src/lib/components/LogoutButton.svelte | 7 +- src/lib/i18n/en/account.json | 8 ++ src/lib/server/challenge.ts | 8 +- src/lib/server/crypto-utils.ts | 6 +- src/lib/server/users/tokens.ts | 17 ++- src/routes/account/+page.server.ts | 3 +- src/routes/account/two-factor/+page.server.ts | 49 +++++++- src/routes/account/two-factor/+page.svelte | 85 +++++++------ src/routes/login/+page.server.ts | 12 +- src/routes/login/+page.svelte | 8 +- src/routes/login/password/+page.server.ts | 118 ++++++++++++++++++ src/routes/login/password/+page.svelte | 101 +++++++++++++++ src/routes/register/+page.server.ts | 12 +- 18 files changed, 462 insertions(+), 91 deletions(-) create mode 100644 .env.example create mode 100644 src/lib/components/ButtonRow.svelte create mode 100644 src/routes/login/password/+page.server.ts create mode 100644 src/routes/login/password/+page.svelte diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f3dbace --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 5ce6766..59dde4a 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/package-lock.json b/package-lock.json index f3f18db..6be304d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8cfd4bc..564349b 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/lib/components/ButtonRow.svelte b/src/lib/components/ButtonRow.svelte new file mode 100644 index 0000000..612f4c3 --- /dev/null +++ b/src/lib/components/ButtonRow.svelte @@ -0,0 +1,19 @@ +
+ +
+ + diff --git a/src/lib/components/LogoutButton.svelte b/src/lib/components/LogoutButton.svelte index d679617..e580ca7 100644 --- a/src/lib/components/LogoutButton.svelte +++ b/src/lib/components/LogoutButton.svelte @@ -1,3 +1,8 @@ + +
- +
diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json index ed8c56e..c0a09ce 100644 --- a/src/lib/i18n/en/account.json +++ b/src/lib/i18n/en/account.json @@ -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", diff --git a/src/lib/server/challenge.ts b/src/lib/server/challenge.ts index 3c8fb7f..29393fe 100644 --- a/src/lib/server/challenge.ts +++ b/src/lib/server/challenge.ts @@ -5,6 +5,7 @@ import { TimeOTP } from './users/totp'; export interface ChallengeBody { aud: string; + exp: number; data: T; } @@ -13,8 +14,11 @@ export class Challenge { challenge: TChallenge, recipient: string ): Promise { + // 5-minute expiration on all challenges. + const expiration = Date.now() + 5 * 60 * 1000; const body = >{ 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 = await CryptoUtils.decryptChallenge(challenge); - if (aud !== recipient) { + const { aud, data, exp }: ChallengeBody = await CryptoUtils.decryptChallenge(challenge); + if (exp < Date.now() || aud !== recipient) { throw new Error('Invalid challenge'); } diff --git a/src/lib/server/crypto-utils.ts b/src/lib/server/crypto-utils.ts index 3314ab7..31d1786 100644 --- a/src/lib/server/crypto-utils.ts +++ b/src/lib/server/crypto-utils.ts @@ -62,7 +62,11 @@ export class CryptoUtils { } static safeCompare(token: string, token2: string) { - return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2)); + try { + return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2)); + } catch { + return false; + } } static sha256hash(input: string) { diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts index 1abbfd2..0e6f530 100644 --- a/src/lib/server/users/tokens.ts +++ b/src/lib/server/users/tokens.ts @@ -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; + } } diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 749e16c..5e5fe1e 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -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' diff --git a/src/routes/account/two-factor/+page.server.ts b/src/routes/account/two-factor/+page.server.ts index 1ea3816..32f454e 100644 --- a/src/routes/account/two-factor/+page.server.ts +++ b/src/routes/account/two-factor/+page.server.ts @@ -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({ 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(['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( + { secret: secretHash }, + currentUser.uuid + ); + return { challenge: issued, action: 'deactivate' }; + } + + const decoded = await CryptoUtils.decryptChallenge>(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' }; } }; diff --git a/src/routes/account/two-factor/+page.svelte b/src/routes/account/two-factor/+page.svelte index 8b5a717..3272439 100644 --- a/src/routes/account/two-factor/+page.svelte +++ b/src/routes/account/two-factor/+page.svelte @@ -1,6 +1,8 @@ + + {$t('account.otp.title')} - {PUBLIC_SITE_NAME} + +

{PUBLIC_SITE_NAME}

{$t('account.otp.title')}

- {#if form?.success} - {$t('account.otp.activated')} -
- {$t('account.otp.return')} - {:else if form?.challenge} -
- - -
- {form?.uri} -
+ + {#if form?.success} + {$t(`account.otp.${form?.action || 'activate'}d`)} + {$t('account.otp.return')} + {:else if form?.challenge} + + -
- - - - - -
-
- {:else if form?.invalid} - {$t('account.errors.otpFailed')} -
-
- -
- {:else if data?.otpEnabled} - {$t('account.otp.enabled')} -
-
- -
- {:else} - {$t('account.otp.disabled')} -
-
- -
- {/if} + + {#if form?.action !== 'deactivate'} + +
+ {form?.uri} +
+
+ {/if} + + + + + + + + + + {:else if form?.invalid} + {$t('account.errors.otpFailed')} +
+ +
+ {:else if data?.otpEnabled} + {$t('account.otp.enabled')} +
+ +
+ {:else} + {$t('account.otp.disabled')} +
+ +
+ {/if} +
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 2fb348b..91003b7 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -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) diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 8000bc0..609e8b3 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -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 @@ /> {/if} - - + + + + + diff --git a/src/routes/login/password/+page.server.ts b/src/routes/login/password/+page.server.ts new file mode 100644 index 0000000..6dc3bdf --- /dev/null +++ b/src/routes/login/password/+page.server.ts @@ -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( + ['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 }; +}; diff --git a/src/routes/login/password/+page.svelte b/src/routes/login/password/+page.svelte new file mode 100644 index 0000000..c9e74a4 --- /dev/null +++ b/src/routes/login/password/+page.svelte @@ -0,0 +1,101 @@ + + + + {$t(`account.${pageTitle}`)} - {PUBLIC_SITE_NAME} + + + +

{PUBLIC_SITE_NAME}

+

{$t(`account.${pageTitle}`)}

+ + {#if form?.success} + {$t( + `account.${form.success === 'set' ? 'passwordSetSuccess' : 'passwordResetSucces'}` + )} + {$t('account.login.title')} + {:else} +
+ + {#if errors.length} + {#each errors as error} + {$t(`account.errors.${error}`)} + {/each} + {/if} + + {#if data.setter} + + + + {$t('account.passwordHint')} + + + + + + {:else} + + + + + {/if} + + +
+ {/if} +
+
diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 54b7bc2..10ac81e 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -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, '/');