From dbb1c1e8fa01098ee0ef1af61785bfacfb7b2a0f Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Sun, 12 Jun 2022 19:24:59 +0100 Subject: [PATCH] feat: finalise 2FA login --- external/lang | 2 +- package.json | 1 + .../settings/account/AccountManagement.tsx | 18 +-- .../account/MultiFactorAuthentication.tsx | 119 ++++++++++++++---- .../modals/components/MFAEnableTOTP.tsx | 76 +++++++++++ src/context/modals/components/MFAFlow.tsx | 44 +++++-- src/context/modals/components/MFARecovery.tsx | 18 +-- src/context/modals/index.tsx | 22 +++- src/context/modals/types.ts | 10 +- src/pages/login/forms/FormLogin.tsx | 10 +- yarn.lock | 10 ++ 11 files changed, 277 insertions(+), 53 deletions(-) create mode 100644 src/context/modals/components/MFAEnableTOTP.tsx diff --git a/external/lang b/external/lang index 68f72e01..c9cdbea7 160000 --- a/external/lang +++ b/external/lang @@ -1 +1 @@ -Subproject commit 68f72e01e2c450f9545e05cc702113ee966c0e9a +Subproject commit c9cdbea7edcb22641b9ea372c85a83ef8e1c1d11 diff --git a/package.json b/package.json index 8bd66244..b2e391c3 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "preact-i18n": "^2.4.0-preactx", "prettier": "^2.3.1", "prismjs": "^1.23.0", + "qrcode.react": "^3.0.2", "react-beautiful-dnd": "^13.1.0", "react-device-detect": "2.2.2", "react-helmet": "^6.1.0", diff --git a/src/components/settings/account/AccountManagement.tsx b/src/components/settings/account/AccountManagement.tsx index 1be77ebd..7af4b367 100644 --- a/src/components/settings/account/AccountManagement.tsx +++ b/src/components/settings/account/AccountManagement.tsx @@ -17,14 +17,16 @@ export default function AccountManagement() { const client = useClient(); const callback = (route: "disable" | "delete") => () => - modalController.mfaFlow(client).then(({ token }) => - client.api - .post(`/auth/account/${route}`, undefined, { - headers: { - "X-MFA-Ticket": token, - }, - }) - .then(() => logOut(true)), + modalController.mfaFlow(client).then( + (ticket) => + ticket && + client.api + .post(`/auth/account/${route}`, undefined, { + headers: { + "X-MFA-Ticket": ticket.token, + }, + }) + .then(() => logOut(true)), ); return ( diff --git a/src/components/settings/account/MultiFactorAuthentication.tsx b/src/components/settings/account/MultiFactorAuthentication.tsx index d6e92537..d913d985 100644 --- a/src/components/settings/account/MultiFactorAuthentication.tsx +++ b/src/components/settings/account/MultiFactorAuthentication.tsx @@ -5,7 +5,7 @@ import { API } from "revolt.js"; import { Text } from "preact-i18n"; import { useCallback, useContext, useEffect, useState } from "preact/hooks"; -import { CategoryButton, Column, Preloader } from "@revoltchat/ui"; +import { CategoryButton, Tip } from "@revoltchat/ui"; import { modalController } from "../../../context/modals"; import { @@ -47,21 +47,29 @@ export default function MultiFactorAuthentication() { // Action called when recovery code button is pressed const recoveryAction = useCallback(async () => { - const { token } = await modalController.mfaFlow(client); + // Perform MFA flow first + const ticket = await modalController.mfaFlow(client); + + // Check whether action was cancelled + if (typeof ticket === "undefined") { + return; + } // Decide whether to generate or fetch. let codes; if (mfa!.recovery_active) { + // Fetch existing recovery codes codes = await client.api.post( "/auth/mfa/recovery", undefined, - toConfig(token), + toConfig(ticket.token), ); } else { + // Generate new recovery codes codes = await client.api.patch( "/auth/mfa/recovery", undefined, - toConfig(token), + toConfig(ticket.token), ); setMFA({ @@ -78,6 +86,70 @@ export default function MultiFactorAuthentication() { }); }, [mfa]); + // Action called when TOTP button is pressed + const totpAction = useCallback(async () => { + // Perform MFA flow first + const ticket = await modalController.mfaFlow(client); + + // Check whether action was cancelled + if (typeof ticket === "undefined") { + return; + } + + // Decide whether to disable or enable. + if (mfa!.totp_mfa) { + // Disable TOTP authentication + await client.api.delete("/auth/mfa/totp", toConfig(ticket.token)); + + setMFA({ + ...mfa!, + totp_mfa: false, + }); + } else { + // Generate a TOTP secret + const { secret } = await client.api.post( + "/auth/mfa/totp", + undefined, + toConfig(ticket.token), + ); + + // Open secret modal + let success; + while (!success) { + try { + // Make the user generator a token + const totp_code = await modalController.mfaEnableTOTP( + secret, + client.user!.username, + ); + + if (totp_code) { + // Check whether it is valid + await client.api.put( + "/auth/mfa/totp", + { + totp_code, + }, + toConfig(ticket.token), + ); + + // Mark as successful and activated + success = true; + + setMFA({ + ...mfa!, + totp_mfa: true, + }); + } else { + break; + } + } catch (err) {} + } + } + }, [mfa]); + + const mfaActive = !!mfa?.totp_mfa; + return ( <>

@@ -97,26 +169,29 @@ export default function MultiFactorAuthentication() { disabled={!mfa} onClick={recoveryAction}> {mfa?.recovery_active - ? "View backup codes" - : "Generate recovery codes"} + ? "View Backup Codes" + : "Generate Recovery Codes"} + + + } + description={"Set up time-based one-time password."} + disabled={!mfa || (!mfa.recovery_active && !mfa.totp_mfa)} + onClick={totpAction}> + {mfa?.totp_mfa ? "Disable" : "Enable"} Authenticator App - {JSON.stringify(mfa, undefined, 4)} + {mfa && ( + + {mfaActive + ? "Two-factor authentication is currently on!" + : "Two-factor authentication is currently off!"} + + )} ); } - -/*} - 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/components/MFAEnableTOTP.tsx b/src/context/modals/components/MFAEnableTOTP.tsx new file mode 100644 index 00000000..7fc4ee2c --- /dev/null +++ b/src/context/modals/components/MFAEnableTOTP.tsx @@ -0,0 +1,76 @@ +import { QRCodeSVG } from "qrcode.react"; +import styled from "styled-components"; + +import { useState } from "preact/hooks"; + +import { Category, Centred, Column, InputBox, Modal } from "@revoltchat/ui"; + +import { ModalProps } from "../types"; + +const Code = styled.code` + user-select: all; +`; + +/** + * TOTP enable modal + */ +export default function MFAEnableTOTP({ + identifier, + secret, + callback, + onClose, +}: ModalProps<"mfa_enable_totp">) { + const uri = `otpauth://totp/Revolt:${identifier}?secret=${secret}&issuer=Revolt`; + const [value, setValue] = useState(""); + + return ( + { + callback(value.trim().replace(/\s/g, "")); + return true; + }, + confirmation: true, + }, + { + palette: "plain", + children: "Cancel", + onClick: () => { + callback(); + return true; + }, + }, + ]} + onClose={() => { + callback(); + onClose(); + }}> + + + + + + {secret} + + + + Enter Code + + setValue(e.currentTarget.value)} + /> + + ); +} diff --git a/src/context/modals/components/MFAFlow.tsx b/src/context/modals/components/MFAFlow.tsx index 17fb1823..731694e5 100644 --- a/src/context/modals/components/MFAFlow.tsx +++ b/src/context/modals/components/MFAFlow.tsx @@ -18,8 +18,6 @@ import { Preloader, } from "@revoltchat/ui"; -import { noopTrue } from "../../../lib/js"; - import { ModalProps } from "../types"; /** @@ -58,6 +56,24 @@ function ResponseEntry({ } /> )} + + {type === "Totp" && ( + + onChange({ totp_code: e.currentTarget.value }) + } + /> + )} + + {type === "Recovery" && ( + + onChange({ recovery_code: e.currentTarget.value }) + } + /> + )} ); } @@ -129,21 +145,31 @@ export default function MFAFlow({ onClose, ...props }: ModalProps<"mfa_flow">) { palette: "plain", children: methods!.length === 1 ? "Cancel" : "Back", - onClick: () => - methods!.length === 1 - ? true - : void setSelected(undefined), + onClick: () => { + if (methods!.length === 1) { + props.callback(); + return true; + } else { + setSelected(undefined); + } + }, }, ] : [ { palette: "plain", children: "Cancel", - onClick: noopTrue, + onClick: () => { + props.callback(); + return true; + }, }, ] } - onClose={onClose}> + onClose={() => { + props.callback(); + onClose(); + }}> {methods ? ( selectedMethod ? ( ) { action="chevron" icon={} onClick={() => setSelected(method)}> - {method} + ); }) diff --git a/src/context/modals/components/MFARecovery.tsx b/src/context/modals/components/MFARecovery.tsx index 7a07cc39..de8e7f60 100644 --- a/src/context/modals/components/MFARecovery.tsx +++ b/src/context/modals/components/MFARecovery.tsx @@ -37,13 +37,17 @@ export default function MFARecovery({ // 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); + const ticket = await modalController.mfaFlow(client); + if (ticket) { + const codes = await client.api.patch( + "/auth/mfa/recovery", + undefined, + toConfig(ticket.token), + ); + + setCodes(codes); + } + return false; }, []); diff --git a/src/context/modals/index.tsx b/src/context/modals/index.tsx index 239d489b..852271ec 100644 --- a/src/context/modals/index.tsx +++ b/src/context/modals/index.tsx @@ -8,6 +8,7 @@ import { import type { Client, API } from "revolt.js"; import { ulid } from "ulid"; +import MFAEnableTOTP from "./components/MFAEnableTOTP"; import MFAFlow from "./components/MFAFlow"; import MFARecovery from "./components/MFARecovery"; import Test from "./components/Test"; @@ -85,7 +86,7 @@ class ModalControllerExtended extends ModalController { mfaFlow(client: Client) { return runInAction( () => - new Promise((callback: (ticket: API.MFATicket) => void) => + new Promise((callback: (ticket?: API.MFATicket) => void) => this.push({ type: "mfa_flow", state: "known", @@ -95,10 +96,29 @@ class ModalControllerExtended extends ModalController { ), ); } + + /** + * Open TOTP secret modal + * @param client Client + */ + mfaEnableTOTP(secret: string, identifier: string) { + return runInAction( + () => + new Promise((callback: (value?: string) => void) => + this.push({ + type: "mfa_enable_totp", + identifier, + secret, + callback, + }), + ), + ); + } } export const modalController = new ModalControllerExtended({ mfa_flow: MFAFlow, mfa_recovery: MFARecovery, + mfa_enable_totp: MFAEnableTOTP, test: Test, }); diff --git a/src/context/modals/types.ts b/src/context/modals/types.ts index 28e69610..4f67b9bf 100644 --- a/src/context/modals/types.ts +++ b/src/context/modals/types.ts @@ -9,15 +9,21 @@ export type Modal = { | { state: "known"; client: Client; - callback: (ticket: API.MFATicket) => void; + callback: (ticket?: API.MFATicket) => void; } | { state: "unknown"; available_methods: API.MFAMethod[]; - callback: (response: API.MFAResponse) => void; + callback: (response?: API.MFAResponse) => void; } )) | { type: "mfa_recovery"; codes: string[]; client: Client } + | { + type: "mfa_enable_totp"; + identifier: string; + secret: string; + callback: (code?: string) => void; + } | { type: "test"; } diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx index bde6da9d..c358be8f 100644 --- a/src/pages/login/forms/FormLogin.tsx +++ b/src/pages/login/forms/FormLogin.tsx @@ -52,15 +52,19 @@ export function FormLogin() { if (session.result === "MFA") { const { allowed_methods } = session; - let mfa_response: API.MFAResponse = await new Promise( - (callback) => + let mfa_response: API.MFAResponse | undefined = + await new Promise((callback) => modalController.push({ type: "mfa_flow", state: "unknown", available_methods: allowed_methods, callback, }), - ); + ); + + if (typeof mfa_response === "undefined") { + throw "Cancelled"; + } session = await client.api.post("/auth/session/login", { mfa_response, diff --git a/yarn.lock b/yarn.lock index 95441f09..0d7c990e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3569,6 +3569,7 @@ __metadata: preact-i18n: ^2.4.0-preactx prettier: ^2.3.1 prismjs: ^1.23.0 + qrcode.react: ^3.0.2 react-beautiful-dnd: ^13.1.0 react-device-detect: 2.2.2 react-helmet: ^6.1.0 @@ -6473,6 +6474,15 @@ __metadata: languageName: node linkType: hard +"qrcode.react@npm:^3.0.2": + version: 3.0.2 + resolution: "qrcode.react@npm:3.0.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 4102e9f416d86808728b93dca4e90cab0b2d3eca2bfe501a26ca62237062ded2121711cfc4edf64832c63e04d34956e26c2e7088023949f9328bbaa56004777d + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3"