diff --git a/package.json b/package.json index 9044539..45423c9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-dom": "^18.2.0", "react-i18next": "^12.0.0", "react-icons": "^4.4.0", + "react-qr-code": "^2.0.11", "react-router-dom": "^6.4.2", "react-use": "^17.4.0", "secure-random-password": "^0.2.3", @@ -61,7 +62,6 @@ "@types/deep-equal": "^1.0.1", "@types/jest": "^29.2.4", "@types/lodash": "^4.14.191", - "@types/node": "^18.14.0", "@types/openpgp": "^4.4.18", "@types/react-dom": "^18.0.11", "@types/react-icons": "^3.0.0", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index e7d8963..9bce3e8 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -291,7 +291,23 @@ }, "2fa": { "title": "Two-Factor-Authentication", - "alreadyEnabled": "You have successfully enabled 2FA!" + "alreadyEnabled": "You have successfully enabled 2FA!", + "setup": { + "description": "Enable 2FA to add an extra layer of security to your account. Each time you log in, you will need to enter a code generated from your authenticator app. This makes it harder for an attacker to hack into your account as they would need to have access to your phone.", + "setupLabel": "Enable 2FA", + "code": { + "label": "Code", + "description": "Enter the code generated by your authenticator app.", + "onlyDigits": "The code can only contain digits." + }, + "submit": "Enable 2FA", + "expired": "The verification time for your current Two-Factor-Authentication code has expired. A new code has been generated.", + "recoveryCodes": { + "title": "Note down your recovery codes", + "description": "These codes are used to recover your account if you lose access to your authenticator app. Note them down and store them in a safe place. You will not be able to view them again. Do not store them in your password manager. IF YOU LOSE YOUR RECOVERY CODES, YOU WILL LOSE ACCESS TO YOUR ACCOUNT. WE WILL NOT BE ABLE TO HELP YOU.", + "submit": "I have noted down my recovery codes" + } + } } }, "LogoutRoute": { diff --git a/src/App.tsx b/src/App.tsx index dcdef42..79c00e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,8 @@ import {RouterProvider, createBrowserRouter} from "react-router-dom" import {SnackbarProvider} from "notistack" -import React, {ReactElement} from "react" - import {QueryClientProvider} from "@tanstack/react-query" import {CssBaseline, Theme, ThemeProvider} from "@mui/material" +import React, {ReactElement} from "react" import {queryClient} from "~/constants/react-query" import {getServerSettings} from "~/apis" diff --git a/src/apis/index.ts b/src/apis/index.ts index fbb1e32..1a76ae7 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -58,3 +58,9 @@ export * from "./delete-reserved-alias" export {default as deleteReservedAlias} from "./delete-reserved-alias" export * from "./get-latest-cron-report" export {default as getLatestCronReport} from "./get-latest-cron-report" +export * from "./get-has-2fa-enabled" +export {default as getHas2FAEnabled} from "./get-has-2fa-enabled" +export * from "./setup-2fa" +export {default as setup2FA} from "./setup-2fa" +export * from "./setup-2fa-verify" +export {default as verify2FASetup} from "./setup-2fa-verify" diff --git a/src/apis/setup-2fa-verify.ts b/src/apis/setup-2fa-verify.ts new file mode 100644 index 0000000..372c13a --- /dev/null +++ b/src/apis/setup-2fa-verify.ts @@ -0,0 +1,19 @@ +import {client} from "~/constants/axios-client" + +export interface Verify2FASetupData { + code: string +} + +export default async function verify2FASetup({code}: Verify2FASetupData): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/verify`, + { + code, + }, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/apis/setup-2fa.ts b/src/apis/setup-2fa.ts new file mode 100644 index 0000000..4d3f03b --- /dev/null +++ b/src/apis/setup-2fa.ts @@ -0,0 +1,18 @@ +import {client} from "~/constants/axios-client" + +export interface Setup2FAResponse { + secret: string + recoveryCodes: string[] +} + +export default async function setup2FA(): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/`, + {}, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/hooks/use-error-success-snacks.ts b/src/hooks/use-error-success-snacks.ts index 009b244..8656c73 100644 --- a/src/hooks/use-error-success-snacks.ts +++ b/src/hooks/use-error-success-snacks.ts @@ -8,7 +8,7 @@ import {parseFastAPIError} from "~/utils" export interface UseErrorSuccessSnacksResult { showSuccess: (message: string) => void - showError: (error: Error) => void + showError: (error: Error | string) => void } export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult { @@ -27,14 +27,16 @@ export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult { autoHideDuration: SUCCESS_SNACKBAR_SHOW_DURATION, }) } - const showError = (error: Error) => { + const showError = (error: Error | string) => { let message - try { - const parsedError = parseFastAPIError(error as AxiosError) + if (typeof error !== "string") { + try { + const parsedError = parseFastAPIError(error as AxiosError) - message = parsedError.detail - } catch (e) {} + message = parsedError.detail + } catch (e) {} + } $errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), { variant: "error", diff --git a/src/route-widgets/Settings2FARoute/Setup2FA.tsx b/src/route-widgets/Settings2FARoute/Setup2FA.tsx new file mode 100644 index 0000000..327e772 --- /dev/null +++ b/src/route-widgets/Settings2FARoute/Setup2FA.tsx @@ -0,0 +1,64 @@ +import {ReactElement} from "react" +import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" +import {BsShieldLockFill} from "react-icons/bs" +// @ts-ignore +import {authenticator} from "@otplib/preset-browser" +import {useMutation} from "@tanstack/react-query" +import {LoadingButton} from "@mui/lab" +import {Grid, Typography} from "@mui/material" + +import {Setup2FAResponse, setup2FA} from "~/apis" +import {useErrorSuccessSnacks} from "~/hooks" + +import VerifyOTPForm from "./VerifyOTPForm" + +export interface Setup2FAProps { + onSuccess: () => void +} + +export default function Setup2FA({onSuccess}: Setup2FAProps): ReactElement { + const {t} = useTranslation() + const {showError} = useErrorSuccessSnacks() + + const { + data: {secret, recoveryCodes} = {}, + mutate, + isLoading, + reset, + } = useMutation(setup2FA, { + onError: showError, + }) + + return ( + + + + {t("routes.SettingsRoute.2fa.setup.description")} + + + + {secret ? ( + { + reset() + mutate() + }} + secret={secret} + recoveryCodes={recoveryCodes!} + onSuccess={onSuccess} + /> + ) : ( + mutate()} + loading={isLoading} + variant="contained" + startIcon={} + > + {t("routes.SettingsRoute.2fa.setup.setupLabel")} + + )} + + + ) +} diff --git a/src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx b/src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx new file mode 100644 index 0000000..7e59df3 --- /dev/null +++ b/src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx @@ -0,0 +1,158 @@ +import * as yup from "yup" +import {ReactElement, useState} from "react" +import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" +import {useFormik} from "formik" +import {BsShieldLockFill} from "react-icons/bs" +import QRCode from "react-qr-code" + +import {useMutation} from "@tanstack/react-query" +import {LoadingButton} from "@mui/lab" + +import {Verify2FASetupData, verify2FASetup} from "~/apis" +import {useErrorSuccessSnacks, useUser} from "~/hooks" +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + InputAdornment, + TextField, +} from "@mui/material" +import {parseFastAPIError} from "~/utils" + +export interface VerifyOTPFormProps { + onSuccess: () => void + onRecreateRequired: () => void + secret: string + recoveryCodes: string[] +} + +const generateOTPAuthUri = (secret: string, email: string): string => + `otpauth://totp/KleckRelay:${email}?secret=${secret}&issuer=KleckRelay` + +export default function Settings2FARoute({ + onSuccess, + recoveryCodes, + onRecreateRequired, + secret, +}: VerifyOTPFormProps): ReactElement { + const {t} = useTranslation() + const {showError} = useErrorSuccessSnacks() + const user = useUser() + const theme = useTheme() + + const [showRecoveryCodes, setShowRecoveryCodes] = useState(false) + + const schema = yup.object().shape({ + code: yup + .string() + .required() + .length(6) + .matches(/^[0-9]+$/, t("routes.SettingsRoute.2fa.setup.code.onlyDigits").toString()) + .label(t("routes.SettingsRoute.2fa.setup.code.label")), + }) + + const {mutateAsync} = useMutation(verify2FASetup, { + onSuccess: () => setShowRecoveryCodes(true), + onError: error => { + if (error.response?.status === 409 || error.response?.status === 410) { + showError(t("routes.SettingsRoute.2fa.setup.expired").toString()) + onRecreateRequired() + } else { + showError(error) + } + }, + }) + const formik = useFormik({ + initialValues: { + code: "", + }, + validationSchema: schema, + onSubmit: async (values, {setErrors}) => { + try { + schema.validateSync(values) + await mutateAsync(values) + } catch (error) { + setErrors(parseFastAPIError(error as AxiosError)) + } + }, + }) + + return ( + <> +
+ + +
+ +
+
+ + + + + + + ), + }} + /> + + + + {t("routes.SettingsRoute.2fa.setup.submit")} + + + + +
+
+ setShowRecoveryCodes(false)}> + {t("routes.SettingsRoute.2fa.setup.recoveryCodes.title")} + + + {recoveryCodes.map(code => ( +

{code}

+ ))} +
+ + {t("routes.SettingsRoute.2fa.setup.recoveryCodes.description")} + +
+ + + +
+ + ) +} diff --git a/src/routes/Settings2FARoute.tsx b/src/routes/Settings2FARoute.tsx index 0f7e9c9..8595c14 100644 --- a/src/routes/Settings2FARoute.tsx +++ b/src/routes/Settings2FARoute.tsx @@ -6,11 +6,13 @@ import {useQuery} from "@tanstack/react-query" import {Alert} from "@mui/material" import {QueryResult, SimplePageBuilder} from "~/components" +import Setup2FA from "~/route-widgets/Settings2FARoute/Setup2FA" import getHas2FAEnabled from "~/apis/get-has-2fa-enabled" export default function Settings2FARoute(): ReactElement { const {t} = useTranslation() - const query = useQuery(["get_2fa_enabled"], getHas2FAEnabled) + const queryKey = ["get_2fa_enabled"] + const query = useQuery(queryKey, getHas2FAEnabled) return ( @@ -23,7 +25,7 @@ export default function Settings2FARoute(): ReactElement { ) : ( - <> + ) } diff --git a/yarn.lock b/yarn.lock index cc78031..4d7337a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1406,11 +1406,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.5.tgz#6a31f820c1077c3f8ce44f9e203e68a176e8f59e" integrity sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q== -"@types/node@^18.14.0": - version "18.14.0" - resolved "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz#94c47b9217bbac49d4a67a967fdcdeed89ebb7d0" - integrity sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A== - "@types/openpgp@^4.4.18": version "4.4.18" resolved "https://registry.yarnpkg.com/@types/openpgp/-/openpgp-4.4.18.tgz#b523e7a97646069756a01faf0569e198fe4d0dc1" @@ -4643,6 +4638,11 @@ pvutils@^1.1.3: resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ== + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -4699,6 +4699,14 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-qr-code@^2.0.11: + version "2.0.11" + resolved "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.11.tgz#444c759a2100424972e17135fbe0e32eaffa19e8" + integrity sha512-P7mvVM5vk9NjGdHMt4Z0KWeeJYwRAtonHTghZT2r+AASinLUUKQ9wfsGH2lPKsT++gps7hXmaiMGRvwTDEL9OA== + dependencies: + prop-types "^15.8.1" + qr.js "0.0.0" + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"