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_USER=
EMAIL_SMTP_PASS= EMAIL_SMTP_PASS=
REGISTRATIONS=true REGISTRATIONS=true
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

View File

@ -19,6 +19,10 @@
&.with-actions { &.with-actions {
gap: 16px; 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", "title": "Manage your account",
"altTitle": "Account management",
"username": "Username", "username": "Username",
"usernameHint": "Only the English alphabet, numbers and _-. are allowed.", "usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
"displayName": "Display Name", "displayName": "Display Name",
@ -8,6 +9,7 @@
"currentEmail": "Current email address", "currentEmail": "Current email address",
"email": "Email address", "email": "Email address",
"newEmail": "New email address", "newEmail": "New email address",
"emailHint": "Hint",
"password": "Password", "password": "Password",
"repeatPassword": "Repeat password", "repeatPassword": "Repeat password",
"changePassword": "Change password", "changePassword": "Change password",
@ -67,7 +69,7 @@
}, },
"otp": { "otp": {
"title": "Two-factor authentication", "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.", "disabled": "Your account does not have two-factor authentication enabled.",
"activated": "Two-factor authentication has been activated successfully!", "activated": "Two-factor authentication has been activated successfully!",
"deactivated": "Two-factor authentication has been deactivated successfully.", "deactivated": "Two-factor authentication has been deactivated successfully.",
@ -77,5 +79,13 @@
"retry": "Try again", "retry": "Try again",
"activate": "Set up two factor authentication", "activate": "Set up two factor authentication",
"deactivate": "Deactivate" "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() { 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) { static async remove(token: OAuth2Token) {

View File

@ -2,6 +2,7 @@ import {
db, db,
oauth2Client, oauth2Client,
oauth2ClientAuthorization, oauth2ClientAuthorization,
oauth2ClientUrl,
type OAuth2Client, type OAuth2Client,
type User type User
} from '$lib/server/drizzle'; } from '$lib/server/drizzle';
@ -36,7 +37,7 @@ export class OAuth2Users {
})?.length; })?.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 normalized = OAuth2Clients.splitScope(scopes);
const [existing] = await db const [existing] = await db
.select() .select()
@ -44,7 +45,7 @@ export class OAuth2Users {
.where( .where(
and( and(
eq(oauth2ClientAuthorization.clientId, client.id), eq(oauth2ClientAuthorization.clientId, client.id),
eq(oauth2ClientAuthorization.userId, user.id) eq(oauth2ClientAuthorization.userId, subject.id)
) )
) )
.limit(1); .limit(1);
@ -64,21 +65,23 @@ export class OAuth2Users {
return; return;
} }
await db await db.insert(oauth2ClientAuthorization).values({
.insert(oauth2ClientAuthorization) userId: subject.id,
.values({ userId: user.id, clientId: client.id, scope: OAuth2Clients.joinScope(normalized) }); 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); const client = await OAuth2Clients.fetchById(clientId);
if (!client) return false; if (!client) return false;
await OAuth2Tokens.wipeClientTokens(client, user); await OAuth2Tokens.wipeClientTokens(client, subject);
await db await db
.delete(oauth2ClientAuthorization) .delete(oauth2ClientAuthorization)
.where( .where(
and( and(
eq(oauth2ClientAuthorization.userId, user.id), eq(oauth2ClientAuthorization.userId, subject.id),
eq(oauth2ClientAuthorization.clientId, client.id) eq(oauth2ClientAuthorization.clientId, client.id)
) )
); );
@ -86,24 +89,33 @@ export class OAuth2Users {
return true; 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> = { const userData: Record<string, unknown> = {
name: user.display_name, name: subject.display_name,
preferred_username: user.username, preferred_username: subject.username,
nickname: user.display_name, nickname: subject.display_name,
updated_at: user.updated_at, updated_at: subject.updated_at,
nonce nonce
}; };
if (scope.includes('email')) { if (scope.includes('email')) {
userData.email = user.email; userData.email = subject.email;
userData.email_verified = true; userData.email_verified = true;
} }
if (scope.includes('picture') && user.pictureId) { if (scope.includes('picture') && subject.pictureId) {
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`; 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 bcrypt from 'bcryptjs';
import { and, eq, gt, or, sql } from 'drizzle-orm'; import { and, eq, or, sql } from 'drizzle-orm';
import { db, user, userToken, type User } from '../drizzle'; import { db, user, type User } from '../drizzle';
import type { UserSession } from './types'; import type { UserSession } from './types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { CryptoUtils } from '../crypto-utils'; import { CryptoUtils } from '../crypto-utils';
@ -92,17 +92,7 @@ export class Users {
} }
static async getActivationToken(token: string) { static async getActivationToken(token: string) {
const [returnedToken] = await db const returnedToken = await UserTokens.getByToken(token, 'activation');
.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);
if (!returnedToken?.userId) return undefined; if (!returnedToken?.userId) return undefined;
const [userInfo] = await db const [userInfo] = await db
@ -124,23 +114,6 @@ export class Users {
await UserTokens.remove(token); 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({ static async register({
username, username,
displayName, displayName,
@ -240,6 +213,12 @@ export class Users {
await UserTokens.remove(token); 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'; 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 { CryptoUtils } from '../crypto-utils';
import { db, userToken, type UserToken } from '../drizzle'; import { db, userToken, type UserToken } from '../drizzle';
@ -40,4 +40,8 @@ export class UserTokens {
.limit(1); .limit(1);
return returned; 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 { return {
user: userInfo, user: userInfo,
email: Users.anonymizeEmail(currentUser.email),
otpEnabled, otpEnabled,
hasAvatar: !!currentUser.pictureId, hasAvatar: !!currentUser.pictureId,
updateRef updateRef

View File

@ -7,15 +7,16 @@
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import ViewColumn from '$lib/components/ColumnView.svelte'; import ViewColumn from '$lib/components/container/ColumnView.svelte';
import MainContainer from '$lib/components/MainContainer.svelte'; import MainContainer from '$lib/components/container/MainContainer.svelte';
import SplitView from '$lib/components/SplitView.svelte'; import SplitView from '$lib/components/container/SplitView.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte'; import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { PUBLIC_SITE_NAME } from '$env/static/public'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import FormErrors from '$lib/components/form/FormErrors.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -62,105 +63,106 @@
<div> <div>
<h2>{$t('account.title')}</h2> <h2>{$t('account.title')}</h2>
<form action="?/update" method="POST" use:enhance={enhanceFn}> <ViewColumn>
<FormWrapper> <form action="?/update" method="POST" use:enhance={enhanceFn}>
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if} <FormWrapper>
{#if errors.length} {#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
{#each errors as error} <FormErrors {errors} prefix="account.errors" />
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
{#if form?.otpRequired} {#if form?.otpRequired}
<!-- Two-factor code request --> <!-- Two-factor code request -->
<FormSection title={$t('account.login.otp')}> <FormSection title={$t('account.login.otp')}>
<input name="challenge" value={form.otpRequired} type="hidden" /> <input name="challenge" value={form.otpRequired} type="hidden" />
<FormControl>
<label for="form-otpCode">{$t('account.login.otpCode')}</label>
<!-- svelte-ignore a11y-autofocus -->
<input id="form-otpCode" name="otpCode" autocomplete="off" autofocus />
</FormControl>
</FormSection>
{:else}
<FormControl> <FormControl>
<label for="form-otpCode">{$t('account.login.otpCode')}</label> <label for="form-username">{$t('account.username')}</label>
<!-- svelte-ignore a11y-autofocus -->
<input id="form-otpCode" name="otpCode" autocomplete="off" autofocus />
</FormControl>
</FormSection>
{:else}
<FormControl>
<label for="form-username">{$t('account.username')}</label>
<input
type="text"
disabled
value={data.user.username}
id="form-username"
autocomplete="username"
bind:this={usernameRef}
/>
</FormControl>
<FormControl>
<label for="form-displayName">{$t('account.displayName')}</label>
<input
type="text"
autocomplete="nickname"
name="displayName"
value={form?.displayName || data.user.name}
id="form-displayName"
bind:this={displayRef}
/>
</FormControl>
<FormSection title={$t('account.changeEmail')}>
<FormControl>
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
<input <input
type="email" type="text"
name="currentEmail" disabled
id="form-currentEmail" value={data.user.username}
autocomplete="email" id="form-username"
autocomplete="username"
bind:this={usernameRef}
/> />
</FormControl> </FormControl>
<FormControl> <FormControl>
<label for="form-newEmail">{$t('account.newEmail')}</label> <label for="form-displayName">{$t('account.displayName')}</label>
<input type="email" name="newEmail" id="form-newEmail" autocomplete="email" />
</FormControl>
</FormSection>
<FormSection title={$t('account.changePassword')}>
<FormControl>
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
<input <input
type="password" type="text"
autocomplete="current-password" autocomplete="nickname"
name="currentPassword" name="displayName"
id="form-currentPassword" value={form?.displayName || data.user.name}
id="form-displayName"
bind:this={displayRef}
/> />
</FormControl> </FormControl>
<FormControl> <FormSection title={$t('account.changeEmail')}>
<label for="form-newPassword">{$t('account.newPassword')}</label> <FormControl>
<input <label for="form-currentEmail">{$t('account.currentEmail')}</label>
type="password" <input
autocomplete="new-password" type="email"
name="newPassword" name="currentEmail"
id="form-newPassword" id="form-currentEmail"
aria-describedby="new-password-hint" autocomplete="email"
/> />
<span id="new-password-hint">{$t('account.passwordHint')}</span> <span>{$t('account.emailHint')}: {data.email}</span>
</FormControl> </FormControl>
<FormControl> <FormControl>
<label for="form-repeatPassword">{$t('account.repeatNewPassword')}</label> <label for="form-newEmail">{$t('account.newEmail')}</label>
<input <input type="email" name="newEmail" id="form-newEmail" autocomplete="email" />
type="password" </FormControl>
autocomplete="new-password" </FormSection>
name="repeatPassword"
id="form-repeatPassword"
/>
</FormControl>
</FormSection>
{/if}
<Button variant="primary" type="submit">{$t('account.submit')}</Button> <FormSection title={$t('account.changePassword')}>
</FormWrapper> <FormControl>
</form> <label for="form-currentPassword">{$t('account.currentPassword')}</label>
<input
type="password"
autocomplete="current-password"
name="currentPassword"
id="form-currentPassword"
/>
</FormControl>
<FormControl>
<label for="form-newPassword">{$t('account.newPassword')}</label>
<input
type="password"
autocomplete="new-password"
name="newPassword"
id="form-newPassword"
aria-describedby="new-password-hint"
/>
<span id="new-password-hint">{$t('account.passwordHint')}</span>
</FormControl>
<FormControl>
<label for="form-repeatPassword">{$t('account.repeatNewPassword')}</label>
<input
type="password"
autocomplete="new-password"
name="repeatPassword"
id="form-repeatPassword"
/>
</FormControl>
</FormSection>
{/if}
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
</FormWrapper>
</form>
<LogoutButton />
</ViewColumn>
</div> </div>
<ViewColumn slot="side"> <ViewColumn slot="side">
@ -168,9 +170,11 @@
<h3>{$t('account.avatar.title')}</h3> <h3>{$t('account.avatar.title')}</h3>
<AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}> <AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
<ViewColumn> <ViewColumn>
<Button variant="primary" on:click={() => ($showAvatarModal = true)} <div>
>{$t('account.avatar.change')}</Button <Button variant="primary" on:click={() => ($showAvatarModal = true)}
> >{$t('account.avatar.change')}</Button
>
</div>
{#if data.hasAvatar} {#if data.hasAvatar}
<form action="?/removeAvatar" method="POST" use:enhance> <form action="?/removeAvatar" method="POST" use:enhance>
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button> <Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
@ -189,9 +193,13 @@
{/if} {/if}
<a href="/account/two-factor">{$t('common.manage')}</a> <a href="/account/two-factor">{$t('common.manage')}</a>
</div> </div>
<div>
<h3>{$t('account.authorizations.title')}</h3>
<a href="/account/authorizations">{$t('common.manage')}</a>
</div>
</ViewColumn> </ViewColumn>
</SplitView> </SplitView>
<LogoutButton />
</MainContainer> </MainContainer>
<AvatarModal show={showAvatarModal} /> <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 { PUBLIC_SITE_NAME } from '$env/static/public';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte'; import ColumnView from '$lib/components/container/ColumnView.svelte';
import MainContainer from '$lib/components/MainContainer.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 FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
@ -19,7 +20,11 @@
</svelte:head> </svelte:head>
<MainContainer> <MainContainer>
<h1>{PUBLIC_SITE_NAME}</h1> <TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1>
<a href="/account">{$t('account.altTitle')}</a>
</TitleRow>
<h2>{$t('account.otp.title')}</h2> <h2>{$t('account.otp.title')}</h2>
<ColumnView> <ColumnView>
@ -50,17 +55,17 @@
{:else if form?.invalid} {:else if form?.invalid}
<Alert type="error">{$t('account.errors.otpFailed')}</Alert> <Alert type="error">{$t('account.errors.otpFailed')}</Alert>
<form action="?/{form?.action || 'activate'}" method="POST"> <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> </form>
{:else if data?.otpEnabled} {:else if data?.otpEnabled}
<Alert type="success">{$t('account.otp.enabled')}</Alert> <Alert type="success">{$t('account.otp.enabled')}</Alert>
<form action="?/deactivate" method="POST"> <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> </form>
{:else} {:else}
<Alert type="default">{$t('account.otp.disabled')}</Alert> <Alert type="default">{$t('account.otp.disabled')}</Alert>
<form action="?/activate" method="POST"> <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> </form>
{/if} {/if}
</ColumnView> </ColumnView>

View File

@ -16,8 +16,12 @@ interface LoginChallenge {
email: string; email: string;
} }
const rainbowTableLimiter = new RateLimiter({
IP: [3, '10s']
});
const limiter = new RateLimiter({ const limiter = new RateLimiter({
IP: [6, '45s'] IP: [6, 'm']
}); });
export const actions = { export const actions = {
@ -54,6 +58,7 @@ export const actions = {
// Find existing active user // Find existing active user
const loginUser = await Users.getByLogin(email); const loginUser = await Users.getByLogin(email);
if (!loginUser) { if (!loginUser) {
if (await rainbowTableLimiter.isLimited(event)) throw error(429);
return fail(400, { email, incorrect: true }); return fail(400, { email, incorrect: true });
} }
@ -80,7 +85,8 @@ export const actions = {
} }
} else { } else {
// Compare user password // 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 }); return fail(400, { email, incorrect: true });
} }
} }

View File

@ -4,12 +4,12 @@
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.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 FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n'; 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 data: PageData;
export let form: ActionData; export let form: ActionData;

View File

@ -4,9 +4,10 @@
import { PUBLIC_SITE_NAME } from '$env/static/public'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte'; import ColumnView from '$lib/components/container/ColumnView.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 FormControl from '$lib/components/form/FormControl.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { ActionData, PageData, SubmitFunction } from './$types'; import type { ActionData, PageData, SubmitFunction } from './$types';
@ -59,11 +60,7 @@
{:else} {:else}
<form action={actionUrl} method="POST" use:enhance={enhanceFn}> <form action={actionUrl} method="POST" use:enhance={enhanceFn}>
<FormWrapper> <FormWrapper>
{#if errors.length} <FormErrors {errors} prefix="account.errors" />
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
{#if data.setter} {#if data.setter}
<FormControl> <FormControl>

View File

@ -3,8 +3,8 @@
import { PUBLIC_SITE_NAME } from '$env/static/public'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte'; import ColumnView from '$lib/components/container/ColumnView.svelte';
import MainContainer from '$lib/components/MainContainer.svelte'; import MainContainer from '$lib/components/container/MainContainer.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@ -46,6 +46,7 @@
{/if} {/if}
<div class="graphic" aria-hidden="true"></div> <div class="graphic" aria-hidden="true"></div>
<div class="card"> <div class="card">
<!-- TODO: client pictures -->
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}> <AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
<div class="card-inner"> <div class="card-inner">
<span class="card-display-name">{data.client.title}</span> <span class="card-display-name">{data.client.title}</span>

View File

@ -3,14 +3,15 @@
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
import { t } from '$lib/i18n'; 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 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 FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import FormErrors from '$lib/components/form/FormErrors.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -54,11 +55,7 @@
{:else} {:else}
<form action="" method="POST" use:enhance={enhanceFn}> <form action="" method="POST" use:enhance={enhanceFn}>
<FormWrapper> <FormWrapper>
{#if errors.length} <FormErrors {errors} prefix="account.errors" />
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
<FormSection required> <FormSection required>
<FormControl> <FormControl>

View File

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