feat: Add setup 2FA

This commit is contained in:
Myzel394 2023-02-26 11:22:35 +01:00
parent 31288c9e79
commit 564861fc9f
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
11 changed files with 309 additions and 17 deletions

View File

@ -45,6 +45,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.0.0", "react-i18next": "^12.0.0",
"react-icons": "^4.4.0", "react-icons": "^4.4.0",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.4.2", "react-router-dom": "^6.4.2",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"secure-random-password": "^0.2.3", "secure-random-password": "^0.2.3",
@ -61,7 +62,6 @@
"@types/deep-equal": "^1.0.1", "@types/deep-equal": "^1.0.1",
"@types/jest": "^29.2.4", "@types/jest": "^29.2.4",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/node": "^18.14.0",
"@types/openpgp": "^4.4.18", "@types/openpgp": "^4.4.18",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",

View File

@ -291,7 +291,23 @@
}, },
"2fa": { "2fa": {
"title": "Two-Factor-Authentication", "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": { "LogoutRoute": {

View File

@ -1,9 +1,8 @@
import {RouterProvider, createBrowserRouter} from "react-router-dom" import {RouterProvider, createBrowserRouter} from "react-router-dom"
import {SnackbarProvider} from "notistack" import {SnackbarProvider} from "notistack"
import React, {ReactElement} from "react"
import {QueryClientProvider} from "@tanstack/react-query" import {QueryClientProvider} from "@tanstack/react-query"
import {CssBaseline, Theme, ThemeProvider} from "@mui/material" import {CssBaseline, Theme, ThemeProvider} from "@mui/material"
import React, {ReactElement} from "react"
import {queryClient} from "~/constants/react-query" import {queryClient} from "~/constants/react-query"
import {getServerSettings} from "~/apis" import {getServerSettings} from "~/apis"

View File

@ -58,3 +58,9 @@ export * from "./delete-reserved-alias"
export {default as deleteReservedAlias} from "./delete-reserved-alias" export {default as deleteReservedAlias} from "./delete-reserved-alias"
export * from "./get-latest-cron-report" export * from "./get-latest-cron-report"
export {default as getLatestCronReport} 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"

View File

@ -0,0 +1,19 @@
import {client} from "~/constants/axios-client"
export interface Verify2FASetupData {
code: string
}
export default async function verify2FASetup({code}: Verify2FASetupData): Promise<void> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/verify`,
{
code,
},
{
withCredentials: true,
},
)
return data
}

18
src/apis/setup-2fa.ts Normal file
View File

@ -0,0 +1,18 @@
import {client} from "~/constants/axios-client"
export interface Setup2FAResponse {
secret: string
recoveryCodes: string[]
}
export default async function setup2FA(): Promise<Setup2FAResponse> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/`,
{},
{
withCredentials: true,
},
)
return data
}

View File

@ -8,7 +8,7 @@ import {parseFastAPIError} from "~/utils"
export interface UseErrorSuccessSnacksResult { export interface UseErrorSuccessSnacksResult {
showSuccess: (message: string) => void showSuccess: (message: string) => void
showError: (error: Error) => void showError: (error: Error | string) => void
} }
export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult { export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
@ -27,14 +27,16 @@ export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
autoHideDuration: SUCCESS_SNACKBAR_SHOW_DURATION, autoHideDuration: SUCCESS_SNACKBAR_SHOW_DURATION,
}) })
} }
const showError = (error: Error) => { const showError = (error: Error | string) => {
let message let message
try { if (typeof error !== "string") {
const parsedError = parseFastAPIError(error as AxiosError) try {
const parsedError = parseFastAPIError(error as AxiosError)
message = parsedError.detail message = parsedError.detail
} catch (e) {} } catch (e) {}
}
$errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), { $errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), {
variant: "error", variant: "error",

View File

@ -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<Setup2FAResponse, AxiosError, void>(setup2FA, {
onError: showError,
})
return (
<Grid container spacing={4} direction="column">
<Grid item>
<Typography variant="body1">
{t("routes.SettingsRoute.2fa.setup.description")}
</Typography>
</Grid>
<Grid item alignSelf="center">
{secret ? (
<VerifyOTPForm
onRecreateRequired={() => {
reset()
mutate()
}}
secret={secret}
recoveryCodes={recoveryCodes!}
onSuccess={onSuccess}
/>
) : (
<LoadingButton
onClick={() => mutate()}
loading={isLoading}
variant="contained"
startIcon={<BsShieldLockFill />}
>
{t("routes.SettingsRoute.2fa.setup.setupLabel")}
</LoadingButton>
)}
</Grid>
</Grid>
)
}

View File

@ -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<boolean>(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<void, AxiosError, Verify2FASetupData>(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<Verify2FASetupData>({
initialValues: {
code: "",
},
validationSchema: schema,
onSubmit: async (values, {setErrors}) => {
try {
schema.validateSync(values)
await mutateAsync(values)
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
return (
<>
<form onSubmit={formik.handleSubmit}>
<Grid container spacing={4} direction="column">
<Grid item alignSelf="center">
<div style={{background: "white", padding: "2rem"}}>
<QRCode value={generateOTPAuthUri(secret, user.email.address)} />
</div>
</Grid>
<Grid item>
<Grid container alignItems="center" spacing={2} direction="row">
<Grid item>
<TextField
fullWidth
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={!!formik.errors.code}
helperText={formik.errors.code}
name="code"
label={t("routes.SettingsRoute.2fa.setup.code.label")}
disabled={formik.isSubmitting}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<BsShieldLockFill />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<LoadingButton
type="submit"
variant="contained"
loading={formik.isSubmitting}
>
{t("routes.SettingsRoute.2fa.setup.submit")}
</LoadingButton>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
<Dialog open={showRecoveryCodes} onClose={() => setShowRecoveryCodes(false)}>
<DialogTitle>{t("routes.SettingsRoute.2fa.setup.recoveryCodes.title")}</DialogTitle>
<DialogContent
sx={{
background: theme.palette.background.default,
}}
>
<code>
{recoveryCodes.map(code => (
<p key={code}>{code}</p>
))}
</code>
<Alert severity="warning">
{t("routes.SettingsRoute.2fa.setup.recoveryCodes.description")}
</Alert>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setShowRecoveryCodes(false)
onSuccess()
}}
>
{t("routes.SettingsRoute.2fa.setup.recoveryCodes.submit")}
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -6,11 +6,13 @@ import {useQuery} from "@tanstack/react-query"
import {Alert} from "@mui/material" import {Alert} from "@mui/material"
import {QueryResult, SimplePageBuilder} from "~/components" import {QueryResult, SimplePageBuilder} from "~/components"
import Setup2FA from "~/route-widgets/Settings2FARoute/Setup2FA"
import getHas2FAEnabled from "~/apis/get-has-2fa-enabled" import getHas2FAEnabled from "~/apis/get-has-2fa-enabled"
export default function Settings2FARoute(): ReactElement { export default function Settings2FARoute(): ReactElement {
const {t} = useTranslation() const {t} = useTranslation()
const query = useQuery<boolean, AxiosError>(["get_2fa_enabled"], getHas2FAEnabled) const queryKey = ["get_2fa_enabled"]
const query = useQuery<boolean, AxiosError>(queryKey, getHas2FAEnabled)
return ( return (
<SimplePageBuilder.Page title={t("routes.SettingsRoute.2fa.title")}> <SimplePageBuilder.Page title={t("routes.SettingsRoute.2fa.title")}>
@ -23,7 +25,7 @@ export default function Settings2FARoute(): ReactElement {
</Alert> </Alert>
</> </>
) : ( ) : (
<></> <Setup2FA onSuccess={query.refetch} />
) )
} }
</QueryResult> </QueryResult>

View File

@ -1406,11 +1406,6 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.5.tgz#6a31f820c1077c3f8ce44f9e203e68a176e8f59e" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.5.tgz#6a31f820c1077c3f8ce44f9e203e68a176e8f59e"
integrity sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q== 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": "@types/openpgp@^4.4.18":
version "4.4.18" version "4.4.18"
resolved "https://registry.yarnpkg.com/@types/openpgp/-/openpgp-4.4.18.tgz#b523e7a97646069756a01faf0569e198fe4d0dc1" 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" resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3"
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== 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: querystringify@^2.1.1:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" 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" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== 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: react-refresh@^0.14.0:
version "0.14.0" version "0.14.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"