mirror of
https://github.com/revoltchat/revite.git
synced 2025-02-21 15:51:11 -05:00
feat: add MFA recovery codes
This commit is contained in:
parent
8eefc87b05
commit
c686e85d37
7 changed files with 247 additions and 45 deletions
|
@ -17,11 +17,7 @@ export default function AccountManagement() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const callback = (route: "disable" | "delete") => () =>
|
const callback = (route: "disable" | "delete") => () =>
|
||||||
modalController.push({
|
modalController.mfaFlow(client).then(({ token }) =>
|
||||||
type: "mfa_flow",
|
|
||||||
state: "known",
|
|
||||||
client,
|
|
||||||
callback: ({ token }) =>
|
|
||||||
client.api
|
client.api
|
||||||
.post(`/auth/account/${route}`, undefined, {
|
.post(`/auth/account/${route}`, undefined, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -29,7 +25,7 @@ export default function AccountManagement() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => logOut(true)),
|
.then(() => logOut(true)),
|
||||||
});
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,37 +1,122 @@
|
||||||
|
import { ListOl } from "@styled-icons/boxicons-regular";
|
||||||
import { Lock } from "@styled-icons/boxicons-solid";
|
import { Lock } from "@styled-icons/boxicons-solid";
|
||||||
|
import { API } from "revolt.js";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
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() {
|
export default function MultiFactorAuthentication() {
|
||||||
|
// Pull in prerequisites
|
||||||
|
const client = useClient();
|
||||||
|
const status = useContext(StatusContext);
|
||||||
|
|
||||||
|
// Keep track of MFA state
|
||||||
|
const [mfa, setMFA] = useState<API.MultiFactorStatus>();
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.settings.pages.account.2fa.title" />
|
<Text id="app.settings.pages.account.2fa.title" />
|
||||||
</h3>
|
</h3>
|
||||||
<h5>
|
<h5>
|
||||||
{/*<Text id="app.settings.pages.account.2fa.description" />*/}
|
<Text id="app.settings.pages.account.2fa.description" />
|
||||||
Two-factor authentication is currently in-development, see{" "}
|
|
||||||
<a href="https://github.com/revoltchat/revite/issues/675">
|
|
||||||
tracking issue here
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<CategoryButton
|
<CategoryButton
|
||||||
|
icon={<ListOl size={24} />}
|
||||||
|
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"}
|
||||||
|
</CategoryButton>
|
||||||
|
|
||||||
|
{JSON.stringify(mfa, undefined, 4)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*<CategoryButton
|
||||||
icon={<Lock size={24} color="var(--error)" />}
|
icon={<Lock size={24} color="var(--error)" />}
|
||||||
description={"Set up 2FA on your account."}
|
description={"Set up 2FA on your account."}
|
||||||
disabled
|
disabled
|
||||||
action={<Text id="general.unavailable" />}>
|
action={<Text id="general.unavailable" />}>
|
||||||
Set up Two-factor authentication
|
Set up Two-factor authentication
|
||||||
</CategoryButton>
|
</CategoryButton>*/
|
||||||
{/*<CategoryButton
|
/*<CategoryButton
|
||||||
icon={<ListOl size={24} />}
|
icon={<ListOl size={24} />}
|
||||||
description={"View and download your 2FA backup codes."}
|
description={"View and download your 2FA backup codes."}
|
||||||
disabled
|
disabled
|
||||||
action="chevron">
|
action="chevron">
|
||||||
View my backup codes
|
View my backup codes
|
||||||
</CategoryButton>*/}
|
</CategoryButton>*/
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,6 +2,4 @@ import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import { modalController } from ".";
|
import { modalController } from ".";
|
||||||
|
|
||||||
export default observer(() => {
|
export default observer(() => modalController.rendered);
|
||||||
return modalController.render();
|
|
||||||
});
|
|
||||||
|
|
|
@ -22,12 +22,18 @@ import { noopTrue } from "../../../lib/js";
|
||||||
|
|
||||||
import { ModalProps } from "../types";
|
import { ModalProps } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of MFA methods to icons
|
||||||
|
*/
|
||||||
const ICONS: Record<API.MFAMethod, React.FC<any>> = {
|
const ICONS: Record<API.MFAMethod, React.FC<any>> = {
|
||||||
Password: Keyboard,
|
Password: Keyboard,
|
||||||
Totp: Key,
|
Totp: Key,
|
||||||
Recovery: Archive,
|
Recovery: Archive,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for handling challenge entry
|
||||||
|
*/
|
||||||
function ResponseEntry({
|
function ResponseEntry({
|
||||||
type,
|
type,
|
||||||
value,
|
value,
|
||||||
|
|
75
src/context/modals/components/MFARecovery.tsx
Normal file
75
src/context/modals/components/MFARecovery.tsx
Normal file
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
title="Your recovery codes"
|
||||||
|
description={"Please save these to a safe location."}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
palette: "primary",
|
||||||
|
children: "Done",
|
||||||
|
onClick: noopTrue,
|
||||||
|
confirmation: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
palette: "plain",
|
||||||
|
children: "Reset",
|
||||||
|
onClick: reset,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClose={onClose}>
|
||||||
|
<List>
|
||||||
|
{known.map((code) => (
|
||||||
|
<span>{code}</span>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 { ulid } from "ulid";
|
||||||
|
|
||||||
import MFAFlow from "./components/MFAFlow";
|
import MFAFlow from "./components/MFAFlow";
|
||||||
|
import MFARecovery from "./components/MFARecovery";
|
||||||
import Test from "./components/Test";
|
import Test from "./components/Test";
|
||||||
import { Modal } from "./types";
|
import { Modal } from "./types";
|
||||||
|
|
||||||
|
@ -17,15 +25,19 @@ class ModalController<T extends Modal> {
|
||||||
constructor(components: Components) {
|
constructor(components: Components) {
|
||||||
this.components = components;
|
this.components = components;
|
||||||
|
|
||||||
makeAutoObservable(this);
|
makeObservable(this, {
|
||||||
this.pop = this.pop.bind(this);
|
stack: observable,
|
||||||
|
push: action,
|
||||||
|
remove: action,
|
||||||
|
rendered: computed,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a new modal on the stack
|
* Display a new modal on the stack
|
||||||
* @param modal Modal data
|
* @param modal Modal data
|
||||||
*/
|
*/
|
||||||
@action push(modal: T) {
|
push(modal: T) {
|
||||||
this.stack = [
|
this.stack = [
|
||||||
...this.stack,
|
...this.stack,
|
||||||
{
|
{
|
||||||
|
@ -36,28 +48,57 @@ class ModalController<T extends Modal> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the top modal from the stack
|
* Remove the keyed modal from the stack
|
||||||
*/
|
*/
|
||||||
@action pop() {
|
remove(key: string) {
|
||||||
this.stack = this.stack.slice(0, this.stack.length - 1);
|
this.stack = this.stack.filter((x) => x.key !== key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render modals
|
* Render modals
|
||||||
*/
|
*/
|
||||||
@computed render() {
|
get rendered() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{this.stack.map((modal) => {
|
{this.stack.map((modal) => {
|
||||||
const Component = this.components[modal.type];
|
const Component = this.components[modal.type];
|
||||||
return <Component {...modal} onClose={this.pop} />;
|
return (
|
||||||
|
<Component
|
||||||
|
{...modal}
|
||||||
|
onClose={() => this.remove(modal.key!)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modalController = new ModalController<Modal>({
|
/**
|
||||||
|
* Modal controller with additional helpers.
|
||||||
|
*/
|
||||||
|
class ModalControllerExtended extends ModalController<Modal> {
|
||||||
|
/**
|
||||||
|
* 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_flow: MFAFlow,
|
||||||
|
mfa_recovery: MFARecovery,
|
||||||
test: Test,
|
test: Test,
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,6 +17,7 @@ export type Modal = {
|
||||||
callback: (response: API.MFAResponse) => void;
|
callback: (response: API.MFAResponse) => void;
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
| { type: "mfa_recovery"; codes: string[]; client: Client }
|
||||||
| {
|
| {
|
||||||
type: "test";
|
type: "test";
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue