feat: initial implementation for totp authenticator

This commit is contained in:
Sheen Capadngan
2024-11-14 00:07:35 +08:00
parent 2d2ad0724f
commit 6bdbac4750
36 changed files with 1250 additions and 117 deletions
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { useGetUserTotpRegistration } from "@app/hooks/api";
import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation";
import { createNotification } from "../notifications";
import { Button, ContentLoader, Input } from "../v2";
type Props = {
onComplete?: () => Promise<void>;
};
const TotpRegistration = ({ onComplete }: Props) => {
const { data: registration, isLoading } = useGetUserTotpRegistration();
const { mutateAsync: verifyUserTotp } = useVerifyUserTotpRegistration();
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [totp, setTotp] = useState("");
const handleTotpVerify = async () => {
await verifyUserTotp({
totp
});
createNotification({
text: "Successfully configured mobile authenticator",
type: "success"
});
if (onComplete) {
onComplete();
}
};
useEffect(() => {
const generateQRCode = async () => {
if (registration?.otpUrl) {
const url = await QRCode.toDataURL(registration.otpUrl);
setQrCodeUrl(url);
}
};
generateQRCode();
}, [registration]);
if (isLoading) {
return <ContentLoader />;
}
return (
<div className="flex max-w-sm flex-col text-bunker-200">
<div className="mb-4 text-center">
Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR code.
</div>
<div className="mb-10 flex items-center justify-center">
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<div className="mb-4 text-center">Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button onClick={handleTotpVerify}>Enable MFA</Button>
</div>
</div>
);
};
export default TotpRegistration;
+26 -8
View File
@@ -19,6 +19,7 @@ import {
Login2Res,
LoginLDAPDTO,
LoginLDAPRes,
MfaMethod,
ResetPasswordDTO,
SendMfaTokenDTO,
SRP1DTO,
@@ -65,10 +66,11 @@ export const selectOrganization = async (data: {
organizationId: string;
userAgent?: UserAgentType;
}) => {
const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean }>(
"/api/v3/auth/select-organization",
data
);
const { data: res } = await apiRequest.post<{
token: string;
isMfaEnabled: boolean;
mfaMethod?: MfaMethod;
}>("/api/v3/auth/select-organization", data);
return res;
};
@@ -154,10 +156,19 @@ export const useSendMfaToken = () => {
});
};
export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCode: string }) => {
export const verifyMfaToken = async ({
email,
mfaCode,
mfaMethod
}: {
email: string;
mfaCode: string;
mfaMethod: string;
}) => {
const { data } = await apiRequest.post("/api/v2/auth/mfa/verify", {
email,
mfaToken: mfaCode
mfaToken: mfaCode,
mfaMethod
});
return data;
@@ -165,10 +176,11 @@ export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCod
export const useVerifyMfaToken = () => {
return useMutation<VerifyMfaTokenRes, {}, VerifyMfaTokenDTO>({
mutationFn: async ({ email, mfaCode }) => {
mutationFn: async ({ email, mfaCode, mfaMethod }) => {
return verifyMfaToken({
email,
mfaCode
mfaCode,
mfaMethod
});
}
});
@@ -302,3 +314,9 @@ export const useGetAuthToken = () =>
onSuccess: (data) => setAuthToken(data.token),
retry: 0
});
export const checkUserTotpMfa = async () => {
const { data } = await apiRequest.get<{ isVerified: boolean }>("/api/v2/auth/mfa/check/totp");
return data.isVerified;
};
+6
View File
@@ -9,6 +9,7 @@ export type SendMfaTokenDTO = {
export type VerifyMfaTokenDTO = {
email: string;
mfaCode: string;
mfaMethod: MfaMethod;
};
export type VerifyMfaTokenRes = {
@@ -149,3 +150,8 @@ export type GetBackupEncryptedPrivateKeyDTO = {
export enum UserAgentType {
CLI = "cli"
}
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
}
+3 -2
View File
@@ -21,12 +21,13 @@ export {
useGetOrgUsers,
useGetUser,
useGetUserAction,
useGetUserTotpRegistration,
useListUserGroupMemberships,
useLogoutUser,
useRegisterUserAction,
useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgMembership,
useUpdateUserAuthMethods
useUpdateUserAuthMethods,
useUpdateUserMfa
} from "./queries";
export { userKeys } from "./query-keys";
+26
View File
@@ -114,3 +114,29 @@ export const useUpdateUserProjectFavorites = () => {
}
});
};
export const useVerifyUserTotpRegistration = () => {
return useMutation({
mutationFn: async ({ totp }: { totp: string }) => {
await apiRequest.post("/api/v1/user/me/totp/verify", {
totp
});
return {};
}
});
};
export const useDeleteUserTotpConfiguration = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await apiRequest.delete("/api/v1/user/me/totp");
return {};
},
onSuccess: () => {
queryClient.invalidateQueries(userKeys.totpConfiguration);
}
});
};
+48 -3
View File
@@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { apiRequest } from "@app/config/request";
import { SessionStorageKeys } from "@app/const";
import { setAuthToken } from "@app/reactQuery";
import { APIKeyDataV2 } from "../apiKeys/types";
import { MfaMethod } from "../auth/types";
import { TGroupWithProjectMemberships } from "../groups/types";
import { workspaceKeys } from "../workspace";
import { userKeys } from "./query-keys";
@@ -390,14 +392,21 @@ export const useRevokeMySessions = () => {
});
};
export const useUpdateMfaEnabled = () => {
export const useUpdateUserMfa = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ isMfaEnabled }: { isMfaEnabled: boolean }) => {
mutationFn: async ({
isMfaEnabled,
selectedMfaMethod
}: {
isMfaEnabled?: boolean;
selectedMfaMethod?: MfaMethod;
}) => {
const {
data: { user }
} = await apiRequest.patch("/api/v2/users/me/mfa", {
isMfaEnabled
isMfaEnabled,
selectedMfaMethod
});
return user;
@@ -446,3 +455,39 @@ export const useListUserGroupMemberships = (username: string) => {
}
});
};
export const useGetUserTotpRegistration = () => {
return useQuery({
queryKey: userKeys.totpRegistration,
queryFn: async () => {
const { data } = await apiRequest.post<{ otpUrl: string; recoveryCodes: string[] }>(
"/api/v1/user/me/totp/register"
);
return data;
}
});
};
export const useGetUserTotpConfiguration = () => {
return useQuery({
queryKey: userKeys.totpConfiguration,
queryFn: async () => {
try {
const { data } = await apiRequest.get<{ isVerified: boolean; recoveryCodes: string[] }>(
"/api/v1/user/me/totp"
);
return data;
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.statusCode === 404) {
return {
isVerified: false,
recoveryCodes: []
};
}
throw error;
}
}
});
};
@@ -16,6 +16,8 @@ export const userKeys = {
myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const,
totpRegistration: ["totp-registration"],
totpConfiguration: ["totp-configuration"],
listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
};
+2
View File
@@ -1,3 +1,4 @@
import { MfaMethod } from "../auth/types";
import { UserWsKeyPair } from "../keys/types";
import { ProjectUserMembershipTemporaryMode } from "../workspace/types";
@@ -26,6 +27,7 @@ export type User = {
authProvider?: AuthMethod;
authMethods: AuthMethod[];
isMfaEnabled: boolean;
selectedMfaMethod?: MfaMethod;
seenIps: string[];
id: string;
};
@@ -22,7 +22,7 @@ import {
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { UserAgentType } from "@app/hooks/api/auth/types";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { getAuthToken, isLoggedIn } from "@app/reactQuery";
@@ -46,6 +46,7 @@ export default function LoginPage() {
const selectOrg = useSelectOrganization();
const { data: user, isLoading: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
@@ -90,15 +91,17 @@ export default function LoginPage() {
return;
}
const { token, isMfaEnabled } = await selectOrg.mutateAsync({
const { token, isMfaEnabled, mfaMethod } = await selectOrg.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
@@ -213,7 +216,11 @@ export default function LoginPage() {
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Head>
{shouldShowMfa ? (
<Mfa email={user.email as string} successCallback={mfaSuccessCallback} />
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link href="/">
+86 -29
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input";
import Image from "next/image";
import Link from "next/link";
@@ -6,10 +6,12 @@ import { useRouter } from "next/router";
import { t } from "i18next";
import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button } from "@app/components/v2";
import { Button, Input } from "@app/components/v2";
import { useSendMfaToken } from "@app/hooks/api";
import { verifyMfaToken } from "@app/hooks/api/auth/queries";
import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
// The style for the verification code input
const codeInputProps = {
@@ -36,23 +38,37 @@ type Props = {
closeMfa?: () => void;
hideLogo?: boolean;
email: string;
method: MfaMethod;
};
export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
const [mfaCode, setMfaCode] = useState("");
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const sendMfaToken = useSendMfaToken();
useEffect(() => {
if (method === MfaMethod.TOTP) {
checkUserTotpMfa().then((isVerified) => {
if (!isVerified) {
SecurityClient.setMfaToken("");
setShouldShowTotpRegistration(true);
}
});
}
}, []);
const verifyMfa = async () => {
setIsLoading(true);
try {
const { token } = await verifyMfaToken({
email,
mfaCode
mfaCode,
mfaMethod: method
});
SecurityClient.setMfaToken("");
@@ -92,6 +108,23 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
}
};
if (shouldShowTotpRegistration) {
return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
<TotpRegistration
onComplete={async () => {
setShouldShowTotpRegistration(false);
await successCallback();
if (closeMfa) {
closeMfa();
}
}}
/>
</div>
);
}
return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
{!hideLogo && (
@@ -101,18 +134,40 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
</div>
</Link>
)}
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
{method === MfaMethod.EMAIL && (
<>
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
</>
)}
{method === MfaMethod.TOTP && (
<>
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
Authenticator MFA Required
</p>
<p className="text-l flex max-w-xs justify-center text-center text-bunker-300">
Open the authenticator app on your mobile device to get your verification code or enter
a recovery code.
</p>
</>
)}
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
{method === MfaMethod.EMAIL && (
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
)}
{method === MfaMethod.TOTP && (
<div className="mt-6 mb-4">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
</div>
)}
</div>
{typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
@@ -132,21 +187,23 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
</Button>
</div>
</div>
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<div className="flex flex-row items-baseline gap-1 text-sm">
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isLoadingResend
? t("signup.step2-resend-progress")
: t("signup.step2-resend-submit")}
</span>
</button>
{method === MfaMethod.EMAIL && (
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<div className="flex flex-row items-baseline gap-1 text-sm">
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isLoadingResend
? t("signup.step2-resend-progress")
: t("signup.step2-resend-submit")}
</span>
</button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
</div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
</div>
)}
</div>
);
};
@@ -1,18 +1,84 @@
import { useQueryClient } from "@tanstack/react-query";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications";
import { Checkbox, EmailServiceSetupModal } from "@app/components/v2";
import { useGetUser, useUpdateMfaEnabled } from "@app/hooks/api";
import {
Button,
Checkbox,
ContentLoader,
DeleteActionModal,
EmailServiceSetupModal,
FormControl,
Select,
SelectItem
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { useDeleteUserTotpConfiguration } from "@app/hooks/api/users/mutation";
import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => {
const { data: user } = useGetUser();
const { mutateAsync } = useUpdateMfaEnabled();
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
const { mutateAsync } = useUpdateUserMfa();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail",
"deleteTotpConfig"
] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration, isLoading: isTotpConfigurationLoading } =
useGetUserTotpConfiguration();
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus();
const handleTotpDeletion = async () => {
try {
await deleteTotpConfiguration();
createNotification({
text: "Successfully deleted mobile authenticator",
type: "success"
});
handlePopUpClose("deleteTotpConfig");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete mobile authenticator";
createNotification({
text,
type: "error"
});
}
};
const updateSelectedMfa = async (mfaMethod: MfaMethod) => {
try {
if (!user) return;
await mutateAsync({
selectedMfaMethod: mfaMethod
});
createNotification({
text: "Successfully updated preferred 2FA method",
type: "success"
});
} catch (err) {
createNotification({
text: "Something went wrong while updating preferred 2FA method.",
type: "error"
});
console.error(err);
}
};
const toggleMfa = async (state: boolean) => {
try {
if (!user) return;
@@ -47,31 +113,86 @@ export const MFASection = () => {
return (
<>
<form>
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
{user && (
<Checkbox
className="data-[state=checked]:bg-primary"
id="isTwoFAEnabled"
isChecked={user?.isMfaEnabled}
onCheckedChange={(state) => {
if (serverDetails?.emailConfigured) {
toggleMfa(state as boolean);
} else {
handlePopUpOpen("setUpEmail");
}
}}
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
{user && (
<Checkbox
className="data-[state=checked]:bg-primary"
id="isTwoFAEnabled"
isChecked={user?.isMfaEnabled}
onCheckedChange={(state) => {
if (serverDetails?.emailConfigured) {
toggleMfa(state as boolean);
} else {
handlePopUpOpen("setUpEmail");
}
}}
>
Enable 2-factor authentication
</Checkbox>
)}
{user?.isMfaEnabled && (
<FormControl label="Preferred 2FA method" className="mt-3">
<Select
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={updateSelectedMfa}
defaultValue={user.selectedMfaMethod ?? MfaMethod.EMAIL}
>
Enable 2-factor authentication via your personal email.
</Checkbox>
)}
</div>
</form>
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
Email
</SelectItem>
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
Mobile Authenticator
</SelectItem>
</Select>
</FormControl>
)}
<div className="mt-12 text-lg font-semibold text-mineshaft-100">Mobile Authenticator</div>
{isTotpConfigurationLoading ? (
<ContentLoader />
) : (
<div>
{totpConfiguration?.isVerified ? (
<div className="mt-2">
<div className="flex flex-row gap-2">
<Button colorSchema="secondary" onClick={setShouldShowRecoveryCodes.toggle}>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button colorSchema="danger" onClick={() => handlePopUpOpen("deleteTotpConfig")}>
Delete
</Button>
</div>
{shouldShowRecoveryCodes && totpConfiguration.recoveryCodes.length && (
<div className="mt-4 bg-mineshaft-600 p-4">
{totpConfiguration.recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
)}
</div>
) : (
<div className="mt-6 flex min-w-full justify-center">
<TotpRegistration
onComplete={async () => {
await queryClient.invalidateQueries(userKeys.totpConfiguration);
}}
/>
</div>
)}
</div>
)}
</div>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
<DeleteActionModal
isOpen={popUp.deleteTotpConfig.isOpen}
title="Are you sure want to delete the mobile authenticator? You’ll have to go through the setup process to enable it again."
onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleTotpDeletion}
/>
</>
);
};