Enable / Disable 2FA

This commit is contained in:
2024-08-15 01:16:09 +08:00
parent 4b72e614fa
commit 19198537af
6 changed files with 329 additions and 61 deletions

View File

@@ -13,11 +13,11 @@ import config from "../config";
import NextUIFormikInput from "./NextUIFormikInput"; import NextUIFormikInput from "./NextUIFormikInput";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ChevronLeftIcon } from "../icons"; import { ChevronLeftIcon } from "../icons";
import { popErrorToast, popToast } from "../utilities"; import { checkTwoFactorStatus, popErrorToast, popToast } from "../utilities";
import { retrieveUserInformation } from "../security/users"; import { retrieveUserInformation } from "../security/users";
import instance from "../security/http"; import instance from "../security/http";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import AuthCode, { AuthCodeRef } from "react-auth-code-input"; import TwoFactorAuthenticationModule from "./TwoFactorAuthenticationModule";
const validationSchema = Yup.object({ const validationSchema = Yup.object({
email: Yup.string() email: Yup.string()
@@ -42,10 +42,6 @@ export default function SignInModule() {
const [twoFactorModal, setTwoFactorModal] = useState(false); const [twoFactorModal, setTwoFactorModal] = useState(false);
const [userLoginInformation, setUserLoginInformation] = useState<any>(); const [userLoginInformation, setUserLoginInformation] = useState<any>();
const [twoFactorToken, setTwoFactorToken] = useState("");
const [twoFactorVerifying, setTwoFactorVerifying] = useState(false);
const AuthInputRef = useRef<AuthCodeRef>(null);
const initialValues = { const initialValues = {
email: "", email: "",
@@ -85,10 +81,9 @@ export default function SignInModule() {
const handleSubmit = (values: any): void => { const handleSubmit = (values: any): void => {
setUserLoginInformation(values); setUserLoginInformation(values);
instance checkTwoFactorStatus(values.email)
.post("/users/has-2fa", { email: values.email })
.then((answer) => { .then((answer) => {
if (answer.data.enabled) { if (answer) {
setTwoFactorModal(true); setTwoFactorModal(true);
} else { } else {
proceedWithLogin(values); proceedWithLogin(values);
@@ -99,32 +94,6 @@ export default function SignInModule() {
}); });
}; };
useEffect(() => {
if (!(twoFactorToken.length == 6 && !twoFactorVerifying)) {
return;
}
setTwoFactorVerifying(true);
instance
.post("/users/verify-2fa", {
email: userLoginInformation.email,
token: twoFactorToken,
})
.then(() => {
proceedWithLogin();
})
.catch((error) => {
popErrorToast(error);
AuthInputRef.current?.clear();
setTwoFactorToken("");
})
.finally(() => {
AuthInputRef.current?.clear();
setTwoFactorToken("");
setTwoFactorVerifying(false);
});
}, [twoFactorToken]);
return ( return (
<div className="flex flex-col gap-16"> <div className="flex flex-col gap-16">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -190,22 +159,12 @@ export default function SignInModule() {
<ModalContent> <ModalContent>
<ModalHeader>Two-Factor Authentication</ModalHeader> <ModalHeader>Two-Factor Authentication</ModalHeader>
<ModalBody> <ModalBody>
<div className="text-center flex flex-col gap-4"> {userLoginInformation && (
<AuthCode <TwoFactorAuthenticationModule
containerClassName="flex flex-row gap-4 w-full justify-center" email={userLoginInformation.email}
inputClassName="w-16 h-16 text-4xl text-center rounded-lg my-2 bg-neutral-100 dark:bg-neutral-800 border-2 border-neutral-200 dark:border-neutral-700 " onTwoFactorSuccess={proceedWithLogin}
length={6}
allowedCharacters="numeric"
ref={AuthInputRef}
disabled={twoFactorVerifying}
onChange={(value) => {
setTwoFactorToken(value);
}}
/> />
<p className="text-md opacity-50"> )}
Please enter the 6 digits passcode from your authenticator app.
</p>
</div>
</ModalBody> </ModalBody>
<ModalFooter></ModalFooter> <ModalFooter></ModalFooter>
</ModalContent> </ModalContent>

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from "react";
import AuthCode, { AuthCodeRef } from "react-auth-code-input";
import instance from "../security/http";
import { popErrorToast } from "../utilities";
export default function TwoFactorsAuthenticationModule({
email,
onTwoFactorSuccess,
}: {
email: string;
onTwoFactorSuccess: () => void;
}) {
const AuthInputRef = useRef<AuthCodeRef>(null);
const [twoFactorToken, setTwoFactorToken] = useState("");
const [twoFactorVerifying, setTwoFactorVerifying] = useState(false);
useEffect(() => {
if (!(twoFactorToken.length == 6 && !twoFactorVerifying)) {
return;
}
setTwoFactorVerifying(true);
instance
.post("/users/verify-2fa", {
email: email,
token: twoFactorToken,
})
.then(() => {
onTwoFactorSuccess();
})
.catch((error) => {
popErrorToast(error);
AuthInputRef.current?.clear();
setTwoFactorToken("");
})
.finally(() => {
AuthInputRef.current?.clear();
setTwoFactorToken("");
setTwoFactorVerifying(false);
});
}, [twoFactorToken]);
return (
<div className="text-center flex flex-col gap-4">
<AuthCode
containerClassName="flex flex-row gap-4 w-full justify-center"
inputClassName="w-16 h-16 text-4xl text-center rounded-lg my-2 bg-neutral-100 dark:bg-neutral-800 border-2 border-neutral-200 dark:border-neutral-700 "
length={6}
allowedCharacters="numeric"
ref={AuthInputRef}
disabled={twoFactorVerifying}
onChange={(value) => {
setTwoFactorToken(value);
}}
/>
<p className="text-md opacity-50">
Please enter the 6 digits passcode from your authenticator app.
</p>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { retrieveUserInformation } from "../security/users";
import { useEffect, useState } from "react";
import { Button, Image, Input } from "@nextui-org/react";
import instance from "../security/http";
import { checkTwoFactorStatus } from "../utilities";
import TwoFactorAuthenticationModule from "./TwoFactorAuthenticationModule";
export default function TwoFactorsAuthenticationSetupModule({
onClose,
}: {
onClose: () => void;
}) {
const [userInformation, setUserInformation] = useState<any>();
const [setup2FAStepperCount, setSetup2FAStepperCount] = useState(0);
const [disable2FAStepperCount, setDisable2FAStepperCount] = useState(0);
const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false);
const [setupQRBase64, setSetupQRBase64] = useState("");
const [setupBase32Secret, setSetupBase32Secret] = useState("");
const disableTwoFactor = async () => {
instance
.post("/users/disable-2fa", {
id: userInformation.id,
})
.then(() => {
setDisable2FAStepperCount(1);
});
};
const enableTwoFactor = () => {
instance
.post("/users/enable-2fa/" + userInformation.id)
.then((response) => {
setSetupQRBase64(response.data.data.qrCodeUrl);
setSetupBase32Secret(response.data.data.secret);
setSetup2FAStepperCount(1);
});
};
const testTwoFactor = () => {};
useEffect(() => {
retrieveUserInformation().then((response) => {
setUserInformation(response);
checkTwoFactorStatus(response.email).then((answer) => {
setIsTwoFactorEnabled(answer);
});
});
}, []);
return (
<div>
{userInformation && (
<>
{!isTwoFactorEnabled && (
<div>
{setup2FAStepperCount === 0 && (
<div className="flex flex-col gap-4">
<p>
This setup will guide you through the enabling of
Two-Factors Authorization (2FA).
</p>
<Button
onPress={() => {
enableTwoFactor();
}}
>
Continue
</Button>
</div>
)}
{setup2FAStepperCount === 1 && (
<div className="flex flex-col gap-4">
<p>
Please scan the QR code below using an authenticator app of
your choice.
</p>
{setupQRBase64 && (
<Image src={setupQRBase64} alt="2FA SETUP QR" />
)}
<p>Or alternatively, manually enter the secret in the app:</p>
<Input value={setupBase32Secret} readOnly />
<div className="w-full flex flex-row justify-end">
<Button
onPress={() => {
setSetup2FAStepperCount(2);
}}
>
Continue
</Button>
</div>
</div>
)}
{setup2FAStepperCount === 2 && (
<div className="flex flex-col gap-4">
<p>Let's give it a try.</p>
<TwoFactorAuthenticationModule
email={userInformation.email}
onTwoFactorSuccess={() => {
setSetup2FAStepperCount(3);
}}
/>
<div className="w-full flex flex-row justify-end">
<Button
variant="light"
onPress={() => {
setSetup2FAStepperCount(3);
}}
>
Skip
</Button>
<Button
onPress={() => {
enableTwoFactor();
}}
>
Setup again
</Button>
</div>
</div>
)}
{setup2FAStepperCount === 3 && (
<div className="flex flex-col gap-4">
<p>
All set! You will be asked to provide the passcode next time
you log in.
</p>
<div className="w-full flex flex-row justify-end">
<Button onPress={onClose}>Finish</Button>
</div>
</div>
)}
</div>
)}
{isTwoFactorEnabled && (
<div>
{disable2FAStepperCount === 0 && (
<div className="flex flex-col gap-4 w-full">
<p>
Are you sure you want to disable Two-Factors Authorization
(2FA)?
</p>
<div className="flex flex-row gap-2 w-full justify-end">
<Button
variant="light"
color="danger"
onPress={() => {
disableTwoFactor();
}}
>
Confirm
</Button>
<Button color="primary" onPress={onClose}>
Cancel
</Button>
</div>
</div>
)}
{disable2FAStepperCount === 1 && (
<div className="flex flex-col gap-4 w-full">
<p>2FA has been disabled.</p>
<div className="w-full flex flex-row justify-end">
<Button onPress={onClose}>Finish</Button>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -18,15 +18,17 @@ import { Form, Formik } from "formik";
import NextUIFormikInput from "./NextUIFormikInput"; import NextUIFormikInput from "./NextUIFormikInput";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import UserProfilePicture from "./UserProfilePicture"; import UserProfilePicture from "./UserProfilePicture";
import { popErrorToast, popToast } from "../utilities"; import { checkTwoFactorStatus, popErrorToast, popToast } from "../utilities";
import instance from "../security/http"; import instance from "../security/http";
import axios from "axios"; import axios from "axios";
import NextUIFormikSelect from "./NextUIFormikSelect"; import NextUIFormikSelect from "./NextUIFormikSelect";
import TwoFactorsAuthenticationSetupModule from "./TwoFactorsAuthenticationSetupModule";
export default function UpdateAccountModule() { export default function UpdateAccountModule() {
const navigate = useNavigate(); const navigate = useNavigate();
const [userInformation, setUserInformation] = useState<any>(); const [userInformation, setUserInformation] = useState<any>();
const [townCouncils, setTownCouncils] = useState<string[]>([]); const [townCouncils, setTownCouncils] = useState<string[]>([]);
const [is2FAEnabled, setIs2FAEnabled] = useState(false);
const { const {
isOpen: isArchiveDialogOpen, isOpen: isArchiveDialogOpen,
@@ -40,6 +42,12 @@ export default function UpdateAccountModule() {
onOpenChange: onResetPasswordOpenChange, onOpenChange: onResetPasswordOpenChange,
} = useDisclosure(); } = useDisclosure();
const {
isOpen: isTwoFactorsAuthenticationOpen,
onOpen: onTwoFactorsAuthenticationOpen,
onOpenChange: onTwoFactorsAuthenticationOpenChange,
} = useDisclosure();
useEffect(() => { useEffect(() => {
retrieveUserInformation() retrieveUserInformation()
.then((response) => { .then((response) => {
@@ -49,6 +57,9 @@ export default function UpdateAccountModule() {
.then((values) => { .then((values) => {
setTownCouncils(JSON.parse(values.data).townCouncils); setTownCouncils(JSON.parse(values.data).townCouncils);
}); });
checkTwoFactorStatus(response.email).then((answer) => {
setIs2FAEnabled(answer);
});
}) })
.catch(() => { .catch(() => {
navigate("/signin"); navigate("/signin");
@@ -266,7 +277,7 @@ export default function UpdateAccountModule() {
} }
className="rounded-xl -m-2 *:px-4" className="rounded-xl -m-2 *:px-4"
> >
<Card className="flex flex-row justify-between *:my-auto bg-primary-50 dark:bg-primary-950 p-4 my-2"> <Card className="flex flex-col gap-4 justify-between *:my-auto bg-primary-50 dark:bg-primary-950 p-4 my-2">
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-lg">Danger zone</p> <p className="text-lg">Danger zone</p>
<p className="opacity-50"> <p className="opacity-50">
@@ -275,17 +286,16 @@ export default function UpdateAccountModule() {
</div> </div>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<Button <Button
color="danger" color={is2FAEnabled ? "danger" : "secondary"}
variant="light" onPress={onTwoFactorsAuthenticationOpen}
onPress={onResetPasswordOpen}
> >
{is2FAEnabled ? "Disable" : "Enable"} Two-Factors
Authentication
</Button>
<Button color="danger" onPress={onResetPasswordOpen}>
Reset your password Reset your password
</Button> </Button>
<Button <Button color="danger" onPress={onArchiveDialogOpen}>
color="danger"
variant="flat"
onPress={onArchiveDialogOpen}
>
Archive this account Archive this account
</Button> </Button>
</div> </div>
@@ -379,6 +389,40 @@ export default function UpdateAccountModule() {
}} }}
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* Two-Factors Authorization Modal */}
<Modal
isOpen={isTwoFactorsAuthenticationOpen}
onOpenChange={onTwoFactorsAuthenticationOpenChange}
size="xl"
>
<ModalContent>
{(onClose) => {
return (
<>
<ModalHeader className="flex flex-col gap-1">
Set up Two-Factors Authentication
</ModalHeader>
<ModalBody>
<div className="w-full h-full pb-4">
<TwoFactorsAuthenticationSetupModule
onClose={() => {
checkTwoFactorStatus(userInformation.email)
.then((answer) => {
setIs2FAEnabled(answer);
})
.finally(() => {
onClose();
});
}}
/>
</div>
</ModalBody>
</>
);
}}
</ModalContent>
</Modal>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,6 +1,7 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import exportFromJSON, { ExportType } from "export-from-json"; import exportFromJSON, { ExportType } from "export-from-json";
import instance from "./security/http";
export function getTimeOfDay(): number { export function getTimeOfDay(): number {
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
@@ -48,3 +49,14 @@ export const exportData = (
) => { ) => {
exportFromJSON({ data, fileName, exportType }); exportFromJSON({ data, fileName, exportType });
}; };
export const checkTwoFactorStatus = async (email: string) => {
try {
const answer = await instance.post("/users/has-2fa", {
email: email,
});
return answer.data.enabled;
} catch {
return false;
}
};

View File

@@ -546,6 +546,27 @@ router.post("/enable-2fa/:id", async (req, res) => {
}); });
}); });
router.post("/disable-2fa/", async (req, res) => {
let id = req.body.id;
const user = User.findByPk(id);
if (!user) {
return res.status(404).send("User not found");
}
await User.update(
{ secret: null },
{
where: { id: id },
}
);
return res.json({
status: "success",
message: "2FA disabled",
});
});
router.post("/has-2fa", async (req, res) => { router.post("/has-2fa", async (req, res) => {
let email = req.body.email; let email = req.body.email;
const user = await User.findOne({ const user = await User.findOne({