From c686e85d3788168c16b9f3222838ee026cf5d91b Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Sun, 12 Jun 2022 16:30:37 +0100 Subject: [PATCH] feat: add MFA recovery codes --- .../settings/account/AccountManagement.tsx | 22 ++-- .../account/MultiFactorAuthentication.tsx | 123 +++++++++++++++--- src/context/modals/ModalRenderer.tsx | 4 +- src/context/modals/components/MFAFlow.tsx | 6 + src/context/modals/components/MFARecovery.tsx | 75 +++++++++++ src/context/modals/index.tsx | 61 +++++++-- src/context/modals/types.ts | 1 + 7 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 src/context/modals/components/MFARecovery.tsx diff --git a/src/components/settings/account/AccountManagement.tsx b/src/components/settings/account/AccountManagement.tsx index 05d82e97..1be77ebd 100644 --- a/src/components/settings/account/AccountManagement.tsx +++ b/src/components/settings/account/AccountManagement.tsx @@ -17,19 +17,15 @@ export default function AccountManagement() { const client = useClient(); const callback = (route: "disable" | "delete") => () => - modalController.push({ - type: "mfa_flow", - state: "known", - client, - callback: ({ token }) => - client.api - .post(`/auth/account/${route}`, undefined, { - headers: { - "X-MFA-Ticket": token, - }, - }) - .then(() => logOut(true)), - }); + modalController.mfaFlow(client).then(({ token }) => + client.api + .post(`/auth/account/${route}`, undefined, { + headers: { + "X-MFA-Ticket": token, + }, + }) + .then(() => logOut(true)), + ); return ( <> diff --git a/src/components/settings/account/MultiFactorAuthentication.tsx b/src/components/settings/account/MultiFactorAuthentication.tsx index e2539918..d6e92537 100644 --- a/src/components/settings/account/MultiFactorAuthentication.tsx +++ b/src/components/settings/account/MultiFactorAuthentication.tsx @@ -1,37 +1,122 @@ +import { ListOl } from "@styled-icons/boxicons-regular"; import { Lock } from "@styled-icons/boxicons-solid"; +import { API } from "revolt.js"; import { Text } from "preact-i18n"; +import { useCallback, useContext, useEffect, useState } from "preact/hooks"; -import { CategoryButton } from "@revoltchat/ui"; +import { CategoryButton, Column, Preloader } from "@revoltchat/ui"; +import { modalController } from "../../../context/modals"; +import { + ClientStatus, + StatusContext, + useClient, +} from "../../../context/revoltjs/RevoltClient"; + +/** + * Temporary helper function for Axios config + * @param token Token + * @returns Headers + */ +export function toConfig(token: string) { + return { + headers: { + "X-MFA-Ticket": token, + }, + }; +} + +/** + * Component for configuring MFA on an account. + */ export default function MultiFactorAuthentication() { + // Pull in prerequisites + const client = useClient(); + const status = useContext(StatusContext); + + // Keep track of MFA state + const [mfa, setMFA] = useState(); + + // Fetch the current MFA status on account + useEffect(() => { + if (!mfa && status === ClientStatus.ONLINE) { + client.api.get("/auth/mfa/").then(setMFA); + } + }, [client, mfa, status]); + + // Action called when recovery code button is pressed + const recoveryAction = useCallback(async () => { + const { token } = await modalController.mfaFlow(client); + + // Decide whether to generate or fetch. + let codes; + if (mfa!.recovery_active) { + codes = await client.api.post( + "/auth/mfa/recovery", + undefined, + toConfig(token), + ); + } else { + codes = await client.api.patch( + "/auth/mfa/recovery", + undefined, + toConfig(token), + ); + + setMFA({ + ...mfa!, + recovery_active: true, + }); + } + + // Display the codes to the user + modalController.push({ + type: "mfa_recovery", + client, + codes, + }); + }, [mfa]); + return ( <>

- {/**/} - Two-factor authentication is currently in-development, see{" "} - - tracking issue here - - . +
+ } - description={"Set up 2FA on your account."} - disabled - action={}> - Set up Two-factor authentication - - {/*} - description={"View and download your 2FA backup codes."} - disabled - action="chevron"> - View my backup codes - */} + description={ + mfa?.recovery_active + ? "View and download your 2FA backup codes." + : "Get ready to use 2FA by setting up a recovery method." + } + disabled={!mfa} + onClick={recoveryAction}> + {mfa?.recovery_active + ? "View backup codes" + : "Generate recovery codes"} + + + {JSON.stringify(mfa, undefined, 4)} ); } + +/*} + description={"Set up 2FA on your account."} + disabled + action={}> + Set up Two-factor authentication +*/ +/*} + description={"View and download your 2FA backup codes."} + disabled + action="chevron"> + View my backup codes +*/ diff --git a/src/context/modals/ModalRenderer.tsx b/src/context/modals/ModalRenderer.tsx index 2ed3d350..ab5c0582 100644 --- a/src/context/modals/ModalRenderer.tsx +++ b/src/context/modals/ModalRenderer.tsx @@ -2,6 +2,4 @@ import { observer } from "mobx-react-lite"; import { modalController } from "."; -export default observer(() => { - return modalController.render(); -}); +export default observer(() => modalController.rendered); diff --git a/src/context/modals/components/MFAFlow.tsx b/src/context/modals/components/MFAFlow.tsx index 73c4d0c8..17fb1823 100644 --- a/src/context/modals/components/MFAFlow.tsx +++ b/src/context/modals/components/MFAFlow.tsx @@ -22,12 +22,18 @@ import { noopTrue } from "../../../lib/js"; import { ModalProps } from "../types"; +/** + * Mapping of MFA methods to icons + */ const ICONS: Record> = { Password: Keyboard, Totp: Key, Recovery: Archive, }; +/** + * Component for handling challenge entry + */ function ResponseEntry({ type, value, diff --git a/src/context/modals/components/MFARecovery.tsx b/src/context/modals/components/MFARecovery.tsx new file mode 100644 index 00000000..7a07cc39 --- /dev/null +++ b/src/context/modals/components/MFARecovery.tsx @@ -0,0 +1,75 @@ +import styled from "styled-components"; + +import { useCallback, useState } from "preact/hooks"; + +import { Modal } from "@revoltchat/ui"; + +import { noopTrue } from "../../../lib/js"; + +import { modalController } from ".."; +import { toConfig } from "../../../components/settings/account/MultiFactorAuthentication"; +import { ModalProps } from "../types"; + +/** + * List of recovery codes + */ +const List = styled.div` + display: grid; + text-align: center; + grid-template-columns: 1fr 1fr; + font-family: var(--monospace-font), monospace; + + span { + user-select: text; + } +`; + +/** + * Recovery codes modal + */ +export default function MFARecovery({ + codes, + client, + onClose, +}: ModalProps<"mfa_recovery">) { + // Keep track of changes to recovery codes + const [known, setCodes] = useState(codes); + + // Subroutine to reset recovery codes + const reset = useCallback(async () => { + const { token } = await modalController.mfaFlow(client); + const codes = await client.api.patch( + "/auth/mfa/recovery", + undefined, + toConfig(token), + ); + setCodes(codes); + return false; + }, []); + + return ( + + + {known.map((code) => ( + {code} + ))} + + + ); +} diff --git a/src/context/modals/index.tsx b/src/context/modals/index.tsx index a5843d30..239d489b 100644 --- a/src/context/modals/index.tsx +++ b/src/context/modals/index.tsx @@ -1,7 +1,15 @@ -import { action, computed, makeAutoObservable } from "mobx"; +import { + action, + computed, + makeObservable, + observable, + runInAction, +} from "mobx"; +import type { Client, API } from "revolt.js"; import { ulid } from "ulid"; import MFAFlow from "./components/MFAFlow"; +import MFARecovery from "./components/MFARecovery"; import Test from "./components/Test"; import { Modal } from "./types"; @@ -17,15 +25,19 @@ class ModalController { constructor(components: Components) { this.components = components; - makeAutoObservable(this); - this.pop = this.pop.bind(this); + makeObservable(this, { + stack: observable, + push: action, + remove: action, + rendered: computed, + }); } /** * Display a new modal on the stack * @param modal Modal data */ - @action push(modal: T) { + push(modal: T) { this.stack = [ ...this.stack, { @@ -36,28 +48,57 @@ class ModalController { } /** - * Remove the top modal from the stack + * Remove the keyed modal from the stack */ - @action pop() { - this.stack = this.stack.slice(0, this.stack.length - 1); + remove(key: string) { + this.stack = this.stack.filter((x) => x.key !== key); } /** * Render modals */ - @computed render() { + get rendered() { return ( <> {this.stack.map((modal) => { const Component = this.components[modal.type]; - return ; + return ( + this.remove(modal.key!)} + /> + ); })} ); } } -export const modalController = new ModalController({ +/** + * Modal controller with additional helpers. + */ +class ModalControllerExtended extends ModalController { + /** + * Perform MFA flow + * @param client Client + */ + mfaFlow(client: Client) { + return runInAction( + () => + new Promise((callback: (ticket: API.MFATicket) => void) => + this.push({ + type: "mfa_flow", + state: "known", + client, + callback, + }), + ), + ); + } +} + +export const modalController = new ModalControllerExtended({ mfa_flow: MFAFlow, + mfa_recovery: MFARecovery, test: Test, }); diff --git a/src/context/modals/types.ts b/src/context/modals/types.ts index 33b5e3ef..28e69610 100644 --- a/src/context/modals/types.ts +++ b/src/context/modals/types.ts @@ -17,6 +17,7 @@ export type Modal = { callback: (response: API.MFAResponse) => void; } )) + | { type: "mfa_recovery"; codes: string[]; client: Client } | { type: "test"; }