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')}
-
+
+
+
@@ -168,9 +170,11 @@
{$t('account.avatar.title')}
-
+
+
+
{#if data.hasAvatar}
-
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}