Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update MFA flow #1082

Merged
merged 12 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,4 @@ export { default as RadioBoxes } from './radioBoxes.svelte';
export { default as ModalWrapper } from './modalWrapper.svelte';
export { default as ModalSideCol } from './modalSideCol.svelte';
export { default as ImagePreview } from './imagePreview.svelte';
export { default as MfaChallengeFormList } from './mfaChallengeFormList.svelte';
134 changes: 134 additions & 0 deletions src/lib/components/mfaChallengeFormList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script context="module" lang="ts">
export async function verify(challenge: Models.MfaChallenge, code: string) {
try {
if (challenge == null) {
challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
}
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountCreate);
} catch (error) {
trackError(error, Submit.AccountCreate);
throw error;
}
}
</script>

<script lang="ts">
import { onMount } from 'svelte';
import { invalidate } from '$app/navigation';
import { Button, FormItem, FormList, InputDigits, InputText } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { AuthenticationFactor, type Models } from '@appwrite.io/console';

export let factors: Models.MfaFactors & { recoveryCode: boolean };
/** If true, the form will be submitted automatically when the code is entered. */
export let autoSubmit: boolean = true;
export let showVerifyButton: boolean = true;
export let disabled: boolean = false;
export let challenge: Models.MfaChallenge;
export let code: string;

let challengeType: AuthenticationFactor;

const enabledFactors = Object.entries(factors).filter(([_, enabled]) => enabled);

async function createChallenge(factor: AuthenticationFactor) {
disabled = true;
challengeType = factor;
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
disabled = false;
}

onMount(async () => {
const enabledNonRecoveryFactors = enabledFactors.filter(
([factor, _]) => factor != 'recoveryCode'
);
if (enabledNonRecoveryFactors.length == 1) {
if (factors.phone) {
createChallenge(AuthenticationFactor.Phone);
} else if (factors.email) {
createChallenge(AuthenticationFactor.Email);
}
}
});
</script>

<FormList>
{#if challengeType == AuthenticationFactor.Recoverycode}
<p>
Enter below one of the recovery codes you received when enabling MFA for this account.
</p>
<InputText id="recovery-code" bind:value={code} required autofocus />
{:else}
{#if factors.totp && (challengeType == AuthenticationFactor.Totp || challengeType == null)}
<p>Enter below a 6-digit one-time code generated by your authentication app.</p>
{:else if challengeType == AuthenticationFactor.Email}
<p>A 6-digit verification code was sent to your email, enter it below.</p>
{:else if challengeType == AuthenticationFactor.Phone}
<p>A 6-digit verification code was sent to your phone, enter it below.</p>
{/if}
<InputDigits bind:value={code} required autofocus {autoSubmit} />
{/if}
{#if showVerifyButton}
<FormItem>
<Button fullWidth submit {disabled}>Verify</Button>
</FormItem>
{/if}
{#if enabledFactors.length > 1}
<span class="with-separators eyebrow-heading-3">or</span>
<div class="u-flex-vertical u-gap-8">
{#if factors.totp && challengeType != null && challengeType != AuthenticationFactor.Totp}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Totp)}>
<span class="icon-device-mobile u-font-size-20" aria-hidden="true" />
<span class="text">Authenticator app</span>
</Button>
</FormItem>
{/if}
{#if factors.email && challengeType != AuthenticationFactor.Email}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Email)}>
<span class="icon-mail u-font-size-20" aria-hidden="true" />
<span class="text">Email verification</span>
</Button>
</FormItem>
{/if}
{#if factors.phone && challengeType != AuthenticationFactor.Phone}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Phone)}>
<span class="icon-chat-alt u-font-size-20" aria-hidden="true" />
<span class="text">Phone verification</span>
</Button>
</FormItem>
{/if}
{#if factors.recoveryCode && challengeType != AuthenticationFactor.Recoverycode}
<FormItem>
<Button
text
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Recoverycode)}>
<span class="text">Use recovery code</span>
</Button>
</FormItem>
{/if}
</div>
{/if}
</FormList>
6 changes: 5 additions & 1 deletion src/lib/elements/forms/inputDigits.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
onValueChange: ({ next }) => {
value = next.join('');

if (autoSubmit && value.length === length && !autoSubmitted) {
if (value.length < length) {
autoSubmitted = false;
}

if (element && autoSubmit && value.length === length && !autoSubmitted) {
autoSubmitted = true;
const firstInputElement = element.querySelector('input');
firstInputElement?.form.requestSubmit();
Expand Down
5 changes: 3 additions & 2 deletions src/lib/layout/header.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { AvatarInitials, DropList, DropListItem, DropListLink, Support } from '$lib/components';
import { app } from '$lib/stores/app';
Expand All @@ -24,7 +24,7 @@
import CreateOrganizationCloud from '$routes/console/createOrganizationCloud.svelte';
import { Feedback } from '$lib/components/feedback';
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
import { BillingPlan } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';

let showDropdown = false;
let showSupport = false;
Expand All @@ -40,6 +40,7 @@

async function logout() {
await sdk.forConsole.account.deleteSession('current');
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountLogout);
await goto(`${base}/login`);
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ export const load: LayoutLoad = async ({ depends, url }) => {
const path = url.search ? `${url.search}&${redirectUrl}` : `?${redirectUrl}`;

if (error.type === 'user_more_factors_required') {
if (url.pathname === '/mfa') return;
if (url.pathname === '/mfa') return {}; // Ensure any previous account/organizations are cleared
redirect(303, `/mfa${path}`);
}

if (!acceptedRoutes.some((n) => url.pathname.startsWith(n))) {
redirect(303, `/login${path}`);
}
return {}; // Ensure any previous account/organizations are cleared
}
};
4 changes: 3 additions & 1 deletion src/routes/console/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = async ({ fetch, depends }) => {
export const load: LayoutLoad = async ({ fetch, depends, parent }) => {
await parent(); // ensure user is authenticated before proceeding

depends(Dependencies.RUNTIMES);
depends(Dependencies.CONSOLE_VARIABLES);

Expand Down
2 changes: 1 addition & 1 deletion src/routes/console/account/mfaRecoveryCodes.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<ul class="buttons-list">
<li class="buttons-list-item">
<Button
download="backups.txt"
download="appwrite-backups.txt"
href={`data:application/octet-stream;charset=utf-8,${formattedBackupCodes}`}
text>
<span class="icon-download u-font-size-20" aria-hidden="true" />
Expand Down
37 changes: 22 additions & 15 deletions src/routes/console/account/mfaRegenerateCodes.svelte
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, FormList, InputDigits } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { AuthenticationFactor } from '@appwrite.io/console';
import MfaChallengeFormList, { verify } from '$lib/components/mfaChallengeFormList.svelte';
import { Button } from '$lib/elements/forms';
import { type Models } from '@appwrite.io/console';

export let show = false;
export let regenerateRecoveryCodes: () => Promise<void>;
export let factors: Models.MfaFactors & { recoveryCode: boolean };

let code = '';
let error = '';
let challenge: Models.MfaChallenge = null;
let code = '';

async function verify() {
async function submit() {
try {
const challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await verify(challenge, code);
await regenerateRecoveryCodes();
show = false;
regenerateRecoveryCodes();
} catch (e) {
error = e.message;
}
}

$: if (show) {
error = '';
challenge = null;
code = '';
}
</script>

<Modal
Expand All @@ -30,16 +35,18 @@
state="warning"
headerDivider={false}
{error}
onSubmit={verify}
onSubmit={submit}
bind:show>
<p>
Are you sure you want to regenerate all recovery codes? All <b
>previously generated recovery codes will become invalid.</b>
</p>
<p>Enter the 6-digit verification code generated by your authenticator app to continue.</p>
<FormList>
<InputDigits bind:value={code} required autofocus autoSubmit={false} />
</FormList>
<MfaChallengeFormList
factors={{ ...factors, recoveryCode: false }}
bind:challenge
bind:code
showVerifyButton={false}
autoSubmit={false} />

<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
Expand Down
Loading
Loading