authorizations management

This commit is contained in:
Evert Prants 2024-05-21 19:16:15 +03:00
parent fcfe39e78a
commit 3bf4c7ce04
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
26 changed files with 411 additions and 169 deletions

View File

@ -17,3 +17,5 @@ EMAIL_SMTP_SECURE=false
EMAIL_SMTP_USER=
EMAIL_SMTP_PASS=
REGISTRATIONS=true
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

View File

@ -19,6 +19,10 @@
&.with-actions {
gap: 16px;
& .actions-wrapper {
width: 100%;
}
}
}

View File

@ -0,0 +1,16 @@
<div class="title-row">
<slot />
<slot name="action" />
</div>
<style>
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.title-row > :global(*):first-child {
margin: 0;
}
</style>

View File

@ -0,0 +1,13 @@
<script lang="ts">
import { t } from '$lib/i18n';
import Alert from '../Alert.svelte';
export let prefix: string;
export let errors: string[] = [];
</script>
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`${prefix}.${error}`)}</Alert>
{/each}
{/if}

View File

@ -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."
}
}

View File

@ -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) {

View File

@ -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<string, unknown> = {
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);
}
}

View File

@ -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';

View File

@ -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()`);
}
}

23
src/routes/+error.svelte Normal file
View File

@ -0,0 +1,23 @@
<script>
import { invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import Button from '$lib/components/Button.svelte';
import MainContainer from '$lib/components/container/MainContainer.svelte';
</script>
<MainContainer>
<div class="centered">
<h1>{$page.status}</h1>
<p>{$page.error?.message}</p>
{#if $page.status !== 404}
<Button on:click={() => invalidateAll()} variant="link">Go back</Button>
{/if}
</div>
</MainContainer>
<style>
.centered {
font-size: 2rem;
text-align: center;
}
</style>

View File

@ -194,6 +194,7 @@ export async function load({ locals, url }) {
return {
user: userInfo,
email: Users.anonymizeEmail(currentUser.email),
otpEnabled,
hasAvatar: !!currentUser.pictureId,
updateRef

View File

@ -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,14 +63,11 @@
<div>
<h2>{$t('account.title')}</h2>
<ViewColumn>
<form action="?/update" method="POST" use:enhance={enhanceFn}>
<FormWrapper>
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
<FormErrors {errors} prefix="account.errors" />
{#if form?.otpRequired}
<!-- Two-factor code request -->
@ -115,6 +113,7 @@
id="form-currentEmail"
autocomplete="email"
/>
<span>{$t('account.emailHint')}: {data.email}</span>
</FormControl>
<FormControl>
@ -161,6 +160,9 @@
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
</FormWrapper>
</form>
<LogoutButton />
</ViewColumn>
</div>
<ViewColumn slot="side">
@ -168,9 +170,11 @@
<h3>{$t('account.avatar.title')}</h3>
<AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
<ViewColumn>
<div>
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
>{$t('account.avatar.change')}</Button
>
</div>
{#if data.hasAvatar}
<form action="?/removeAvatar" method="POST" use:enhance>
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
@ -189,9 +193,13 @@
{/if}
<a href="/account/two-factor">{$t('common.manage')}</a>
</div>
<div>
<h3>{$t('account.authorizations.title')}</h3>
<a href="/account/authorizations">{$t('common.manage')}</a>
</div>
</ViewColumn>
</SplitView>
<LogoutButton />
</MainContainer>
<AvatarModal show={showAvatarModal} />

View File

@ -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<MappedClient[]>((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 };
};

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { assets } from '$app/paths';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import Button from '$lib/components/Button.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.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 { t } from '$lib/i18n';
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{$t('account.authorizations.title')} - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer>
<TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1>
<a href="/account">{$t('account.altTitle')}</a>
</TitleRow>
<h2>{$t('account.authorizations.title')}</h2>
<ColumnView>
<p>{$t('account.authorizations.description')}</p>
<p>{$t('account.authorizations.warning')}</p>
<div class="auth-list">
{#each data.items as client}
<!-- TODO: client pictures -->
<AvatarCard src={`${assets}/application.png`} alt={client.title}>
<div class="card-row">
<div class="card-inner">
<span class="card-display-name">{client.title}</span>
<span class="card-user-name">{client.description}</span>
{#if client.website}
<div class="card-links">
<a href={client.website} target="_blank" rel="nofollow noreferrer"
>{$t(`account.authorizations.website`)}</a
>
</div>
{/if}
</div>
<div class="card-button">
<form action="?/revoke&client={client.id}" method="POST" use:enhance>
<Button type="submit" variant="primary"
>{$t(`account.authorizations.revoke`)}</Button
>
</form>
</div>
</div>
</AvatarCard>
{:else}
<div class="auth-list-empty">{$t(`account.authorizations.none`)}</div>
{/each}
</div>
</ColumnView>
</MainContainer>
<style>
p {
margin: 0;
}
.auth-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-row {
display: flex;
height: 100%;
width: 100%;
}
.card-inner {
display: flex;
flex-direction: column;
height: 100%;
}
.card-button {
margin-left: auto;
}
.card-display-name {
font-size: 1.25rem;
font-weight: 700;
}
.card-links {
display: flex;
flex-direction: column;
margin-top: auto;
}
.auth-list-empty {
font-weight: 700;
text-align: center;
}
</style>

View File

@ -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 @@
</svelte:head>
<MainContainer>
<TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1>
<a href="/account">{$t('account.altTitle')}</a>
</TitleRow>
<h2>{$t('account.otp.title')}</h2>
<ColumnView>
@ -50,17 +55,17 @@
{:else if form?.invalid}
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
<form action="?/{form?.action || 'activate'}" method="POST">
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
<Button type="submit" variant="primary">{$t('account.otp.retry')}</Button>
</form>
{:else if data?.otpEnabled}
<Alert type="success">{$t('account.otp.enabled')}</Alert>
<form action="?/deactivate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
<Button type="submit" variant="primary">{$t('account.otp.deactivate')}</Button>
</form>
{:else}
<Alert type="default">{$t('account.otp.disabled')}</Alert>
<form action="?/activate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
<Button type="submit" variant="primary">{$t('account.otp.activate')}</Button>
</form>
{/if}
</ColumnView>

View File

@ -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 });
}
}

View File

@ -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;

View File

@ -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}
<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}
<FormErrors {errors} prefix="account.errors" />
{#if data.setter}
<FormControl>

View File

@ -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}
<div class="graphic" aria-hidden="true"></div>
<div class="card">
<!-- TODO: client pictures -->
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
<div class="card-inner">
<span class="card-display-name">{data.client.title}</span>

View File

@ -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}
<form action="" method="POST" use:enhance={enhanceFn}>
<FormWrapper>
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
<FormErrors {errors} prefix="account.errors" />
<FormSection required>
<FormControl>

View File

@ -0,0 +1 @@
<slot />