diff --git a/.env.example b/.env.example index f3dbace..e654b0c 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,5 @@ EMAIL_SMTP_SECURE=false EMAIL_SMTP_USER= EMAIL_SMTP_PASS= REGISTRATIONS=true +ADDRESS_HEADER=X-Forwarded-For +XFF_DEPTH=1 diff --git a/src/lib/components/avatar/AvatarCard.svelte b/src/lib/components/avatar/AvatarCard.svelte index 8376de1..e0f0b3e 100644 --- a/src/lib/components/avatar/AvatarCard.svelte +++ b/src/lib/components/avatar/AvatarCard.svelte @@ -19,6 +19,10 @@ &.with-actions { gap: 16px; + + & .actions-wrapper { + width: 100%; + } } } diff --git a/src/lib/components/ButtonRow.svelte b/src/lib/components/container/ButtonRow.svelte similarity index 100% rename from src/lib/components/ButtonRow.svelte rename to src/lib/components/container/ButtonRow.svelte diff --git a/src/lib/components/ColumnView.svelte b/src/lib/components/container/ColumnView.svelte similarity index 100% rename from src/lib/components/ColumnView.svelte rename to src/lib/components/container/ColumnView.svelte diff --git a/src/lib/components/MainContainer.svelte b/src/lib/components/container/MainContainer.svelte similarity index 100% rename from src/lib/components/MainContainer.svelte rename to src/lib/components/container/MainContainer.svelte diff --git a/src/lib/components/SideContainer.svelte b/src/lib/components/container/SideContainer.svelte similarity index 100% rename from src/lib/components/SideContainer.svelte rename to src/lib/components/container/SideContainer.svelte diff --git a/src/lib/components/SplitView.svelte b/src/lib/components/container/SplitView.svelte similarity index 100% rename from src/lib/components/SplitView.svelte rename to src/lib/components/container/SplitView.svelte diff --git a/src/lib/components/container/TitleRow.svelte b/src/lib/components/container/TitleRow.svelte new file mode 100644 index 0000000..360c74d --- /dev/null +++ b/src/lib/components/container/TitleRow.svelte @@ -0,0 +1,16 @@ +
+ + +
+ + diff --git a/src/lib/components/form/FormErrors.svelte b/src/lib/components/form/FormErrors.svelte new file mode 100644 index 0000000..71ac7a1 --- /dev/null +++ b/src/lib/components/form/FormErrors.svelte @@ -0,0 +1,13 @@ + + +{#if errors.length} + {#each errors as error} + {$t(`${prefix}.${error}`)} + {/each} +{/if} diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json index c0a09ce..1eb39ac 100644 --- a/src/lib/i18n/en/account.json +++ b/src/lib/i18n/en/account.json @@ -1,5 +1,6 @@ { "title": "Manage your account", + "altTitle": "Account management", "username": "Username", "usernameHint": "Only the English alphabet, numbers and _-. are allowed.", "displayName": "Display Name", @@ -8,6 +9,7 @@ "currentEmail": "Current email address", "email": "Email address", "newEmail": "New email address", + "emailHint": "Hint", "password": "Password", "repeatPassword": "Repeat password", "changePassword": "Change password", @@ -67,7 +69,7 @@ }, "otp": { "title": "Two-factor authentication", - "enabled": "Two-factor authentication is enabled", + "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.", @@ -77,5 +79,13 @@ "retry": "Try again", "activate": "Set up two factor authentication", "deactivate": "Deactivate" + }, + "authorizations": { + "title": "Authorized applications", + "description": "These applications are authorized automatically when requested, provided you have already consented to the information they require. You should revoke any applications you do not recognize.", + "warning": "By revoking the authorization, you may be prompted to authorize the application again in the future. This does NOT ensure that your information is deleted by any third-party applications in question. Please contact each application's owner individually if you wish to remove your information from their servers.", + "website": "Visit website", + "revoke": "Revoke", + "none": "You currently do not have any authorized applications." } } diff --git a/src/lib/server/oauth2/model/tokens.ts b/src/lib/server/oauth2/model/tokens.ts index 0cc2d63..269344d 100644 --- a/src/lib/server/oauth2/model/tokens.ts +++ b/src/lib/server/oauth2/model/tokens.ts @@ -100,7 +100,7 @@ export class OAuth2Tokens { } static async wipeExpiredTokens() { - await db.execute(sql`DELETE FROM o_auth2_token WHERE expires_at < NOW()`); + await db.execute(sql`DELETE FROM ${oauth2Token} WHERE ${oauth2Token.expires_at} < NOW()`); } static async remove(token: OAuth2Token) { diff --git a/src/lib/server/oauth2/model/user.ts b/src/lib/server/oauth2/model/user.ts index f6d9759..3125e99 100644 --- a/src/lib/server/oauth2/model/user.ts +++ b/src/lib/server/oauth2/model/user.ts @@ -2,6 +2,7 @@ import { db, oauth2Client, oauth2ClientAuthorization, + oauth2ClientUrl, type OAuth2Client, type User } from '$lib/server/drizzle'; @@ -36,7 +37,7 @@ export class OAuth2Users { })?.length; } - static async saveConsent(user: User, client: OAuth2Client, scopes: string | string[]) { + static async saveConsent(subject: User, client: OAuth2Client, scopes: string | string[]) { const normalized = OAuth2Clients.splitScope(scopes); const [existing] = await db .select() @@ -44,7 +45,7 @@ export class OAuth2Users { .where( and( eq(oauth2ClientAuthorization.clientId, client.id), - eq(oauth2ClientAuthorization.userId, user.id) + eq(oauth2ClientAuthorization.userId, subject.id) ) ) .limit(1); @@ -64,21 +65,23 @@ export class OAuth2Users { return; } - await db - .insert(oauth2ClientAuthorization) - .values({ userId: user.id, clientId: client.id, scope: OAuth2Clients.joinScope(normalized) }); + await db.insert(oauth2ClientAuthorization).values({ + userId: subject.id, + clientId: client.id, + scope: OAuth2Clients.joinScope(normalized) + }); } - static async revokeConsent(user: User, clientId: string) { + static async revokeConsent(subject: User, clientId: string) { const client = await OAuth2Clients.fetchById(clientId); if (!client) return false; - await OAuth2Tokens.wipeClientTokens(client, user); + await OAuth2Tokens.wipeClientTokens(client, subject); await db .delete(oauth2ClientAuthorization) .where( and( - eq(oauth2ClientAuthorization.userId, user.id), + eq(oauth2ClientAuthorization.userId, subject.id), eq(oauth2ClientAuthorization.clientId, client.id) ) ); @@ -86,24 +89,33 @@ export class OAuth2Users { return true; } - static async issueIdToken(user: User, client: OAuth2Client, scope: string[], nonce?: string) { + static async listAuthorizations(subject: User) { + return db + .select() + .from(oauth2Client) + .innerJoin(oauth2ClientAuthorization, eq(oauth2ClientAuthorization.clientId, oauth2Client.id)) + .leftJoin(oauth2ClientUrl, eq(oauth2ClientUrl.clientId, oauth2Client.id)) + .where(and(eq(oauth2ClientAuthorization.userId, subject.id))); + } + + static async issueIdToken(subject: User, client: OAuth2Client, scope: string[], nonce?: string) { const userData: Record = { - name: user.display_name, - preferred_username: user.username, - nickname: user.display_name, - updated_at: user.updated_at, + name: subject.display_name, + preferred_username: subject.username, + nickname: subject.display_name, + updated_at: subject.updated_at, nonce }; if (scope.includes('email')) { - userData.email = user.email; + userData.email = subject.email; userData.email_verified = true; } - if (scope.includes('picture') && user.pictureId) { - userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`; + if (scope.includes('picture') && subject.pictureId) { + userData.picture = `${PUBLIC_URL}/api/avatar/${subject.uuid}`; } - return JWT.issue(userData, user.uuid, client.client_id); + return JWT.issue(userData, subject.uuid, client.client_id); } } diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts index fd4c330..c3e768c 100644 --- a/src/lib/server/users/index.ts +++ b/src/lib/server/users/index.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcryptjs'; -import { and, eq, gt, or, sql } from 'drizzle-orm'; -import { db, user, userToken, type User } from '../drizzle'; +import { and, eq, or, sql } from 'drizzle-orm'; +import { db, user, type User } from '../drizzle'; import type { UserSession } from './types'; import { redirect } from '@sveltejs/kit'; import { CryptoUtils } from '../crypto-utils'; @@ -92,17 +92,7 @@ export class Users { } static async getActivationToken(token: string) { - const [returnedToken] = await db - .select({ id: userToken.id, token: userToken.token, userId: userToken.userId }) - .from(userToken) - .where( - and( - eq(userToken.token, token), - eq(userToken.type, 'activation'), - gt(userToken.expires_at, new Date()) - ) - ) - .limit(1); + const returnedToken = await UserTokens.getByToken(token, 'activation'); if (!returnedToken?.userId) return undefined; const [userInfo] = await db @@ -124,23 +114,6 @@ export class Users { await UserTokens.remove(token); } - static async getPasswordToken(token: string) { - return db.query.userToken.findFirst({ - columns: { - id: true, - token: true - }, - where: and( - eq(userToken.token, token), - eq(userToken.type, 'password'), - gt(userToken.expires_at, new Date()) - ), - with: { - user: true - } - }); - } - static async register({ username, displayName, @@ -240,6 +213,12 @@ export class Users { await UserTokens.remove(token); } } + + static anonymizeEmail(email: string) { + const [name, domain] = email.split('@'); + const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`; + return `${namePart}@${domain}`; + } } export * from './totp'; diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts index 0e6f530..5f9366f 100644 --- a/src/lib/server/users/tokens.ts +++ b/src/lib/server/users/tokens.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, isNull, or } from 'drizzle-orm'; +import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'; import { CryptoUtils } from '../crypto-utils'; import { db, userToken, type UserToken } from '../drizzle'; @@ -40,4 +40,8 @@ export class UserTokens { .limit(1); return returned; } + + static async wipeExpiredTokens() { + await db.execute(sql`DELETE FROM ${userToken} WHERE ${userToken.expires_at} < NOW()`); + } } diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..96f4122 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,23 @@ + + + +
+

{$page.status}

+

{$page.error?.message}

+ {#if $page.status !== 404} + + {/if} +
+
+ + diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 5e5fe1e..82ae4f4 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -194,6 +194,7 @@ export async function load({ locals, url }) { return { user: userInfo, + email: Users.anonymizeEmail(currentUser.email), otpEnabled, hasAvatar: !!currentUser.pictureId, updateRef diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 3efa4b0..72b9df3 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -7,15 +7,16 @@ import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import Button from '$lib/components/Button.svelte'; import Alert from '$lib/components/Alert.svelte'; - import ViewColumn from '$lib/components/ColumnView.svelte'; - import MainContainer from '$lib/components/MainContainer.svelte'; - import SplitView from '$lib/components/SplitView.svelte'; + import ViewColumn from '$lib/components/container/ColumnView.svelte'; + import MainContainer from '$lib/components/container/MainContainer.svelte'; + import SplitView from '$lib/components/container/SplitView.svelte'; import { enhance } from '$app/forms'; import type { SubmitFunction } from '@sveltejs/kit'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarModal from '$lib/components/avatar/AvatarModal.svelte'; import { writable } from 'svelte/store'; import { PUBLIC_SITE_NAME } from '$env/static/public'; + import FormErrors from '$lib/components/form/FormErrors.svelte'; export let data: PageData; export let form: ActionData; @@ -62,105 +63,106 @@

{$t('account.title')}

-
- - {#if form?.success}{$t('account.changeSuccess')}{/if} - {#if errors.length} - {#each errors as error} - {$t(`account.errors.${error}`)} - {/each} - {/if} + + + + {#if form?.success}{$t('account.changeSuccess')}{/if} + - {#if form?.otpRequired} - - - + {#if form?.otpRequired} + + + + + + + + + + {:else} - - - - - - {:else} - - - - - - - - - - - - - + - - - - - - - - + - - - - {$t('account.passwordHint')} - + + + + + {$t('account.emailHint')}: {data.email} + - - - - - - {/if} + + + + + - - - + + + + + + + + + + {$t('account.passwordHint')} + + + + + + + + {/if} + + +
+ + + +
@@ -168,9 +170,11 @@

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

- +
+ +
{#if data.hasAvatar}
@@ -189,9 +193,13 @@ {/if} {$t('common.manage')} + +
+

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

+ {$t('common.manage')} +
- diff --git a/src/routes/account/authorizations/+page.server.ts b/src/routes/account/authorizations/+page.server.ts new file mode 100644 index 0000000..91c08da --- /dev/null +++ b/src/routes/account/authorizations/+page.server.ts @@ -0,0 +1,63 @@ +import { OAuth2Clients, OAuth2Users } from '$lib/server/oauth2/index.js'; +import { Users } from '$lib/server/users'; +import { fail, redirect } from '@sveltejs/kit'; + +interface MappedClient { + id: string; + title: string; + description: string | null; + scope: string[]; + website?: string; +} + +export const actions = { + revoke: async ({ locals, url }) => { + const currentUser = await Users.getBySession(locals.session.data?.user); + if (!currentUser) { + await locals.session.destroy(); + return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + const clientId = url.searchParams.get('client') as string; + if (!clientId) { + return fail(400, { invalid: true }); + } + + await OAuth2Users.revokeConsent(currentUser, clientId); + + return {}; + } +}; + +export const load = async ({ locals, url }) => { + const currentUser = await Users.getBySession(locals.session.data?.user); + if (!currentUser) { + await locals.session.destroy(); + return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + const entites = await OAuth2Users.listAuthorizations(currentUser); + const items = entites.reduce((accum, entry) => { + const existing = accum.find((item) => item.id === entry.o_auth2_client.client_id); + if (existing) { + if (!existing.website && entry.o_auth2_client_url?.type === 'website') { + existing.website = entry.o_auth2_client_url.url; + } + + return accum; + } + + accum.push({ + id: entry.o_auth2_client.client_id, + title: entry.o_auth2_client.title, + description: entry.o_auth2_client.description, + scope: OAuth2Clients.splitScope(entry.o_auth2_client_authorization.scope || ''), + website: + entry.o_auth2_client_url?.type === 'website' ? entry.o_auth2_client_url.url : undefined + }); + + return accum; + }, []); + + return { items }; +}; diff --git a/src/routes/account/authorizations/+page.svelte b/src/routes/account/authorizations/+page.svelte new file mode 100644 index 0000000..bcf2052 --- /dev/null +++ b/src/routes/account/authorizations/+page.svelte @@ -0,0 +1,100 @@ + + + + {$t('account.authorizations.title')} - {PUBLIC_SITE_NAME} + + + + +

{PUBLIC_SITE_NAME}

+ {$t('account.altTitle')} +
+ +

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

+ + +

{$t('account.authorizations.description')}

+

{$t('account.authorizations.warning')}

+ +
+ {#each data.items as client} + + +
+
+ {client.title} + {client.description} + + {#if client.website} + + {/if} +
+
+ + + +
+
+
+ {:else} +
{$t(`account.authorizations.none`)}
+ {/each} +
+
+
+ + diff --git a/src/routes/account/two-factor/+page.svelte b/src/routes/account/two-factor/+page.svelte index 3272439..bccb449 100644 --- a/src/routes/account/two-factor/+page.svelte +++ b/src/routes/account/two-factor/+page.svelte @@ -2,8 +2,9 @@ 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 ColumnView from '$lib/components/container/ColumnView.svelte'; + import MainContainer from '$lib/components/container/MainContainer.svelte'; + import TitleRow from '$lib/components/container/TitleRow.svelte'; import FormControl from '$lib/components/form/FormControl.svelte'; import FormSection from '$lib/components/form/FormSection.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte'; @@ -19,7 +20,11 @@ -

{PUBLIC_SITE_NAME}

+ +

{PUBLIC_SITE_NAME}

+ {$t('account.altTitle')} +
+

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

@@ -50,17 +55,17 @@ {: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 91003b7..d453d3c 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -16,8 +16,12 @@ interface LoginChallenge { email: string; } +const rainbowTableLimiter = new RateLimiter({ + IP: [3, '10s'] +}); + const limiter = new RateLimiter({ - IP: [6, '45s'] + IP: [6, 'm'] }); export const actions = { @@ -54,6 +58,7 @@ export const actions = { // Find existing active user const loginUser = await Users.getByLogin(email); if (!loginUser) { + if (await rainbowTableLimiter.isLimited(event)) throw error(429); return fail(400, { email, incorrect: true }); } @@ -80,7 +85,8 @@ export const actions = { } } else { // Compare user password - if (!loginUser || !password || !(await Users.validatePassword(loginUser, password))) { + if (!password || !(await Users.validatePassword(loginUser, password))) { + if (await rainbowTableLimiter.isLimited(event)) throw error(429); return fail(400, { email, incorrect: true }); } } diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 609e8b3..e1dd0d8 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -4,12 +4,12 @@ import type { ActionData, PageData } from './$types'; import Alert from '$lib/components/Alert.svelte'; import Button from '$lib/components/Button.svelte'; - import SideContainer from '$lib/components/SideContainer.svelte'; + import SideContainer from '$lib/components/container/SideContainer.svelte'; import FormControl from '$lib/components/form/FormControl.svelte'; 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'; + import ButtonRow from '$lib/components/container/ButtonRow.svelte'; export let data: PageData; export let form: ActionData; diff --git a/src/routes/login/password/+page.svelte b/src/routes/login/password/+page.svelte index c9e74a4..2cfa838 100644 --- a/src/routes/login/password/+page.svelte +++ b/src/routes/login/password/+page.svelte @@ -4,9 +4,10 @@ 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 ColumnView from '$lib/components/container/ColumnView.svelte'; + import SideContainer from '$lib/components/container/SideContainer.svelte'; import FormControl from '$lib/components/form/FormControl.svelte'; + import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import { t } from '$lib/i18n'; import type { ActionData, PageData, SubmitFunction } from './$types'; @@ -59,11 +60,7 @@ {:else}
- {#if errors.length} - {#each errors as error} - {$t(`account.errors.${error}`)} - {/each} - {/if} + {#if data.setter} diff --git a/src/routes/oauth2/authorize/+page.svelte b/src/routes/oauth2/authorize/+page.svelte index f7daf23..46f055e 100644 --- a/src/routes/oauth2/authorize/+page.svelte +++ b/src/routes/oauth2/authorize/+page.svelte @@ -3,8 +3,8 @@ 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 ColumnView from '$lib/components/container/ColumnView.svelte'; + import MainContainer from '$lib/components/container/MainContainer.svelte'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import { t } from '$lib/i18n'; import type { PageData } from './$types'; @@ -46,6 +46,7 @@ {/if}
+
{data.client.title} diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 4230a9b..c90292c 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -3,14 +3,15 @@ import type { SubmitFunction } from '@sveltejs/kit'; import type { PageData, ActionData } from './$types'; import { t } from '$lib/i18n'; - import SideContainer from '$lib/components/SideContainer.svelte'; + import SideContainer from '$lib/components/container/SideContainer.svelte'; import Alert from '$lib/components/Alert.svelte'; - import ColumnView from '$lib/components/ColumnView.svelte'; + import ColumnView from '$lib/components/container/ColumnView.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormControl from '$lib/components/form/FormControl.svelte'; import Button from '$lib/components/Button.svelte'; import FormSection from '$lib/components/form/FormSection.svelte'; import { enhance } from '$app/forms'; + import FormErrors from '$lib/components/form/FormErrors.svelte'; export let data: PageData; export let form: ActionData; @@ -54,11 +55,7 @@ {:else} - {#if errors.length} - {#each errors as error} - {$t(`account.errors.${error}`)} - {/each} - {/if} + diff --git a/src/routes/soadmin/+layout.svelte b/src/routes/soadmin/+layout.svelte new file mode 100644 index 0000000..4fa864c --- /dev/null +++ b/src/routes/soadmin/+layout.svelte @@ -0,0 +1 @@ +