Add two-factor authentication (#692)

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
This commit is contained in:
Chris 2021-09-21 05:29:20 -04:00 committed by GitHub
parent a8bc1760c7
commit c0330acd83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1177 additions and 152 deletions

View File

@ -14,10 +14,10 @@ export default function SettingsShell(props) {
current: router.pathname == "/settings/profile",
},
{
name: "Password",
href: "/settings/password",
name: "Security",
href: "/settings/security",
icon: KeyIcon,
current: router.pathname == "/settings/password",
current: router.pathname == "/settings/security",
},
{ name: "Embed", href: "/settings/embed", icon: CodeIcon, current: router.pathname == "/settings/embed" },
{

View File

@ -0,0 +1,122 @@
import React, { SyntheticEvent, useState } from "react";
import Modal from "@components/Modal";
import { ErrorCode } from "@lib/auth";
const errorMessages: { [key: string]: string } = {
[ErrorCode.IncorrectPassword]: "Current password is incorrect",
[ErrorCode.NewPasswordMatchesOld]:
"New password matches your old password. Please choose a different password.",
};
const ChangePasswordSection = () => {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const closeSuccessModal = () => {
setSuccessModalOpen(false);
};
async function changePasswordHandler(e: SyntheticEvent) {
e.preventDefault();
if (isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const response = await fetch("/api/auth/changepw", {
method: "PATCH",
body: JSON.stringify({ oldPassword, newPassword }),
headers: {
"Content-Type": "application/json",
},
});
if (response.status === 200) {
setOldPassword("");
setNewPassword("");
setSuccessModalOpen(true);
return;
}
const body = await response.json();
setErrorMessage(errorMessages[body.error] || "Something went wrong. Please try again");
} catch (err) {
console.error("Error changing password", err);
setErrorMessage("Something went wrong. Please try again");
} finally {
setIsSubmitting(false);
}
}
return (
<>
<div className="mt-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Change Password</h2>
</div>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
<div className="py-6 lg:pb-8">
<div className="flex">
<div className="w-1/2 mr-2">
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
Current Password
</label>
<div className="mt-1">
<input
type="password"
value={oldPassword}
onInput={(e) => setOldPassword(e.currentTarget.value)}
name="current_password"
id="current_password"
required
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your old password"
/>
</div>
</div>
<div className="w-1/2 ml-2">
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
New Password
</label>
<div className="mt-1">
<input
type="password"
name="new_password"
id="new_password"
value={newPassword}
required
onInput={(e) => setNewPassword(e.currentTarget.value)}
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your super secure new password"
/>
</div>
</div>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
<div className="py-8 flex justify-end">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
Save
</button>
</div>
<hr className="mt-4" />
</div>
</form>
<Modal
heading="Password updated successfully"
description="Your password has been successfully changed."
open={successModalOpen}
handleClose={closeSuccessModal}
/>
</>
);
};
export default ChangePasswordSection;

View File

@ -0,0 +1,101 @@
import { SyntheticEvent, useState } from "react";
import Button from "@components/ui/Button";
import { Dialog, DialogContent } from "@components/Dialog";
import { ErrorCode } from "@lib/auth";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
import TwoFactorModalHeader from "./TwoFactorModalHeader";
interface DisableTwoFactorAuthModalProps {
/**
* Called when the user closes the modal without disabling two-factor auth
*/
onCancel: () => void;
/**
* Called when the user disables two-factor auth
*/
onDisable: () => void;
}
const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => {
const [password, setPassword] = useState("");
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function handleDisable(e: SyntheticEvent) {
e.preventDefault();
if (isDisabling) {
return;
}
setIsDisabling(true);
setErrorMessage(null);
try {
const response = await TwoFactorAuthAPI.disable(password);
if (response.status === 200) {
onDisable();
return;
}
const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage("Password is incorrect.");
} else {
setErrorMessage("Something went wrong.");
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error disabling two-factor authentication", e);
} finally {
setIsDisabling(false);
}
}
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader
title="Disable two-factor authentication"
description="If you need to disable 2FA, we recommend re-enabling it as soon as possible."
/>
<form onSubmit={handleDisable}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
type="password"
name="password"
id="password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
</form>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button
type="submit"
className="ml-2"
onClick={handleDisable}
disabled={password.length === 0 || isDisabling}>
Disable
</Button>
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default DisableTwoFactorAuthModal;

View File

@ -0,0 +1,211 @@
import React, { SyntheticEvent, useState } from "react";
import Button from "@components/ui/Button";
import { Dialog, DialogContent } from "@components/Dialog";
import { ErrorCode } from "@lib/auth";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
import TwoFactorModalHeader from "./TwoFactorModalHeader";
interface EnableTwoFactorModalProps {
/**
* Called when the user closes the modal without disabling two-factor auth
*/
onCancel: () => void;
/**
* Called when the user enables two-factor auth
*/
onEnable: () => void;
}
enum SetupStep {
ConfirmPassword,
DisplayQrCode,
EnterTotpCode,
}
const setupDescriptions = {
[SetupStep.ConfirmPassword]: "Confirm your current password to get started.",
[SetupStep.DisplayQrCode]: "Scan the image below with the authenticator app on your phone.",
[SetupStep.EnterTotpCode]: "Enter the six-digit code from your authenticator app below.",
};
const WithStep = ({
step,
current,
children,
}: {
step: SetupStep;
current: SetupStep;
children: JSX.Element;
}) => {
return step === current ? children : null;
};
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [dataUri, setDataUri] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function handleSetup(e: SyntheticEvent) {
e.preventDefault();
if (isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const response = await TwoFactorAuthAPI.setup(password);
const body = await response.json();
if (response.status === 200) {
setDataUri(body.dataUri);
setStep(SetupStep.DisplayQrCode);
return;
}
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage("Password is incorrect.");
} else {
setErrorMessage("Something went wrong.");
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error setting up two-factor authentication", e);
} finally {
setIsSubmitting(false);
}
}
async function handleEnable(e: SyntheticEvent) {
e.preventDefault();
if (isSubmitting || totpCode.length !== 6) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const response = await TwoFactorAuthAPI.enable(totpCode);
const body = await response.json();
if (response.status === 200) {
onEnable();
return;
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage("Code is incorrect. Please try again.");
} else {
setErrorMessage("Something went wrong.");
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error enabling up two-factor authentication", e);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader
title="Enable two-factor authentication"
description={setupDescriptions[step]}
/>
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<form onSubmit={handleSetup}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
type="password"
name="password"
id="password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
</form>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<div className="flex justify-center">
<img src={dataUri} />
</div>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<form onSubmit={handleEnable}>
<div className="mb-4">
<label htmlFor="code" className="mt-4 block text-sm font-medium text-gray-700">
Code
</label>
<div className="mt-1">
<input
type="text"
name="code"
id="code"
required
value={totpCode}
maxLength={6}
minLength={6}
inputMode="numeric"
onInput={(e) => setTotpCode(e.currentTarget.value)}
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
</form>
</WithStep>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
className="ml-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
Continue
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button type="submit" className="ml-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
Continue
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<Button
type="submit"
className="ml-2"
onClick={handleEnable}
disabled={totpCode.length !== 6 || isSubmitting}>
Enable
</Button>
</WithStep>
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default EnableTwoFactorModal;

View File

@ -0,0 +1,33 @@
const TwoFactorAuthAPI = {
async setup(password: string) {
return fetch("/api/auth/two-factor/totp/setup", {
method: "POST",
body: JSON.stringify({ password }),
headers: {
"Content-Type": "application/json",
},
});
},
async enable(code: string) {
return fetch("/api/auth/two-factor/totp/enable", {
method: "POST",
body: JSON.stringify({ code }),
headers: {
"Content-Type": "application/json",
},
});
},
async disable(password: string) {
return fetch("/api/auth/two-factor/totp/disable", {
method: "POST",
body: JSON.stringify({ password }),
headers: {
"Content-Type": "application/json",
},
});
},
};
export default TwoFactorAuthAPI;

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import Button from "@components/ui/Button";
import Badge from "@components/ui/Badge";
import EnableTwoFactorModal from "./EnableTwoFactorModal";
import DisableTwoFactorModal from "./DisableTwoFactorModal";
const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean }) => {
const [enabled, setEnabled] = useState(twoFactorEnabled);
const [enableModalOpen, setEnableModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
return (
<>
<div className="flex flex-row items-center">
<h2 className="text-lg leading-6 font-medium text-gray-900">Two-Factor Authentication</h2>
<Badge className="text-xs ml-2" variant={enabled ? "success" : "gray"}>
{enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-500">
Add an extra layer of security to your account in case your password is stolen.
</p>
<Button
className="mt-6"
type="submit"
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
{enabled ? "Disable" : "Enable"} Two-Factor Authentication
</Button>
{enableModalOpen && (
<EnableTwoFactorModal
onEnable={() => {
setEnabled(true);
setEnableModalOpen(false);
}}
onCancel={() => setEnableModalOpen(false)}
/>
)}
{disableModalOpen && (
<DisableTwoFactorModal
onDisable={() => {
setEnabled(false);
setDisableModalOpen(false);
}}
onCancel={() => setDisableModalOpen(false)}
/>
)}
</>
);
};
export default TwoFactorAuthSection;

View File

@ -0,0 +1,20 @@
import React from "react";
import { ShieldCheckIcon } from "@heroicons/react/solid";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-black rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<ShieldCheckIcon className="w-6 h-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{title}
</h3>
<p className="text-sm text-gray-400">{description}</p>
</div>
</div>
);
};
export default TwoFactorModalHeader;

5
environment.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
}
}

View File

@ -27,3 +27,16 @@ export async function getSession(options: GetSessionOptions): Promise<Session |
// that these are equal are ensured in `[...nextauth]`'s callback
return session as Session | null;
}
export enum ErrorCode {
UserNotFound = "user-not-found",
IncorrectPassword = "incorrect-password",
UserMissingPassword = "missing-password",
TwoFactorDisabled = "two-factor-disabled",
TwoFactorAlreadyEnabled = "two-factor-already-enabled",
TwoFactorSetupRequired = "two-factor-setup-required",
SecondFactorRequired = "second-factor-required",
IncorrectTwoFactorCode = "incorrect-two-factor-code",
InternalServerError = "internal-server-error",
NewPasswordMatchesOld = "new-password-matches-old",
}

View File

@ -52,6 +52,8 @@
"next-transpile-modules": "^8.0.0",
"nodemailer": "^6.6.3",
"npm-run-all": "^4.1.5",
"otplib": "^12.0.1",
"qrcode": "^1.4.4",
"react": "17.0.2",
"react-dates": "^21.8.0",
"react-dom": "17.0.2",
@ -72,6 +74,7 @@
"@types/jest": "^27.0.1",
"@types/node": "^16.6.1",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.1",
"@types/react": "^17.0.18",
"@types/react-dates": "^21.8.3",
"@types/react-select": "^4.0.17",

View File

@ -1,7 +1,9 @@
import NextAuth from "next-auth";
import Providers from "next-auth/providers";
import prisma from "../../../lib/prisma";
import { Session, verifyPassword } from "../../../lib/auth";
import prisma from "@lib/prisma";
import { ErrorCode, Session, verifyPassword } from "@lib/auth";
import { authenticator } from "otplib";
import { symmetricDecrypt } from "@lib/crypto";
export default NextAuth({
session: {
@ -18,6 +20,7 @@ export default NextAuth({
credentials: {
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
},
async authorize(credentials) {
const user = await prisma.user.findUnique({
@ -27,16 +30,45 @@ export default NextAuth({
});
if (!user) {
throw new Error("No user found");
throw new Error(ErrorCode.UserNotFound);
}
if (!user.password) {
throw new Error("Incorrect password");
throw new Error(ErrorCode.UserMissingPassword);
}
const isValid = await verifyPassword(credentials.password, user.password);
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
if (!isValid) {
throw new Error("Incorrect password");
if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = authenticator.check(credentials.totpCode, secret);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
return {

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { hashPassword, verifyPassword } from "../../../lib/auth";
import { ErrorCode, hashPassword, verifyPassword } from "../../../lib/auth";
import { getSession } from "@lib/auth";
import prisma from "../../../lib/prisma";
@ -28,17 +28,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const currentPassword = user.password;
if (!currentPassword) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
const passwordsMatch = await verifyPassword(oldPassword, currentPassword);
if (!passwordsMatch) {
res.status(403).json({ message: "Incorrect password" });
return;
return res.status(403).json({ error: ErrorCode.IncorrectPassword });
}
if (oldPassword === newPassword) {
return res.status(400).json({ error: ErrorCode.NewPasswordMatchesOld });
}
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
where: {
id: user.id,

View File

@ -0,0 +1,50 @@
import prisma from "@lib/prisma";
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const session = await getSession({ req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.error("Session is missing a user id.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (!user.password) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
if (!user.twoFactorEnabled) {
return res.json({ message: "Two factor disabled" });
}
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
twoFactorEnabled: false,
twoFactorSecret: null,
},
});
return res.json({ message: "Two factor disabled" });
}

View File

@ -0,0 +1,64 @@
import prisma from "@lib/prisma";
import { ErrorCode, getSession } from "@lib/auth";
import { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
import { symmetricDecrypt } from "@lib/crypto";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const session = await getSession({ req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.error("Session is missing a user id.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({ error: ErrorCode.TwoFactorAlreadyEnabled });
}
if (!user.twoFactorSecret) {
return res.status(400).json({ error: ErrorCode.TwoFactorSetupRequired });
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with two factor setup.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const isValidToken = authenticator.check(req.body.code, secret);
if (!isValidToken) {
return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode });
}
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
twoFactorEnabled: true,
},
});
return res.json({ message: "Two-factor enabled" });
}

View File

@ -0,0 +1,66 @@
import prisma from "@lib/prisma";
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
import { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
import qrcode from "qrcode";
import { symmetricEncrypt } from "@lib/crypto";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const session = await getSession({ req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.error("Session is missing a user id.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (!user.password) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
if (user.twoFactorEnabled) {
return res.status(400).json({ error: ErrorCode.TwoFactorAlreadyEnabled });
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with two factor setup.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
// This generates a secret 32 characters in length. Do not modify the number of
// bytes without updating the sanity checks in the enable and login endpoints.
const secret = authenticator.generateSecret(20);
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
twoFactorEnabled: false,
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
},
});
const name = user.email || user.username || user.id.toString();
const keyUri = authenticator.keyuri(name, "Cal", secret);
const dataUri = await qrcode.toDataURL(keyUri);
return res.json({ secret, keyUri, dataUri });
}

View File

@ -1,17 +1,70 @@
import { HeadSeo } from "@components/seo/head-seo";
import Link from "next/link";
import { getCsrfToken } from "next-auth/client";
import { getSession } from "@lib/auth";
import { useEffect } from "react";
import { getCsrfToken, signIn } from "next-auth/client";
import { ErrorCode, getSession } from "@lib/auth";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
const errorMessages: { [key: string]: string } = {
[ErrorCode.SecondFactorRequired]:
"Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
[ErrorCode.IncorrectPassword]: "Password is incorrect. Please try again.",
[ErrorCode.UserNotFound]: "No account exists matching that email address.",
[ErrorCode.IncorrectTwoFactorCode]: "Two-factor code is incorrect. Please try again.",
[ErrorCode.InternalServerError]:
"Something went wrong. Please try again and contact us if the issue persists.",
};
export default function Login({ csrfToken }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [secondFactorRequired, setSecondFactorRequired] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!router.query?.callbackUrl) {
window.history.replaceState(null, document.title, "?callbackUrl=/");
}
}, [router.query]);
async function handleSubmit(e: React.SyntheticEvent) {
e.preventDefault();
if (isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const response = await signIn("credentials", { redirect: false, email, password, totpCode: code });
if (!response) {
console.error("Received empty response from next auth");
return;
}
if (!response.error) {
window.location.reload();
return;
}
if (response.error === ErrorCode.SecondFactorRequired) {
setSecondFactorRequired(true);
setErrorMessage(errorMessages[ErrorCode.SecondFactorRequired]);
} else {
setErrorMessage(errorMessages[response.error] || "Something went wrong.");
}
} catch (e) {
setErrorMessage("Something went wrong.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-neutral-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<HeadSeo title="Login" description="Login" />
@ -22,7 +75,7 @@ export default function Login({ csrfToken }) {
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 mx-2 rounded-sm sm:px-10 border border-neutral-200">
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
<form className="space-y-6" onSubmit={handleSubmit}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
@ -35,6 +88,8 @@ export default function Login({ csrfToken }) {
type="email"
autoComplete="email"
required
value={email}
onInput={(e) => setEmail(e.currentTarget.value)}
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
@ -60,18 +115,44 @@ export default function Login({ csrfToken }) {
type="password"
autoComplete="current-password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
</div>
{secondFactorRequired && (
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
Two-Factor Code
</label>
<div className="mt-1">
<input
id="totpCode"
name="totpCode"
type="text"
maxLength={6}
minLength={6}
inputMode="numeric"
value={code}
onInput={(e) => setCode(e.currentTarget.value)}
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
</div>
)}
<div className="space-y-2">
<button
type="submit"
disabled={isSubmitting}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
Sign in
</button>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</form>
</div>
<div className="mt-4 text-neutral-600 text-center text-sm">

View File

@ -1,127 +0,0 @@
import { useRef, useState } from "react";
import prisma from "@lib/prisma";
import Modal from "@components/Modal";
import Shell from "@components/Shell";
import SettingsShell from "@components/Settings";
import { useSession } from "next-auth/client";
import Loader from "@components/Loader";
import { getSession } from "@lib/auth";
export default function Settings() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const [successModalOpen, setSuccessModalOpen] = useState(false);
const oldPasswordRef = useRef<HTMLInputElement>();
const newPasswordRef = useRef<HTMLInputElement>();
if (loading) {
return <Loader />;
}
const closeSuccessModal = () => {
setSuccessModalOpen(false);
};
async function changePasswordHandler(event) {
event.preventDefault();
const enteredOldPassword = oldPasswordRef.current.value;
const enteredNewPassword = newPasswordRef.current.value;
// TODO: Add validation
/*eslint-disable */
const response = await fetch("/api/auth/changepw", {
method: "PATCH",
body: JSON.stringify({ oldPassword: enteredOldPassword, newPassword: enteredNewPassword }),
headers: {
"Content-Type": "application/json",
},
});
/*eslint-enable */
setSuccessModalOpen(true);
}
return (
<Shell heading="Change Password" subtitle="Change the password that you use to sign in to your account.">
<SettingsShell>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
<div className="py-6 lg:pb-8">
<div className="flex">
<div className="w-1/2 mr-2">
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
Current Password
</label>
<div className="mt-1">
<input
ref={oldPasswordRef}
type="password"
name="current_password"
id="current_password"
required
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your old password"
/>
</div>
</div>
<div className="w-1/2 ml-2">
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
New Password
</label>
<div className="mt-1">
<input
ref={newPasswordRef}
type="password"
name="new_password"
id="new_password"
required
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your super secure new password"
/>
</div>
</div>
</div>
<hr className="mt-8" />
<div className="py-4 flex justify-end">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
Save
</button>
</div>
</div>
</form>
<Modal
heading="Password updated successfully"
description="Your password has been successfully changed."
open={successModalOpen}
handleClose={closeSuccessModal}
/>
</SettingsShell>
</Shell>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
username: true,
name: true,
},
});
return {
props: { user }, // will be passed to the page component as props
};
}

View File

@ -0,0 +1,49 @@
import React from "react";
import prisma from "@lib/prisma";
import Shell from "@components/Shell";
import SettingsShell from "@components/Settings";
import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader";
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
import ChangePasswordSection from "@components/security/ChangePasswordSection";
export default function Security({ user }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
if (loading) {
return <Loader />;
}
return (
<Shell heading="Security" subtitle="Manage your account's security.">
<SettingsShell>
<ChangePasswordSection />
<TwoFactorAuthSection twoFactorEnabled={user.twoFactorEnabled} />
</SettingsShell>
</Shell>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
username: true,
name: true,
twoFactorEnabled: true,
},
});
return {
props: { user }, // will be passed to the page component as props
};
}

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "twoFactorSecret" TEXT;

View File

@ -81,6 +81,8 @@ model User {
availability Availability[]
selectedCalendars SelectedCalendar[]
completedOnboarding Boolean? @default(false)
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
plan UserPlan @default(PRO)

250
yarn.lock
View File

@ -798,6 +798,44 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@otplib/core@^12.0.1":
version "12.0.1"
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-12.0.1.tgz#73720a8cedce211fe5b3f683cd5a9c098eaf0f8d"
integrity sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==
"@otplib/plugin-crypto@^12.0.1":
version "12.0.1"
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz#2b42c624227f4f9303c1c041fca399eddcbae25e"
integrity sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==
dependencies:
"@otplib/core" "^12.0.1"
"@otplib/plugin-thirty-two@^12.0.1":
version "12.0.1"
resolved "https://registry.yarnpkg.com/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz#5cc9b56e6e89f2a1fe4a2b38900ca4e11c87aa9e"
integrity sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==
dependencies:
"@otplib/core" "^12.0.1"
thirty-two "^1.0.2"
"@otplib/preset-default@^12.0.1":
version "12.0.1"
resolved "https://registry.yarnpkg.com/@otplib/preset-default/-/preset-default-12.0.1.tgz#cb596553c08251e71b187ada4a2246ad2a3165ba"
integrity sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==
dependencies:
"@otplib/core" "^12.0.1"
"@otplib/plugin-crypto" "^12.0.1"
"@otplib/plugin-thirty-two" "^12.0.1"
"@otplib/preset-v11@^12.0.1":
version "12.0.1"
resolved "https://registry.yarnpkg.com/@otplib/preset-v11/-/preset-v11-12.0.1.tgz#4c7266712e7230500b421ba89252963c838fc96d"
integrity sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==
dependencies:
"@otplib/core" "^12.0.1"
"@otplib/plugin-crypto" "^12.0.1"
"@otplib/plugin-thirty-two" "^12.0.1"
"@panva/asn1.js@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6"
@ -1370,6 +1408,13 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11"
integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==
"@types/qrcode@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.4.1.tgz#0689f400c3a95d2db040c99c99834faa09ee9dc1"
integrity sha512-vxMyr7JM7tYPxu8vUE83NiosWX5DZieCyYeJRoOIg0pAkyofCBzknJ2ycUZkPGDFis2RS8GN/BeJLnRnAPxeCA==
dependencies:
"@types/node" "*"
"@types/react-dates@^21.8.3":
version "21.8.3"
resolved "https://registry.yarnpkg.com/@types/react-dates/-/react-dates-21.8.3.tgz#dc4e71f83d09979b1c4f355c267e52a850d0fe2c"
@ -1658,6 +1703,11 @@ ansi-regex@^2.0.0:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
ansi-regex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
ansi-regex@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
@ -1668,7 +1718,7 @@ ansi-styles@^2.2.1:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
ansi-styles@^3.2.1:
ansi-styles@^3.2.0, ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
@ -2146,6 +2196,19 @@ bser@2.1.1:
dependencies:
node-int64 "^0.4.0"
buffer-alloc-unsafe@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
buffer-alloc@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
dependencies:
buffer-alloc-unsafe "^1.1.0"
buffer-fill "^1.0.0"
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
@ -2156,7 +2219,12 @@ buffer-equal-constant-time@1.0.1:
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
buffer-from@^1.0.0:
buffer-fill@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
buffer-from@^1.0.0, buffer-from@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
@ -2183,6 +2251,14 @@ buffer@^4.3.0:
ieee754 "^1.1.4"
isarray "^1.0.0"
buffer@^5.4.3:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.1.13"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
@ -2224,7 +2300,7 @@ camelcase-css@^2.0.1:
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
camelcase@^5.3.1:
camelcase@^5.0.0, camelcase@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
@ -2390,6 +2466,15 @@ cli-truncate@^2.1.0:
slice-ansi "^3.0.0"
string-width "^4.2.0"
cliui@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
dependencies:
string-width "^3.1.0"
strip-ansi "^5.2.0"
wrap-ansi "^5.1.0"
cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
@ -2786,6 +2871,11 @@ debug@^3.1.0:
dependencies:
ms "^2.1.1"
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
decimal.js@^10.2.1:
version "10.3.1"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"
@ -2889,6 +2979,11 @@ diffie-hellman@^5.0.0:
miller-rabin "^4.0.0"
randombytes "^2.0.0"
dijkstrajs@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@ -3000,6 +3095,11 @@ emittery@^0.8.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860"
integrity sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==
emoji-regex@^7.0.1:
version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@ -3481,6 +3581,13 @@ find-cache-dir@3.3.1:
make-dir "^3.0.2"
pkg-dir "^4.1.0"
find-up@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
dependencies:
locate-path "^3.0.0"
find-up@^4.0.0, find-up@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
@ -3618,7 +3725,7 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-caller-file@^2.0.5:
get-caller-file@^2.0.1, get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
@ -4049,7 +4156,7 @@ ics@^2.31.0:
nanoid "^3.1.23"
yup "^0.32.9"
ieee754@^1.1.4, ieee754@^1.2.1:
ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@ -4245,6 +4352,11 @@ is-extglob@^2.1.1:
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
is-fullwidth-code-point@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
@ -4380,6 +4492,11 @@ isarray@^1.0.0, isarray@~1.0.0:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
isarray@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@ -5157,6 +5274,14 @@ loader-utils@1.2.3:
emojis-list "^2.0.0"
json5 "^1.0.1"
locate-path@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
dependencies:
p-locate "^3.0.0"
path-exists "^3.0.0"
locate-path@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
@ -5826,6 +5951,15 @@ ospath@^1.2.2:
resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b"
integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=
otplib@^12.0.1:
version "12.0.1"
resolved "https://registry.yarnpkg.com/otplib/-/otplib-12.0.1.tgz#c1d3060ab7aadf041ed2960302f27095777d1f73"
integrity sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==
dependencies:
"@otplib/core" "^12.0.1"
"@otplib/preset-default" "^12.0.1"
"@otplib/preset-v11" "^12.0.1"
p-each-series@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a"
@ -5838,13 +5972,20 @@ p-limit@3.1.0:
dependencies:
yocto-queue "^0.1.0"
p-limit@^2.2.0:
p-limit@^2.0.0, p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
dependencies:
p-try "^2.0.0"
p-locate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
dependencies:
p-limit "^2.0.0"
p-locate@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
@ -5937,6 +6078,11 @@ path-browserify@1.0.1:
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
path-exists@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@ -6046,6 +6192,11 @@ please-upgrade-node@^3.2.0:
dependencies:
semver-compare "^1.0.0"
pngjs@^3.3.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
pnp-webpack-plugin@1.6.4:
version "1.6.4"
resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
@ -6275,6 +6426,19 @@ purgecss@^4.0.3:
postcss "^8.2.1"
postcss-selector-parser "^6.0.2"
qrcode@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.4.4.tgz#f0c43568a7e7510a55efc3b88d9602f71963ea83"
integrity sha512-oLzEC5+NKFou9P0bMj5+v6Z40evexeE29Z9cummZXZ9QXyMr3lphkURzxjXgPJC5azpxcshoDWV1xE46z+/c3Q==
dependencies:
buffer "^5.4.3"
buffer-alloc "^1.2.0"
buffer-from "^1.1.1"
dijkstrajs "^1.0.1"
isarray "^2.0.1"
pngjs "^3.3.0"
yargs "^13.2.4"
qs@^6.7.0:
version "6.10.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
@ -6677,6 +6841,11 @@ require-from-string@^2.0.2:
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
require_optional@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.1.tgz#4cf35a4247f64ca3df8c2ef208cc494b1ca8fc2e"
@ -6832,6 +7001,11 @@ semver@^7.2.1, semver@^7.3.2, semver@^7.3.5:
dependencies:
lru-cache "^6.0.0"
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
setimmediate@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
@ -7110,6 +7284,15 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
string-width@^3.0.0, string-width@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
dependencies:
emoji-regex "^7.0.1"
is-fullwidth-code-point "^2.0.0"
strip-ansi "^5.1.0"
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
@ -7195,6 +7378,13 @@ strip-ansi@^3.0.0:
dependencies:
ansi-regex "^2.0.0"
strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
dependencies:
ansi-regex "^4.1.0"
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
@ -7374,6 +7564,11 @@ thenify-all@^1.0.0:
dependencies:
any-promise "^1.0.0"
thirty-two@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno=
throat@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375"
@ -7859,6 +8054,11 @@ which-boxed-primitive@^1.0.2:
is-string "^1.0.5"
is-symbol "^1.0.3"
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
which-typed-array@^1.1.2:
version "1.1.7"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.7.tgz#2761799b9a22d4b8660b3c1b40abaa7739691793"
@ -7895,6 +8095,15 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
wrap-ansi@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
dependencies:
ansi-styles "^3.2.0"
string-width "^3.0.0"
strip-ansi "^5.0.0"
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@ -7968,6 +8177,11 @@ xtend@^4.0.0, xtend@^4.0.2:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
@ -7992,11 +8206,35 @@ yargonaut@^1.1.4:
figlet "^1.1.1"
parent-require "^1.0.0"
yargs-parser@^13.1.2:
version "13.1.2"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs-parser@^20.2.2:
version "20.2.9"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
yargs@^13.2.4:
version "13.3.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
dependencies:
cliui "^5.0.0"
find-up "^3.0.0"
get-caller-file "^2.0.1"
require-directory "^2.1.1"
require-main-filename "^2.0.0"
set-blocking "^2.0.0"
string-width "^3.0.0"
which-module "^2.0.0"
y18n "^4.0.0"
yargs-parser "^13.1.2"
yargs@^16.0.0, yargs@^16.0.3:
version "16.2.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"