Skip to content

Commit

Permalink
Merge pull request #1082 from appwrite/fix-mfa-flow
Browse files Browse the repository at this point in the history
Update MFA flow
  • Loading branch information
stnguyen90 authored May 16, 2024
2 parents ea25a87 + 0b9172e commit 0af1af4
Show file tree
Hide file tree
Showing 11 changed files with 306 additions and 228 deletions.
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

0 comments on commit 0af1af4

Please sign in to comment.