feat: 2fa

This commit is contained in:
diced
2023-08-16 18:24:35 -07:00
parent 46b28663b7
commit 5e72450f39
20 changed files with 666 additions and 31 deletions

View File

@@ -46,6 +46,8 @@
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1",
"next": "^13.4.7",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
@@ -64,6 +66,7 @@
"@types/katex": "^0.16.0",
"@types/multer": "^1.4.7",
"@types/node": "^20.3.1",
"@types/qrcode": "^1.5.1",
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"@types/signale": "^1.4.4",

View File

@@ -27,6 +27,8 @@ model User {
role Role @default(USER)
view Json @default("{}")
totpSecret String?
files File[]
urls Url[]
folders Folder[]

View File

@@ -276,6 +276,7 @@ export default function GeneratorButton({
onClick={() => generators[name as keyof typeof generators](token!, generatorType as any, options)}
fullWidth
leftIcon={<IconDownload size='1rem' />}
size='sm'
>
Download
</Button>

View File

@@ -1,11 +1,12 @@
import { useConfig } from '@/components/ConfigProvider';
import { Group, SimpleGrid, Title } from '@mantine/core';
import SettingsAvatar from './parts/SettingsAvatar';
import SettingsDashboard from './parts/SettingsDashboard';
import SettingsUser from './parts/SettingsUser';
import SettingsFileView from './parts/SettingsFileView';
import SettingsOAuth from './parts/SettingsOAuth';
import SettingsGenerators from './parts/SettingsGenerators';
import { useConfig } from '@/components/ConfigProvider';
import SettingsMfa from './parts/SettingsMfa';
import SettingsOAuth from './parts/SettingsOAuth';
import SettingsUser from './parts/SettingsUser';
export default function DashboardSettings() {
const config = useConfig();
@@ -26,6 +27,7 @@ export default function DashboardSettings() {
<SettingsFileView />
{config.features.oauthRegistration && <SettingsOAuth />}
{config.mfa.totp.enabled && <SettingsMfa />}
<SettingsGenerators />
</SimpleGrid>

View File

@@ -1,17 +1,15 @@
import { Response } from '@/lib/api/response';
import { readToDataURL } from '@/lib/base64';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import { readToDataURL } from '@/lib/base64';
import { useUserStore } from '@/lib/store/user';
import { Avatar, Button, Card, FileInput, Group, Paper, Stack, Text, Title } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconChevronDown, IconPhoto, IconPhotoCancel, IconPhotoUp, IconSettingsFilled } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
export default function SettingsAvatar() {
const router = useRouter();
const user = useUserStore((state) => state.user);
const { avatar: currentAvatar, mutate } = useAvatar();

View File

@@ -6,7 +6,6 @@ import {
ColorSwatch,
DEFAULT_THEME,
Group,
MantineProvider,
MantineThemeOverride,
NumberInput,
Paper,
@@ -14,14 +13,13 @@ import {
Stack,
Switch,
Text,
Title,
Title
} from '@mantine/core';
import {
IconFile,
IconMoonFilled,
IconPaintFilled,
IconPercentage,
IconSunFilled,
IconSunFilled
} from '@tabler/icons-react';
function ThemeSelectItem({ value, label, ...others }: { value: string; label: string }) {

View File

@@ -2,15 +2,11 @@ import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/store/user';
import {
ActionIcon,
Button,
ColorInput,
CopyButton,
Divider,
Group,
Paper,
PasswordInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
@@ -18,13 +14,11 @@ import {
Text,
TextInput,
Textarea,
Title,
Tooltip,
Title
} from '@mantine/core';
import { hasLength, useForm } from '@mantine/form';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconFileX, IconUserCancel } from '@tabler/icons-react';
import { forwardRef, useEffect, useState } from 'react';
import { IconCheck, IconFileX } from '@tabler/icons-react';
import { mutate } from 'swr';
export default function SettingsFileView() {

View File

@@ -0,0 +1,221 @@
import { Response } from '@/lib/api/response';
import { User } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/store/user';
import {
Anchor,
Box,
Button,
Center,
Group,
Image,
LoadingOverlay,
Modal,
Paper,
PinInput,
Stack,
Text,
Title,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconShieldLockFilled } from '@tabler/icons-react';
import Link from 'next/link';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
export default function SettingsMfa() {
const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
const [totpOpen, setTotpOpen] = useState(false);
const {
data: twoData,
error: twoError,
isLoading: twoLoading,
} = useSWR<Extract<Response['/api/user/mfa/totp'], { secret: string; qrcode: string }>>(
totpOpen && !user?.totpSecret ? '/api/user/mfa/totp' : null,
null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
}
);
const [pinDisabled, setPinDisabled] = useState(false);
const [pinError, setPinError] = useState('');
const enable2fa = async (pin: string) => {
if (pin.length !== 6) return setPinError('Invalid pin');
const { data, error } = await fetchApi<Extract<Response['/api/user/mfa/totp'], User>>(
'/api/user/mfa/totp',
'POST',
{
code: pin,
secret: twoData!.secret,
}
);
if (error) {
setPinError(error.message!);
setPinDisabled(false);
} else {
setTotpOpen(false);
setPinDisabled(false);
mutate('/api/user');
setUser(data);
notifications.show({
title: '2FA Enabled',
message: 'You have successfully enabled 2FA on your account.',
color: 'green',
icon: <IconShieldLockFilled size='1rem' />,
});
}
};
const disable2fa = async (pin: string) => {
if (pin.length !== 6) return setPinError('Invalid pin');
const { data, error } = await fetchApi<Extract<Response['/api/user/mfa/totp'], User>>(
'/api/user/mfa/totp',
'DELETE',
{
code: pin,
}
);
if (error) {
setPinError(error.message!);
setPinDisabled(false);
} else {
setTotpOpen(false);
setPinDisabled(false);
mutate('/api/user');
setUser(data);
notifications.show({
title: '2FA Disabled',
message: 'You have successfully disabled 2FA on your account.',
color: 'green',
icon: <IconShieldLockFilled size='1rem' />,
});
}
};
const handlePinChange = (value: string) => {
if (value.length === 6) {
setPinDisabled(true);
user?.totpSecret ? disable2fa(value) : enable2fa(value);
} else {
setPinError('');
}
};
return (
<>
<Modal title={<Title>Enable 2FA</Title>} opened={totpOpen} onClose={() => setTotpOpen(false)}>
<Stack spacing='sm'>
{user?.totpSecret ? (
<Text size='sm' color='dimmed'>
Enter the 6-digit code from your authenticator app below to confirm disabling 2FA.
</Text>
) : (
<>
<Text size='sm' color='dimmed'>
<b>Step 1</b> Open/download an authenticator that supports qrcode scanning or manual code
entry. Popular options include{' '}
<Anchor component={Link} href='https://authy.com/' target='_blank'>
Authy
</Anchor>
,{' '}
<Anchor
component={Link}
href='https://support.google.com/accounts/answer/1066447'
target='_blank'
>
Google Authenticator
</Anchor>
, and{' '}
<Anchor
component={Link}
href='https://www.microsoft.com/en-us/security/mobile-authenticator-app'
target='_blank'
>
Microsoft Authenticator
</Anchor>
.
</Text>
<Text size='sm' color='dimmed'>
<b>Step 2</b> Scan the QR code below with your authenticator app to enable 2FA.
</Text>
<Box pos='relative'>
{twoLoading && !twoError ? (
<LoadingOverlay visible />
) : (
<Center>
<Image
width={180}
height={180}
src={twoData?.qrcode}
alt={'qr code ' + twoData?.secret ?? ''}
/>
</Center>
)}
</Box>
<Text size='sm' color='dimmed'>
If you can't scan the QR code, you can manually enter the following code into your
authenticator app: <br /> {twoData?.secret ?? ''}
</Text>
<Text size='sm' color='dimmed'>
<b>Step 3</b> Enter the 6-digit code from your authenticator app below to confirm 2FA setup.
</Text>
</>
)}
<Center>
<PinInput
data-autofocus
length={6}
oneTimeCode
type='number'
placeholder=''
onChange={handlePinChange}
autoFocus={true}
error={!!pinError}
disabled={pinDisabled}
size='xl'
/>
</Center>
{pinError && (
<Text align='center' size='sm' color='red' mt={0}>
{pinError}
</Text>
)}
</Stack>
</Modal>
<Paper withBorder p='sm'>
<Title order={2}>Multi-Factor Authentication</Title>
<Text size='sm' color='dimmed' mt={3}>
Setup 2FA to protect your account with an additional layer of security.
</Text>
<Group mt='xs'>
<Button
size='sm'
leftIcon={<IconShieldLockFilled size={24} />}
color={user?.totpSecret ? 'red' : 'blue'}
onClick={() => setTotpOpen(true)}
>
{user?.totpSecret ? 'Disable 2FA' : 'Enable 2FA'}
</Button>
</Group>
</Paper>
</>
);
}

View File

@@ -2,9 +2,8 @@ import { useConfig } from '@/components/ConfigProvider';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { findProvider } from '@/lib/oauth/providerUtil';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user';
import { Button, Group, Paper, Stack, Switch, Text, Title } from '@mantine/core';
import { Button, Group, Paper, Text, Title } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import type { OAuthProviderType } from '@prisma/client';
import {

View File

@@ -14,6 +14,7 @@ import { ApiUserFilesIdPasswordResponse } from '@/pages/api/user/files/[id]/pass
import { ApiUserFilesTransactionResponse } from '@/pages/api/user/files/transaction';
import { ApiUserFoldersResponse } from '@/pages/api/user/folders';
import { ApiUserFoldersIdResponse } from '@/pages/api/user/folders/[id]';
import { ApiUserMfaTotpResponse } from '@/pages/api/user/mfa/totp';
import { ApiUserRecentResponse } from '@/pages/api/user/recent';
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
import { ApiUserTokenResponse } from '@/pages/api/user/token';
@@ -30,6 +31,7 @@ export type Response = {
'/api/auth/oauth': ApiAuthOauthResponse;
'/api/auth/login': ApiLoginResponse;
'/api/auth/logout': ApiLogoutResponse;
'/api/user/mfa/totp': ApiUserMfaTotpResponse;
'/api/user/folders/[id]': ApiUserFoldersIdResponse;
'/api/user/folders': ApiUserFoldersResponse;
'/api/user/files/[id]/password': ApiUserFilesIdPasswordResponse;

View File

@@ -63,6 +63,9 @@ export const PROP_TO_ENV: Record<string, string> = {
'website.theme.dark': 'WEBSITE_THEME_DARK',
'website.theme.light': 'WEBSITE_THEME_LIGHT',
'mfa.totp.enabled': 'MFA_TOTP_ENABLED',
'mfa.totp.issuer': 'MFA_TOTP_ISSUER',
'oauth.bypassLocalLogin': 'OAUTH_BYPASS_LOCAL_LOGIN',
'oauth.loginOnly': 'OAUTH_LOGIN_ONLY',
'oauth.discord.clientId': 'OAUTH_DISCORD_CLIENT_ID',
@@ -134,6 +137,9 @@ export function readEnv() {
env(PROP_TO_ENV['website.theme.dark'], 'website.theme.dark', 'string'),
env(PROP_TO_ENV['website.theme.light'], 'website.theme.light', 'string'),
env(PROP_TO_ENV['mfa.totp.enabled'], 'mfa.totp.enabled', 'boolean'),
env(PROP_TO_ENV['mfa.totp.issuer'], 'mfa.totp.issuer', 'string'),
env(PROP_TO_ENV['oauth.bypassLocalLogin'], 'oauth.bypassLocalLogin', 'boolean'),
env(PROP_TO_ENV['oauth.loginOnly'], 'oauth.loginOnly', 'boolean'),
env(PROP_TO_ENV['oauth.discord.clientId'], 'oauth.discord.clientId', 'string'),
@@ -205,6 +211,12 @@ export function readEnv() {
light: undefined,
},
},
mfa: {
totp: {
enabled: undefined,
issuer: undefined,
},
},
oauth: {
bypassLocalLogin: undefined,
loginOnly: undefined,

View File

@@ -132,6 +132,13 @@ export const schema = z.object({
light: z.string().default('builtin:light_gray'),
}),
}),
mfa: z.object({
totp: z.object({
enabled: z.boolean().default(false),
issuer: z.string().default('Zipline'),
}),
// TODO: passkeys
}),
oauth: z.object({
bypassLocalLogin: z.boolean().default(false),
loginOnly: z.boolean().default(false),

View File

@@ -11,6 +11,8 @@ export type User = {
oauthProviders: OAuthProvider[];
totpSecret?: string | null;
avatar?: string | null;
password?: string | null;
token?: string | null;
@@ -24,6 +26,7 @@ export const userSelect = {
role: true,
view: true,
oauthProviders: true,
totpSecret: true,
};
export type UserViewSettings = z.infer<typeof userViewSchema>;

24
src/lib/totp.ts Normal file
View File

@@ -0,0 +1,24 @@
import { authenticator } from 'otplib';
import { toDataURL } from 'qrcode';
export function generateKey() {
return authenticator.generateSecret(16);
}
export function verifyTotpCode(code: string, secret: string) {
return authenticator.check(code, secret);
}
export function totpQrcode({
issuer,
username,
secret,
}: {
issuer?: string;
username: string;
secret: string;
}) {
return toDataURL(authenticator.keyuri(username, issuer ?? 'Zipline', secret), {
width: 180,
});
}

View File

@@ -5,19 +5,22 @@ import { loginToken } from '@/lib/login';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { verifyTotpCode } from '@/lib/totp';
export type ApiLoginResponse = {
user: User;
token: string;
user?: User;
token?: string;
totp?: true;
};
type Body = {
username: string;
password: string;
code?: string;
};
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>) {
const { username, password } = req.body;
const { username, password, code } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
@@ -38,6 +41,16 @@ async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>)
const valid = await verifyPassword(password, user.password);
if (!valid) return res.badRequest('Invalid password', { password: true });
if (user.totpSecret && code) {
const valid = verifyTotpCode(code, user.totpSecret);
if (!valid) return res.badRequest('Invalid code', { code: true });
}
if (user.totpSecret && !code)
return res.ok({
totp: true,
});
const token = loginToken(res, user);
delete (user as any).token;

View File

@@ -0,0 +1,75 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp';
export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: string; qrcode: string };
type Body = {
code?: string;
secret?: string;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserMfaTotpResponse>) {
if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled');
if (req.method === 'DELETE') {
if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled");
const { code } = req.body;
if (!code) return res.badRequest('Missing code');
if (code.length !== 6) return res.badRequest('Invalid code');
const valid = verifyTotpCode(code, req.user.totpSecret);
if (!valid) return res.badRequest('Invalid code');
const user = await prisma.user.update({
where: { id: req.user.id },
data: { totpSecret: null },
select: userSelect,
});
return res.json(user);
} else if (req.method === 'POST') {
const { code, secret } = req.body;
if (!code) return res.badRequest('Missing code');
if (code.length !== 6) return res.badRequest('Invalid code');
if (!secret) return res.badRequest('Missing secret');
const valid = verifyTotpCode(code, secret);
if (!valid) return res.badRequest('Invalid code');
const user = await prisma.user.update({
where: { id: req.user.id },
data: { totpSecret: secret },
select: userSelect,
});
return res.json(user);
}
if (!req.user.totpSecret) {
const secret = generateKey();
const qrcode = await totpQrcode({
issuer: config.mfa.totp.issuer,
username: req.user.username,
secret,
});
return res.json({
secret,
qrcode,
});
}
return res.json({
secret: req.user.totpSecret,
});
}
export default combine([method(['GET', 'POST', 'DELETE']), ziplineAuth()], handler);

View File

@@ -115,7 +115,10 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
where: {
id: user.id,
},
select: userSelect,
select: {
...userSelect,
totpSecret: false,
},
});
logger.info(`${req.user.username} deleted another user`, {

View File

@@ -59,7 +59,10 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
avatar: avatar64 ?? null,
token: createToken(),
},
select: userSelect,
select: {
...userSelect,
totpSecret: false,
},
});
logger.info(`${req.user.username} created a new user`, {

View File

@@ -1,16 +1,18 @@
import { Response } from '@/lib/api/response';
import { config } from '@/lib/config';
import { SafeConfig } from '@/lib/config/safe';
import { getZipline } from '@/lib/db/models/zipline';
import { fetchApi } from '@/lib/fetchApi';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import { eitherTrue, isTruthy } from '@/lib/primitive';
import { eitherTrue } from '@/lib/primitive';
import {
Button,
Card,
Center,
Group,
LoadingOverlay,
Modal,
PasswordInput,
PinInput,
Stack,
Text,
TextInput,
@@ -22,11 +24,13 @@ import {
IconBrandGithubFilled,
IconBrandGoogle,
IconCircleKeyFilled,
IconShieldQuestion,
IconX,
} from '@tabler/icons-react';
import { InferGetServerSidePropsType } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
@@ -44,6 +48,11 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 &&
router.query.local !== 'true';
const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = useState(false);
const [pinError, setPinError] = useState('');
const [pin, setPin] = useState('');
useEffect(() => {
if (data?.user) {
router.push('/dashboard');
@@ -61,22 +70,42 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
},
});
const onSubmit = async (values: typeof form.values) => {
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
setPinDisabled(true);
setPinError('');
const { username, password } = values;
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
username,
password,
code,
});
if (error) {
if (error.username) form.setFieldError('username', 'Invalid username');
else if (error.password) form.setFieldError('password', 'Invalid password');
else if (error.code) setPinError(error.message!);
setPinDisabled(false);
} else {
if (data!.totp) {
setTotpOpen(true);
setPinDisabled(false);
return;
}
mutate(data as Response['/api/user']);
}
};
const handlePinChange = (value: string) => {
setPin(value);
if (value.length === 6) {
onSubmit(form.values, value);
}
};
useEffect(() => {
if (willRedirect) {
const provider = Object.keys(config.oauthEnabled).find(
@@ -93,6 +122,50 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
<Modal onClose={() => {}} title={<Title order={3}>Enter code</Title>} opened={totpOpen} withCloseButton={false}>
<Center>
<PinInput
data-autofocus
length={6}
oneTimeCode
type='number'
placeholder=''
onChange={handlePinChange}
autoFocus={true}
error={!!pinError}
disabled={pinDisabled}
size='xl'
/>
</Center>
{pinError && (
<Text align='center' size='sm' color='red' mt={0}>
{pinError}
</Text>
)}
<Group mt='sm' grow>
<Button
leftIcon={<IconX size='1rem' />}
color='red'
variant='outline'
onClick={() => {
setTotpOpen(false);
form.reset();
}}
>
Cancel login attempt
</Button>
<Button
leftIcon={<IconShieldQuestion size='1rem' />}
loading={pinDisabled}
type='submit'
onClick={() => onSubmit(form.values, pin)}
>
Verify
</Button>
</Group>
</Modal>
<Center
h='100vh'
sx={
@@ -120,7 +193,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
{showLocalLogin && (
<>
<form onSubmit={form.onSubmit(onSubmit)}>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='lg'

202
yarn.lock
View File

@@ -3744,6 +3744,54 @@ __metadata:
languageName: node
linkType: hard
"@otplib/core@npm:^12.0.1":
version: 12.0.1
resolution: "@otplib/core@npm:12.0.1"
checksum: b3c34bc20b31bc3f49cc0dc3c0eb070491c0101e8c1efa83cec48ca94158bd736aaca8187df667fc0c4a239d4ac52076bc44084bee04a50c80c3630caf77affa
languageName: node
linkType: hard
"@otplib/plugin-crypto@npm:^12.0.1":
version: 12.0.1
resolution: "@otplib/plugin-crypto@npm:12.0.1"
dependencies:
"@otplib/core": ^12.0.1
checksum: 6867c74ee8aca6c2db9670362cf51e44f3648602c39318bf537421242e33f0012a172acd43bbed9a21d706e535dc4c66aff965380673391e9fd74cf685b5b13a
languageName: node
linkType: hard
"@otplib/plugin-thirty-two@npm:^12.0.1":
version: 12.0.1
resolution: "@otplib/plugin-thirty-two@npm:12.0.1"
dependencies:
"@otplib/core": ^12.0.1
thirty-two: ^1.0.2
checksum: 920099e40d3e8c2941291c84c70064c2d86d0d1ed17230d650445d5463340e406bc413ddf2e40c374ddc4ee988ef1e3facacab9b5248b1ff361fd13df52bf88f
languageName: node
linkType: hard
"@otplib/preset-default@npm:^12.0.1":
version: 12.0.1
resolution: "@otplib/preset-default@npm:12.0.1"
dependencies:
"@otplib/core": ^12.0.1
"@otplib/plugin-crypto": ^12.0.1
"@otplib/plugin-thirty-two": ^12.0.1
checksum: 8133231384f6277f77eb8e42ef83bc32a8b01059bef147d1c358d9e9bfd292e1c239f581fe008367a48489dd68952b7ac0948e6c41412fc06079da2c91b71d16
languageName: node
linkType: hard
"@otplib/preset-v11@npm:^12.0.1":
version: 12.0.1
resolution: "@otplib/preset-v11@npm:12.0.1"
dependencies:
"@otplib/core": ^12.0.1
"@otplib/plugin-crypto": ^12.0.1
"@otplib/plugin-thirty-two": ^12.0.1
checksum: 367cb09397e617c21ec748d54e920ab43f1c5dfba70cbfd88edf73aecca399cf0c09fefe32518f79c7ee8a06e7058d14b200da378cc7d46af3cac4e22a153e2f
languageName: node
linkType: hard
"@phc/format@npm:^1.0.0":
version: 1.0.0
resolution: "@phc/format@npm:1.0.0"
@@ -4649,6 +4697,15 @@ __metadata:
languageName: node
linkType: hard
"@types/qrcode@npm:^1.5.1":
version: 1.5.1
resolution: "@types/qrcode@npm:1.5.1"
dependencies:
"@types/node": "*"
checksum: 0f780f31d983fa8d1bfbba1a130e8f7b07866d556807eccfb1d08d841c70644cdee8fc169ca752bdf17e99995e6155fcb6179079338ac8359294c781b95112b5
languageName: node
linkType: hard
"@types/qs@npm:*":
version: 6.9.7
resolution: "@types/qs@npm:6.9.7"
@@ -5840,6 +5897,13 @@ __metadata:
languageName: node
linkType: hard
"camelcase@npm:^5.0.0":
version: 5.3.1
resolution: "camelcase@npm:5.3.1"
checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001406":
version: 1.0.30001507
resolution: "caniuse-lite@npm:1.0.30001507"
@@ -6019,6 +6083,17 @@ __metadata:
languageName: node
linkType: hard
"cliui@npm:^6.0.0":
version: 6.0.0
resolution: "cliui@npm:6.0.0"
dependencies:
string-width: ^4.2.0
strip-ansi: ^6.0.0
wrap-ansi: ^6.2.0
checksum: 4fcfd26d292c9f00238117f39fc797608292ae36bac2168cfee4c85923817d0607fe21b3329a8621e01aedf512c99b7eaa60e363a671ffd378df6649fb48ae42
languageName: node
linkType: hard
"clone-response@npm:^1.0.2":
version: 1.0.3
resolution: "clone-response@npm:1.0.3"
@@ -6438,6 +6513,13 @@ __metadata:
languageName: node
linkType: hard
"decamelize@npm:^1.2.0":
version: 1.2.0
resolution: "decamelize@npm:1.2.0"
checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa
languageName: node
linkType: hard
"decimal.js@npm:^10.4.3":
version: 10.4.3
resolution: "decimal.js@npm:10.4.3"
@@ -6691,6 +6773,13 @@ __metadata:
languageName: node
linkType: hard
"dijkstrajs@npm:^1.0.1":
version: 1.0.3
resolution: "dijkstrajs@npm:1.0.3"
checksum: 82ff2c6633f235dd5e6bed04ec62cdfb1f327b4d7534557bd52f18991313f864ee50654543072fff4384a92b643ada4d5452f006b7098dbdfad6c8744a8c9e08
languageName: node
linkType: hard
"dir-glob@npm:^3.0.1":
version: 3.0.1
resolution: "dir-glob@npm:3.0.1"
@@ -6875,6 +6964,13 @@ __metadata:
languageName: node
linkType: hard
"encode-utf8@npm:^1.0.3":
version: 1.0.3
resolution: "encode-utf8@npm:1.0.3"
checksum: 550224bf2a104b1d355458c8a82e9b4ea07f9fc78387bc3a49c151b940ad26473de8dc9e121eefc4e84561cb0b46de1e4cd2bc766f72ee145e9ea9541482817f
languageName: node
linkType: hard
"encodeurl@npm:~1.0.2":
version: 1.0.2
resolution: "encodeurl@npm:1.0.2"
@@ -8376,6 +8472,13 @@ __metadata:
languageName: node
linkType: hard
"get-caller-file@npm:^2.0.1":
version: 2.0.5
resolution: "get-caller-file@npm:2.0.5"
checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9
languageName: node
linkType: hard
"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.0":
version: 1.2.1
resolution: "get-intrinsic@npm:1.2.1"
@@ -12052,6 +12155,17 @@ __metadata:
languageName: node
linkType: hard
"otplib@npm:^12.0.1":
version: 12.0.1
resolution: "otplib@npm:12.0.1"
dependencies:
"@otplib/core": ^12.0.1
"@otplib/preset-default": ^12.0.1
"@otplib/preset-v11": ^12.0.1
checksum: 4a1b91cf1b8e920b50ad4bac2ef2a89126630c62daf68e9b32ff15106b2551db905d3b979955cf5f8f114da0a8883cec3d636901d65e793c1745bb4174e2a572
languageName: node
linkType: hard
"outdent@npm:^0.8.0":
version: 0.8.0
resolution: "outdent@npm:0.8.0"
@@ -12514,6 +12628,13 @@ __metadata:
languageName: node
linkType: hard
"pngjs@npm:^5.0.0":
version: 5.0.0
resolution: "pngjs@npm:5.0.0"
checksum: 04e912cc45fb9601564e2284efaf0c5d20d131d9b596244f8a6789fc6cdb6b18d2975a6bbf7a001858d7e159d5c5c5dd7b11592e97629b7137f7f5cef05904c8
languageName: node
linkType: hard
"postcss-discard-duplicates@npm:^5.1.0":
version: 5.1.0
resolution: "postcss-discard-duplicates@npm:5.1.0"
@@ -12885,6 +13006,20 @@ __metadata:
languageName: node
linkType: hard
"qrcode@npm:^1.5.3":
version: 1.5.3
resolution: "qrcode@npm:1.5.3"
dependencies:
dijkstrajs: ^1.0.1
encode-utf8: ^1.0.3
pngjs: ^5.0.0
yargs: ^15.3.1
bin:
qrcode: bin/qrcode
checksum: 9a8a20a0a9cb1d15de8e7b3ffa214e8b6d2a8b07655f25bd1b1d77f4681488f84d7bae569870c0652872d829d5f8ac4922c27a6bd14c13f0e197bf07b28dead7
languageName: node
linkType: hard
"qs@npm:6.11.0":
version: 6.11.0
resolution: "qs@npm:6.11.0"
@@ -13409,6 +13544,13 @@ __metadata:
languageName: node
linkType: hard
"require-directory@npm:^2.1.1":
version: 2.1.1
resolution: "require-directory@npm:2.1.1"
checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80
languageName: node
linkType: hard
"require-like@npm:>= 0.1.1":
version: 0.1.2
resolution: "require-like@npm:0.1.2"
@@ -13416,6 +13558,13 @@ __metadata:
languageName: node
linkType: hard
"require-main-filename@npm:^2.0.0":
version: 2.0.0
resolution: "require-main-filename@npm:2.0.0"
checksum: e9e294695fea08b076457e9ddff854e81bffbe248ed34c1eec348b7abbd22a0d02e8d75506559e2265e96978f3c4720bd77a6dad84755de8162b357eb6c778c7
languageName: node
linkType: hard
"requireindex@npm:^1.2.0":
version: 1.2.0
resolution: "requireindex@npm:1.2.0"
@@ -14717,6 +14866,13 @@ __metadata:
languageName: node
linkType: hard
"thirty-two@npm:^1.0.2":
version: 1.0.2
resolution: "thirty-two@npm:1.0.2"
checksum: f6700b31d16ef942fdc0d14daed8a2f69ea8b60b0e85db8b83adf58d84bbeafe95a17d343ab55efaae571bb5148b62fc0ee12b04781323bf7af7d7e9693eec76
languageName: node
linkType: hard
"through2@npm:^2.0.3":
version: 2.0.5
resolution: "through2@npm:2.0.5"
@@ -15704,6 +15860,13 @@ __metadata:
languageName: node
linkType: hard
"which-module@npm:^2.0.0":
version: 2.0.1
resolution: "which-module@npm:2.0.1"
checksum: 1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be
languageName: node
linkType: hard
"which-typed-array@npm:^1.1.9":
version: 1.1.9
resolution: "which-typed-array@npm:1.1.9"
@@ -15896,6 +16059,13 @@ __metadata:
languageName: node
linkType: hard
"y18n@npm:^4.0.0":
version: 4.0.3
resolution: "y18n@npm:4.0.3"
checksum: 014dfcd9b5f4105c3bb397c1c8c6429a9df004aa560964fb36732bfb999bfe83d45ae40aeda5b55d21b1ee53d8291580a32a756a443e064317953f08025b1aa4
languageName: node
linkType: hard
"yallist@npm:^3.0.2":
version: 3.1.1
resolution: "yallist@npm:3.1.1"
@@ -15924,6 +16094,35 @@ __metadata:
languageName: node
linkType: hard
"yargs-parser@npm:^18.1.2":
version: 18.1.3
resolution: "yargs-parser@npm:18.1.3"
dependencies:
camelcase: ^5.0.0
decamelize: ^1.2.0
checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9
languageName: node
linkType: hard
"yargs@npm:^15.3.1":
version: 15.4.1
resolution: "yargs@npm:15.4.1"
dependencies:
cliui: ^6.0.0
decamelize: ^1.2.0
find-up: ^4.1.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: ^4.2.0
which-module: ^2.0.0
y18n: ^4.0.0
yargs-parser: ^18.1.2
checksum: 40b974f508d8aed28598087720e086ecd32a5fd3e945e95ea4457da04ee9bdb8bdd17fd91acff36dc5b7f0595a735929c514c40c402416bbb87c03f6fb782373
languageName: node
linkType: hard
"yocto-queue@npm:^0.1.0":
version: 0.1.0
resolution: "yocto-queue@npm:0.1.0"
@@ -15968,6 +16167,7 @@ __metadata:
"@types/katex": ^0.16.0
"@types/multer": ^1.4.7
"@types/node": ^20.3.1
"@types/qrcode": ^1.5.1
"@types/react": ^18.2.7
"@types/react-dom": ^18.2.4
"@types/signale": ^1.4.4
@@ -15988,7 +16188,9 @@ __metadata:
multer: ^1.4.5-lts.1
next: ^13.4.7
npm-run-all: ^4.1.5
otplib: ^12.0.1
prisma: ^5.0.0
qrcode: ^1.5.3
react: ^18.2.0
react-dom: ^18.2.0
react-markdown: ^8.0.7