feat: mfa support (#29)

This commit is contained in:
Sylver 2022-10-07 19:48:07 +08:00 committed by GitHub
parent ec071a47f6
commit f43a8e0cb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1499 additions and 390 deletions

View File

@ -28,6 +28,7 @@ An invite-only file sharing service with support for ShareX. You can see a previ
- [x] EXIF metadata removal
- [x] Conversions (GIF>WebM, WebP>PNG, etc.)
- [x] Purging of old and/or large files (`config.purge`).
- [x] 2FA support
## screenshots
@ -44,6 +45,7 @@ An invite-only file sharing service with support for ShareX. You can see a previ
</tr>
<tr>
<td><img src="https://i.imgur.com/1KUrtVf.png" title="Paste Page" alt="Paste Page"></td>
<td><img src="https://i.imgur.com/GYaEcKy.png" title="2FA setup" alt="2FA setup"></td>
</tr>
</table>

View File

@ -9,7 +9,7 @@
"node": ">=16"
},
"scripts": {
"watch": "tsup src/main.ts src/migrations/* --watch --onSuccess \"node dist/main.js\" --target node16",
"watch": "rm -rf ./dist && tsup src/main.ts src/migrations/* --watch --onSuccess \"node dist/main.js\" --target node16",
"build": "rm -rf ./dist && ncc build src/main.ts -o dist --minify --transpile-only --v8-cache --no-source-map-register",
"lint": "eslint src --fix --cache",
"test": "jest"
@ -55,6 +55,7 @@
"nanoid": "^3.3.4",
"nodemailer": "^6.7.6",
"normalize-url": "^6",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",

View File

@ -87,6 +87,34 @@
"primary": false,
"nullable": false,
"mappedType": "array"
},
"otp_secret": {
"name": "otp_secret",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"otp_enabled": {
"name": "otp_enabled",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "false",
"mappedType": "boolean"
},
"otp_recovery_codes": {
"name": "otp_recovery_codes",
"type": "text[]",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "array"
}
},
"name": "users",

View File

@ -0,0 +1,15 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20221006070931 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "users" add column "otp_secret" varchar(255) null, add column "otp_enabled" boolean not null default false, add column "otp_recovery_codes" text[] null;');
}
async down(): Promise<void> {
this.addSql('alter table "users" drop column "otp_secret";');
this.addSql('alter table "users" drop column "otp_enabled";');
this.addSql('alter table "users" drop column "otp_recovery_codes";');
}
}

View File

@ -1,44 +0,0 @@
import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../../config';
import type { JWTPayloadUser } from './strategies/jwt.strategy';
import { AuthService, TokenType } from './auth.service';
import ms from 'ms';
import { PasswordAuthGuard } from './guards/password.guard';
@Controller()
export class AuthController {
private static readonly ONE_YEAR = ms('1y');
private static readonly COOKIE_OPTIONS = {
path: '/',
httpOnly: true,
domain: config.rootHost.normalised.split(':').shift(),
secure: config.rootHost.url.startsWith('https'),
};
constructor(private readonly authService: AuthService) {}
@Post('auth/login')
@UseGuards(PasswordAuthGuard)
async login(@Req() request: FastifyRequest, @Res() reply: FastifyReply) {
const payload: JWTPayloadUser = { name: request.user.username, id: request.user.id, secret: request.user.secret };
const expiresAt = Date.now() + AuthController.ONE_YEAR;
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, payload, '1y');
return reply
.setCookie('token', token, {
...AuthController.COOKIE_OPTIONS,
expires: new Date(expiresAt),
})
.send({ ok: true });
}
@Post('auth/logout')
async logout(@Res() reply: FastifyReply) {
return reply
.setCookie('token', '', {
...AuthController.COOKIE_OPTIONS,
expires: new Date(),
})
.send({ ok: true });
}
}

View File

@ -2,15 +2,14 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { config } from '../../config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JWTStrategy } from './strategies/jwt.strategy';
import { PasswordStrategy } from './strategies/password.strategy';
import { User } from '../user/user.entity';
import { AuthResolver } from './auth.resolver';
@Module({
controllers: [AuthController],
providers: [AuthService, PasswordStrategy, JWTStrategy],
controllers: [],
providers: [AuthResolver, AuthService, JWTStrategy],
exports: [AuthService],
imports: [
MikroOrmModule.forFeature([User]),

View File

@ -0,0 +1,85 @@
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { UseGuards } from '@nestjs/common';
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
import type { FastifyReply } from 'fastify';
import ms from 'ms';
import { config } from '../../config';
import { User } from '../user/user.entity';
import { UserId } from './auth.decorators';
import { AuthService, TokenType } from './auth.service';
import { OTPEnabledDto } from './dto/otp-enabled.dto';
import { JWTAuthGuard } from './guards/jwt.guard';
import type { JWTPayloadUser } from './strategies/jwt.strategy';
@Resolver(() => User)
export class AuthResolver {
private static readonly ONE_YEAR = ms('1y');
private static readonly COOKIE_OPTIONS = {
path: '/',
httpOnly: true,
domain: config.rootHost.normalised.split(':').shift(),
secure: config.rootHost.url.startsWith('https'),
};
constructor(
@InjectRepository(User) private readonly userRepo: EntityRepository<User>,
private readonly authService: AuthService
) {}
@Mutation(() => User)
async login(
@Context() ctx: any,
@Args('username') username: string,
@Args('password') password: string,
@Args('otpCode', { nullable: true }) otpCode?: string
) {
const reply = ctx.reply as FastifyReply;
const user = await this.authService.authenticateUser(username, password, otpCode);
const payload: JWTPayloadUser = { name: user.username, id: user.id, secret: user.secret };
const expiresAt = Date.now() + AuthResolver.ONE_YEAR;
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, payload, '1y');
void reply.setCookie('token', token, {
...AuthResolver.COOKIE_OPTIONS,
expires: new Date(expiresAt),
});
// fixes querying things like user.token, which requires req.user to match the query user
ctx.req.user = user;
return user;
}
@Mutation(() => Boolean)
async logout(@Context() ctx: any) {
const reply = ctx.reply as FastifyReply;
void reply.setCookie('token', '', {
...AuthResolver.COOKIE_OPTIONS,
expires: new Date(),
});
return true;
}
@Mutation(() => OTPEnabledDto)
@UseGuards(JWTAuthGuard)
async generateOTP(@UserId() userId: string) {
const user = await this.userRepo.findOneOrFail(userId);
return this.authService.generateOTP(user);
}
@Mutation(() => Boolean)
@UseGuards(JWTAuthGuard)
async confirmOTP(@UserId() userId: string, @Args('otpCode') otpCode: string) {
const user = await this.userRepo.findOneOrFail(userId);
await this.authService.confirmOTP(user, otpCode);
return true;
}
@Mutation(() => Boolean)
@UseGuards(JWTAuthGuard)
async disableOTP(@UserId() userId: string, @Args('otpCode') otpCode: string) {
const user = await this.userRepo.findOneOrFail(userId);
await this.authService.disableOTP(user, otpCode);
return true;
}
}

View File

@ -1,5 +1,12 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { authenticator } from 'otplib';
import { User } from '../user/user.entity';
import type { OTPEnabledDto } from './dto/otp-enabled.dto';
export enum TokenType {
USER = 'USER',
@ -13,9 +20,14 @@ export interface TokenPayload {
iat: number;
}
const NUMBER_REGEX = /^\d{6}$/u;
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
constructor(
@InjectRepository(User) private readonly userRepo: EntityRepository<User>,
private readonly jwtService: JwtService
) {}
signToken<PayloadType extends Record<string, any>>(type: TokenType, payload: PayloadType, expiresIn = '1y') {
return this.jwtService.signAsync(payload, {
@ -36,4 +48,133 @@ export class AuthService {
throw new BadRequestException('Token validation failed.');
}
}
/**
* Authenticaet a user by username and password.
*/
async authenticateUser(username: string, password: string, otpCode?: string) {
const user = await this.userRepo.findOne({
$or: [{ username }, { email: { $ilike: username } }],
});
if (!user) throw new UnauthorizedException();
const passwordMatches = await bcrypt.compare(password, user.password);
if (!passwordMatches) {
throw new UnauthorizedException();
}
if (user.otpEnabled) {
// account has otp enabled, check if the code is valid
await this.validateOTPCode(otpCode, user);
}
return user;
}
/**
* Adds OTP codes to a user, without enabling OTP.
* This is the first step in enabling OTP, next will be to get the user to verify the code using enableOTP().
*/
async generateOTP(user: User): Promise<OTPEnabledDto> {
if (user.otpEnabled) {
throw new UnauthorizedException('User already has OTP enabled.');
}
const recoveryCodes = [];
user.otpSecret = authenticator.generateSecret();
user.otpRecoveryCodes = [];
for (let i = 0; i < 8; i++) {
const code = crypto
.randomBytes(8)
.toString('hex')
.match(/.{1,4}/gu)!
.join('-');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
user.otpRecoveryCodes.push(hashedCode);
recoveryCodes.push(code);
}
await this.userRepo.persistAndFlush(user);
return {
recoveryCodes,
secret: user.otpSecret,
qrauthUrl: authenticator.keyuri(user.username, 'Micro', user.otpSecret),
};
}
/**
* Enable OTP after the user has verified the code.
* Start by calling generateOTP() to get the code.
*/
async confirmOTP(user: User, otpCode: string) {
if (user.otpEnabled) {
throw new UnauthorizedException('User already has OTP enabled.');
}
if (!user.otpSecret || !user.otpRecoveryCodes || !user.otpRecoveryCodes[0]) {
throw new Error('User does not have 2FA codes.');
}
user.otpEnabled = true;
await this.validateOTPCode(otpCode, user);
await this.userRepo.persistAndFlush(user);
}
/**
* Disable OTP for a user.
* @param otpCode Either a recovery code or an OTP code.
*/
async disableOTP(user: User, otpCode: string) {
await this.validateOTPCode(otpCode, user);
user.otpSecret = undefined;
user.otpRecoveryCodes = undefined;
user.otpEnabled = false;
await this.userRepo.persistAndFlush(user);
}
/**
* Validate an OTP code for a user.
* Supports recovery codes.g
* @throws if the user does not have OTP enabled, check beforehand.
*/
private async validateOTPCode(otpCode: string | undefined, user: User) {
if (!user.otpEnabled || !user.otpSecret) {
throw new Error('User does not have OTP enabled.');
}
if (!otpCode) {
throw new UnauthorizedException('OTP code is required.');
}
if (this.isOTPCode(otpCode)) {
// user gave us an otp code
const isValid = authenticator.check(otpCode, user.otpSecret);
if (!isValid) {
throw new UnauthorizedException('Invalid OTP code.');
}
} else {
// user likely gave us a recovery code, or garbage
const hashedRecoveryCode = crypto.createHash('sha256').update(otpCode.toLowerCase()).digest('hex');
if (!user.otpRecoveryCodes) {
throw new Error('User has no recovery codes.');
}
const codeIndex = user.otpRecoveryCodes.indexOf(hashedRecoveryCode);
if (codeIndex === -1) {
throw new UnauthorizedException('Invalid or already used recovery code.');
}
// remove recovery code
user.otpRecoveryCodes.splice(codeIndex, 1);
await this.userRepo.persistAndFlush(user);
}
}
/**
* Determine if the input is numeric OTP code or not.
* Usually if this is false, the code will be a recovery code. Or garbage.
*/
private isOTPCode(input: string) {
return NUMBER_REGEX.test(input);
}
}

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class OTPEnabledDto {
@Field(() => [String])
recoveryCodes: string[];
@Field()
secret: string;
@Field()
qrauthUrl: string;
}

View File

@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class PasswordAuthGuard extends AuthGuard('local') {}

View File

@ -1,27 +0,0 @@
import { EntityRepository } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import bcrypt from 'bcryptjs';
import type { FastifyRequest } from 'fastify';
import { Strategy } from 'passport-local';
import { User } from '../../user/user.entity';
@Injectable()
export class PasswordStrategy extends PassportStrategy(Strategy) {
constructor(@InjectRepository(User) private readonly userRepo: EntityRepository<User>) {
super();
}
async validate(username: string, password: string): Promise<FastifyRequest['user']> {
const lowerUsername = username.toLowerCase();
const user = await this.userRepo.findOne({
$or: [{ username: lowerUsername }, { email: { $ilike: username } }],
});
if (!user) throw new UnauthorizedException();
const passwordMatches = await bcrypt.compare(password, user.password);
if (!passwordMatches) throw new UnauthorizedException();
return user;
}
}

View File

@ -1,4 +1,14 @@
import { Collection, Entity, Index, OneToMany, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import {
ArrayType,
Collection,
Entity,
Index,
OneToMany,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
} from '@mikro-orm/core';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Exclude } from 'class-transformer';
import { generateContentId } from '../../helpers/generate-content-id.helper';
@ -49,13 +59,25 @@ export class User {
@Exclude()
files = new Collection<File>(this);
@Exclude()
@OneToMany(() => UserVerification, (verification) => verification.user, {
orphanRemoval: true,
persist: true,
hidden: true,
})
@Exclude()
verifications = new Collection<UserVerification>(this);
@Exclude()
@Property({ nullable: true, hidden: true })
otpSecret?: string;
@Field()
@Property({ default: false, hidden: true })
otpEnabled: boolean;
@Exclude()
@Property({ nullable: true, hidden: true, type: ArrayType })
otpRecoveryCodes?: string[];
[OptionalProps]: 'permissions' | 'tags' | 'verifiedEmail';
}

View File

@ -40,17 +40,20 @@ export class UserResolver {
}
@ResolveField(() => Number)
async aggregateFileSize(@Parent() user: User) {
async aggregateFileSize(@UserId() userId: string, @Parent() user: User) {
if (userId !== user.id) throw new UnauthorizedException();
const result = await this.fileRepo.createQueryBuilder().where({ owner: user.id }).getKnex().sum('size').first();
return Number(result.sum);
}
@ResolveField(() => FilePage)
async files(
@UserId() userId: string,
@Parent() user: User,
@Args('first', { nullable: true }) limit: number = 0,
@Args('after', { nullable: true }) cursor?: string
): Promise<FilePage> {
if (userId !== user.id) throw new UnauthorizedException();
if (limit > 100) limit = 100;
if (limit <= 0) limit = 10;
const query: FilterQuery<File> = { owner: user.id };
@ -68,10 +71,12 @@ export class UserResolver {
@ResolveField(() => PastePage)
async pastes(
@UserId() userId: string,
@Parent() user: User,
@Args('first', { nullable: true }) limit: number = 0,
@Args('after', { nullable: true }) cursor?: string
): Promise<PastePage> {
if (userId !== user.id) throw new UnauthorizedException();
if (limit > 100) limit = 100;
if (limit <= 0) limit = 10;
const query: FilterQuery<Paste> = { owner: user.id };
@ -88,7 +93,8 @@ export class UserResolver {
}
@ResolveField(() => String)
async token(@Parent() user: User) {
async token(@UserId() userId: string, @Parent() user: User) {
if (userId !== user.id) throw new UnauthorizedException();
return this.authService.signToken<JWTPayloadUser>(TokenType.USER, {
name: user.username,
secret: user.secret,

View File

@ -100,15 +100,26 @@ type Link {
}
type Mutation {
confirmOTP(otpCode: String!): Boolean!
createInvite: Invite!
createLink(destination: String!, host: String): Link!
createPaste(partial: CreatePasteDto!): Paste!
createUser(data: CreateUserDto!): User!
deleteFile(fileId: ID!, key: String): Boolean!
disableOTP(otpCode: String!): Boolean!
generateOTP: OTPEnabledDto!
login(otpCode: String, password: String!, username: String!): User!
logout: Boolean!
refreshToken: User!
resendVerificationEmail(data: ResendVerificationEmailDto): Boolean!
}
type OTPEnabledDto {
qrauthUrl: String!
recoveryCodes: [String!]!
secret: String!
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
@ -176,6 +187,7 @@ type User {
email: String
files(after: String, first: Float): FilePage!
id: ID!
otpEnabled: Boolean!
pastes(after: String, first: Float): PastePage!
permissions: Float!
tags: [String!]!

View File

@ -6,4 +6,7 @@ generates:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
- 'typescript-react-apollo'
hooks:
afterAllFileWrite:
- prettier --write

View File

@ -34,6 +34,7 @@
"next": "12.2.0",
"postcss": "^8.4.13",
"prism-react-renderer": "^1.3.5",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
"react-dom": "^18.1.0",
"react-feather": "^2.0.9",
@ -53,6 +54,7 @@
"@types/lodash": "^4.14.182",
"@types/node": "16",
"@types/react": "^18.0.8",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
}
}

View File

@ -22,6 +22,7 @@ export interface InputContainerProps<T> {
maxHeight?: boolean;
className?: string;
style?: InputStyle;
isError?: boolean;
childProps: T;
children: (data: { childClasses: string } & T) => ReactNode;
}
@ -29,7 +30,8 @@ export interface InputContainerProps<T> {
export function InputContainer<T extends InputChildPropsBase>({
children,
className,
style = InputStyle.Default,
isError,
style = isError ? InputStyle.Error : InputStyle.Default,
maxHeight = true,
childProps,
...rest

View File

@ -3,11 +3,13 @@ import React from 'react';
import type { InputChildProps } from './container';
import { InputContainer } from './container';
export interface InputProps extends InputChildProps<InputHTMLAttributes<HTMLInputElement>> {}
export interface InputProps extends InputChildProps<InputHTMLAttributes<HTMLInputElement>> {
isError?: boolean;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...delegated }, ref) => {
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isError, ...delegated }, ref) => {
return (
<InputContainer className={className} childProps={delegated}>
<InputContainer className={className} childProps={delegated} isError={isError}>
{({ childClasses, ...rest }) => <input {...rest} className={childClasses} ref={ref} />}
</InputContainer>
);

View File

@ -0,0 +1,44 @@
import { Spinner } from '@ryanke/pandora';
import type { FC } from 'react';
import { Input } from './input';
export interface OtpInputProps {
loading: boolean;
invalid?: boolean;
onCode: (code: string) => void;
}
const TOTP_CODE_LENGTH = 6;
const RECOVERY_CODE_LENGTH = 19;
const NUMBER_REGEX = /^\d+$/u;
export const OtpInput: FC<OtpInputProps> = ({ loading, invalid, onCode }) => {
return (
<div className="relative w-full">
<Input
isError={invalid}
placeholder="123456"
onChange={(event) => {
if (loading || !event.target.value) return;
if (
(event.target.value.length === TOTP_CODE_LENGTH && NUMBER_REGEX.test(event.target.value)) ||
event.target.value.length === RECOVERY_CODE_LENGTH
) {
onCode(event.target.value);
}
}}
onKeyDown={(event) => {
if (loading || !event.currentTarget.value) return;
if (event.key === 'Enter') {
onCode(event.currentTarget.value);
}
}}
/>
{loading && (
<div className="absolute right-3 top-0 bottom-0 flex items-center justify-center">
<Spinner size="small" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,20 @@
import type { FC } from 'react';
import { Fragment } from 'react';
export interface PingProps {
active: boolean;
}
export const Ping: FC<PingProps> = ({ active }) => {
return (
<div className="h-full flex relative items-center">
{active && (
<Fragment>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-300 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-purple-400" />
</Fragment>
)}
{!active && <span className="relative inline-flex rounded-full h-3 w-3 bg-gray-600" />}
</div>
);
};

View File

@ -1,7 +1,10 @@
import classNames from 'classnames';
import type { FC, ReactNode } from 'react';
export const Section: FC<{ className?: string; children: ReactNode }> = ({ className, children }) => {
const classes = classNames('absolute left-0 right-0 py-8 bg-black shadow-lg', className);
return <section className={classes}>{children}</section>;
return (
<div className="relative py-8">
<section className={className}>{children}</section>
<div className="top-0 bottom-0 absolute w-[500vw] -left-full h-full bg-black shadow-lg -z-10" />
</div>
);
};

View File

@ -0,0 +1,29 @@
import classNames from 'classnames';
import type { FC } from 'react';
import { Fragment } from 'react';
import { Ping } from './ping';
export interface StepsProps {
steps: string[];
stepIndex: number;
onClick?: (index: number) => void;
}
export const Steps: FC<StepsProps> = ({ steps, stepIndex }) => {
return (
<div className="flex justify-center items-center text-sm">
{steps.map((step, index) => {
const isActive = index === stepIndex;
return (
<Fragment key={step}>
{index !== 0 && <div className="h-px w-8 mx-4 bg-gray-800" />}
<div className="flex items-center gap-2">
<Ping active={isActive} />
<div className={classNames(!isActive && 'text-gray-400')}>{step}</div>
</div>
</Fragment>
);
})}
</div>
);
};

View File

@ -33,7 +33,7 @@ export const ConfigGenerator = () => {
return (
<Section>
<Container className="flex flex-col justify-between dots selection:bg-purple-600 py-8">
<Container className="flex flex-col justify-between dots selection:bg-purple-600 py-8 px-0">
<div className="w-full flex-grow">
{!config.data && (
<div className="flex items-center justify-center w-full h-full py-10">

View File

@ -1,7 +1,11 @@
import { useAsync } from '@ryanke/pandora';
import classNames from 'classnames';
import { Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import type { FC } from 'react';
import { Fragment, useCallback, useEffect } from 'react';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { OtpInput } from 'src/components/input/otp';
import type { LoginMutationVariables } from 'src/generated/graphql';
import * as Yup from 'yup';
import { Input } from '../components/input/input';
import { Submit } from '../components/input/submit';
@ -15,6 +19,8 @@ const schema = Yup.object().shape({
export const LoginForm: FC = () => {
const user = useUser();
const router = useRouter();
const [loginInfo, setLoginInfo] = useState<LoginMutationVariables | null>(null);
const [invalidOTP, setInvalidOTP] = useState(false);
const redirect = useCallback(() => {
const url = new URL(window.location.href);
const to = url.searchParams.get('to') ?? '/dashboard';
@ -28,12 +34,45 @@ export const LoginForm: FC = () => {
}
}, [user, router, redirect]);
const [login, loggingIn] = useAsync(async (values: LoginMutationVariables) => {
try {
setInvalidOTP(false);
await user.login(values);
redirect();
} catch (error: any) {
if (user.otpRequired && error.message.toLowerCase().includes('invalid otp')) {
setInvalidOTP(true);
return;
}
throw error;
}
});
if (user.otpRequired && loginInfo) {
return (
<div>
<OtpInput
loading={loggingIn}
invalid={invalidOTP}
onCode={(otp) => {
login({ ...loginInfo, otp });
}}
/>
<span className={classNames(`text-xs text-center`, invalidOTP ? 'text-red-400' : 'text-gray-600')}>
{invalidOTP ? 'Invalid OTP code' : 'Enter the OTP code from your authenticator app'}
</span>
</div>
);
}
return (
<Fragment>
<Formik
initialValues={{ username: '', password: '' }}
validationSchema={schema}
onSubmit={async (values) => {
setLoginInfo(values);
await user.login(values);
redirect();
}}

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ export const useQueryState = <S>(key: string, initialState?: S, parser?: (input:
const result = parser ? parser(value) : (value as any);
setValue(result);
}
}, [key, parser]);
}, []);
useEffect(() => {
const route = new URL(window.location.href);

View File

@ -1,6 +1,7 @@
query GetUser {
user {
...RegularUser
otpEnabled
}
}
@ -11,3 +12,29 @@ fragment RegularUser on User {
verifiedEmail
token
}
mutation Login($username: String!, $password: String!, $otp: String) {
login(username: $username, password: $password, otpCode: $otp) {
...RegularUser
}
}
mutation Logout {
logout
}
mutation GenerateOTP {
generateOTP {
recoveryCodes
qrauthUrl
secret
}
}
mutation ConfirmOTP($otpCode: String!) {
confirmOTP(otpCode: $otpCode)
}
mutation DisableOTP($otpCode: String!) {
disableOTP(otpCode: $otpCode)
}

View File

@ -1,32 +1,37 @@
import { useAsync } from '@ryanke/pandora';
import Router, { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { resetClient } from '../apollo';
import { useGetUserQuery } from '../generated/graphql';
import { http } from '../helpers/http.helper';
interface LoginData {
username: string;
password: string;
}
import type { LoginMutationVariables } from '../generated/graphql';
import { useGetUserQuery, useLoginMutation, useLogoutMutation } from '../generated/graphql';
export const useUser = (redirect = false) => {
const user = useGetUserQuery();
const router = useRouter();
const [loginMutation] = useLoginMutation();
const [logoutMutation] = useLogoutMutation();
const [otp, setOtp] = useState(false);
const [login] = useAsync(async (data: LoginData) => {
await http(`auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const [login] = useAsync(async (variables: LoginMutationVariables) => {
try {
await loginMutation({
variables: variables,
});
await user.refetch();
Router.push('/dashboard');
await user.refetch();
Router.push('/dashboard');
} catch (error: any) {
console.log({ error });
if (error.message.toLowerCase().includes('otp')) {
setOtp(true);
}
throw error;
}
});
const [logout] = useAsync(async () => {
await http(`auth/logout`, { method: 'POST' });
await logoutMutation();
resetClient();
});
@ -40,6 +45,7 @@ export const useUser = (redirect = false) => {
data: user.data?.user,
error: user.error,
loading: user.loading,
otpRequired: otp,
login: login,
logout: logout,
} as const;

View File

@ -0,0 +1,167 @@
import { Button, ButtonStyle, Container, useAsync, useToasts } from '@ryanke/pandora';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import { QRCodeSVG } from 'qrcode.react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Copy, Download } from 'react-feather';
import { OtpInput } from 'src/components/input/otp';
import { PageLoader } from 'src/components/page-loader';
import { Steps } from 'src/components/steps';
import type { GenerateOtpMutation } from 'src/generated/graphql';
import { useConfirmOtpMutation, useGenerateOtpMutation } from 'src/generated/graphql';
import { useQueryState } from 'src/hooks/useQueryState';
export default function Generate() {
const [generate] = useGenerateOtpMutation();
const [result, setResult] = useState<GenerateOtpMutation | null>(null);
const createToast = useToasts();
const router = useRouter();
const [currentStep, setCurrentStep] = useQueryState('step', 0, (value) => Number(value));
const [confirmOtp] = useConfirmOtpMutation();
useEffect(() => {
generate()
.then(({ data }) => {
if (!data) return;
setResult(data);
})
.catch((error) => {
createToast({
text: error.message,
error: true,
});
router.push('/dashboard');
});
}, []);
const copyable = useMemo(() => {
if (!result) return;
const prefix = `Use these in place of OTP codes in emergency situations on ${window.location.host}. \nEach code will only work once. If you are close to running out, you should generate new codes.\n\n`;
const body = result.generateOTP.recoveryCodes.join('\n');
return prefix + body;
}, [result]);
const download = useCallback(() => {
if (!copyable) return;
const element = document.createElement('a');
const file = new Blob([copyable], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `${window.location.host}-recovery-codes.txt`;
document.body.append(element);
element.click();
}, [copyable]);
const copy = useCallback(() => {
if (!copyable) return;
navigator.clipboard.writeText(copyable);
createToast({
text: 'Copied recovery codes!',
});
}, [createToast, copyable]);
const [confirm, confirming] = useAsync(async (otpCode: string) => {
try {
await confirmOtp({ variables: { otpCode } });
createToast({ text: 'Successfully enabled 2FA!' });
router.replace('/dashboard');
} catch (error: any) {
if (error.message) {
createToast({
text: error.message,
error: true,
});
}
}
});
if (!result) {
return <PageLoader />;
}
return (
<Container>
<Steps steps={['Backup', 'Scan', 'Verify']} stepIndex={currentStep} />
<div className="max-w-xl mx-auto mt-16">
<div className="flex gap-8 md:h-[20em] flex flex-col-reverse md:grid md:grid-cols-6">
{currentStep === 0 && (
<Fragment>
<div className="bg-gray-900 p-4 h-min rounded-xl font-mono whitespace-pre truncate text-gray-600 col-span-2 select-none text-center">
{result.generateOTP.recoveryCodes.join('\n')}
</div>
<div className="col-span-4">
<h2>Store your backup codes</h2>
<p className="max-w-xl text-gray-500 text-sm mt-2">
You should store these codes somewhere safe. If you lose access to your authenticator, you can use
these codes to access your account. Each code will only work once.
</p>
<div className="flex mt-8 gap-2">
<Button style={ButtonStyle.Secondary} className="w-auto" onClick={download}>
<Download className="h-3.5 w-3.5" />
Download Codes
</Button>
<Button style={ButtonStyle.Secondary} className="w-auto" onClick={copy}>
<Copy className="h-3.5 w-3.5" />
Copy Codes
</Button>
</div>
</div>
</Fragment>
)}
{currentStep === 1 && (
<Fragment>
<div className="p-4 rounded-xl h-min bg-white col-span-2">
<QRCodeSVG className="w-full h-full" size={128} value={result.generateOTP.qrauthUrl} />
</div>
<div className="col-span-4">
<h2>Scan the QR code</h2>
<div className="max-w-xl text-gray-500 text-sm mt-2 space-y-2">
<p>
Scan this QR code with your authenticator app. You can use any authenticator app that supports TOTP,
such as Google Authenticator and Authy.
</p>
<p className="text-xs text-gray-600">
If you can't scan the QR code, you can enter the code{' '}
<code className="text-purple-400">{result.generateOTP.secret}</code> manually.
</p>
</div>
</div>
</Fragment>
)}
{currentStep === 2 && (
<Fragment>
<div className="col-span-full">
<h2>Verify your code</h2>
<p className="max-w-xl text-gray-500 text-sm mt-2">
Enter the code from your authenticator app to verify that it is working.
</p>
<div className="flex mt-4 gap-2">
<OtpInput loading={confirming} onCode={confirm} />
</div>
</div>
</Fragment>
)}
</div>
<div className="w-full flex justify-between items-center mt-8">
<button
type="button"
onClick={() => setCurrentStep((prev) => prev - 1)}
disabled={currentStep === 0}
className={classNames(
`text-gray-400 flex items-center gap-1 hover:underline`,
currentStep === 0 && 'opacity-0 pointer-events-none'
)}
>
<ChevronLeft className="h-4 w-4" /> Back
</button>
<Button
onClick={() => setCurrentStep((prev) => prev + 1)}
disabled={currentStep === 2}
className="w-auto ml-auto"
>
Next <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</Container>
);
}

View File

@ -1,15 +1,18 @@
import { Breadcrumbs, Container, useAsync } from '@ryanke/pandora';
import { Breadcrumbs, Button, Container, useAsync } from '@ryanke/pandora';
import { useRouter } from 'next/router';
import { OtpInput } from 'src/components/input/otp';
import { Input } from '../../components/input/input';
import { PageLoader } from '../../components/page-loader';
import { Title } from '../../components/title';
import { ConfigGenerator } from '../../containers/config-generator/config-generator';
import { useRefreshTokenMutation } from '../../generated/graphql';
import { GetUserDocument, useDisableOtpMutation, useRefreshTokenMutation } from '../../generated/graphql';
import { useConfig } from '../../hooks/useConfig';
import { useUser } from '../../hooks/useUser';
export default function Preferences() {
const user = useUser(true);
const config = useConfig();
const router = useRouter();
const [refreshMutation] = useRefreshTokenMutation();
const [refresh, refreshing] = useAsync(async () => {
// eslint-disable-next-line no-alert
@ -19,6 +22,10 @@ export default function Preferences() {
await user.logout();
});
const [disableOTP, disableOTPMut] = useDisableOtpMutation({
refetchQueries: [{ query: GetUserDocument }],
});
if (!user.data || !config.data) {
return <PageLoader title="Preferences" />;
}
@ -53,6 +60,32 @@ export default function Preferences() {
<div className="mt-10">
<ConfigGenerator />
</div>
<div className="grid grid-cols-2 gap-4 mt-8">
<div className="left col-span-full md:col-span-1">
<div className="font-bold text-xl">2-factor Authentication</div>
<p className="text-sm mt-2 text-gray-400">
2-factor authentication is currently {user.data.otpEnabled ? 'enabled' : 'disabled'}.{' '}
{user.data.otpEnabled ? `Enter an authenticator code to disable it.` : 'Click to setup.'}
</p>
</div>
<div className="right flex items-center col-span-full md:col-span-1">
{user.data.otpEnabled && (
<OtpInput
loading={disableOTPMut.loading}
onCode={(otpCode) => {
disableOTP({
variables: { otpCode },
});
}}
/>
)}
{!user.data.otpEnabled && (
<Button className="w-auto ml-auto" onClick={() => router.push(`/dashboard/mfa`)}>
Enable 2FA
</Button>
)}
</div>
</div>
</Container>
);
}

View File

@ -64,6 +64,7 @@ importers:
nanoid: ^3.3.4
nodemailer: ^6.7.6
normalize-url: ^6
otplib: ^12.0.1
passport: ^0.6.0
passport-jwt: ^4.0.0
passport-local: ^1.0.0
@ -117,6 +118,7 @@ importers:
nanoid: 3.3.4
nodemailer: 6.7.6
normalize-url: 6.1.0
otplib: 12.0.1
passport: 0.6.0
passport-jwt: 4.0.0
passport-local: 1.0.0
@ -204,7 +206,9 @@ importers:
nanoid: ^3.3.4
next: 12.2.0
postcss: ^8.4.13
prettier: ^2.7.1
prism-react-renderer: ^1.3.5
qrcode.react: ^3.1.0
react: 18.2.0
react-dom: ^18.1.0
react-feather: ^2.0.9
@ -235,6 +239,7 @@ importers:
next: 12.2.0_biqbaboplfbrettd7655fr4n2y
postcss: 8.4.14
prism-react-renderer: 1.3.5_react@18.2.0
qrcode.react: 3.1.0_react@18.2.0
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-feather: 2.0.10_react@18.2.0
@ -253,6 +258,7 @@ importers:
'@types/lodash': 4.14.182
'@types/node': 16.11.43
'@types/react': 18.0.14
prettier: 2.7.1
typescript: 4.7.4
packages:
@ -1600,6 +1606,7 @@ packages:
engines: {node: '>=12'}
dependencies:
'@jridgewell/trace-mapping': 0.3.9
dev: true
/@endemolshinegroup/cosmiconfig-typescript-loader/3.0.2_zmjss6mecb4soo3dpdlecld3xa:
resolution: {integrity: sha512-QRVtqJuS1mcT56oHpVegkKBlgtWjXw/gHNWO3eL9oyB5Sc7HBoc2OLG/nYpVfT/Jejvo3NUrD0Udk7XgoyDKkA==}
@ -2355,7 +2362,6 @@ packages:
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/@jest/core/28.1.2_ts-node@10.8.2:
resolution: {integrity: sha512-Xo4E+Sb/nZODMGOPt2G3cMmCBqL4/W2Ijwr7/mrXlq4jdJwcFQ/9KrrJZT2adQRk2otVBXXOz1GRQ4Z5iOgvRQ==}
@ -2398,6 +2404,7 @@ packages:
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/@jest/create-cache-key-function/27.5.1:
resolution: {integrity: sha512-dmH1yW+makpTSURTy8VzdUwFnfQh1G8R+DxO2Ho2FFmBbKFEVm+3jWdvFhE2VqB/LATCTokkP0dotjyQyw5/AQ==}
@ -2663,6 +2670,7 @@ packages:
dependencies:
'@jridgewell/resolve-uri': 3.0.8
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@mikro-orm/cli/5.2.2_4k2cb7nrrwahnwnvazylxfgb44:
resolution: {integrity: sha512-IRMcfVFF6edBX2lQsKswMAjw6ILOvwnLT0cMJziknVMuPop/ckaWxlA5r4Uu1BorNtoHf52qB9i3r5YwEe9qAQ==}
@ -3396,6 +3404,39 @@ packages:
- encoding
dev: false
/@otplib/core/12.0.1:
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
dev: false
/@otplib/plugin-crypto/12.0.1:
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
dependencies:
'@otplib/core': 12.0.1
dev: false
/@otplib/plugin-thirty-two/12.0.1:
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
dev: false
/@otplib/preset-default/12.0.1:
resolution: {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
dev: false
/@otplib/preset-v11/12.0.1:
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@protobufjs/aspromise/1.1.2:
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
dev: false
@ -3548,6 +3589,7 @@ packages:
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@swc/core-android-arm64/1.2.204:
@ -3564,6 +3606,7 @@ packages:
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@swc/core-darwin-arm64/1.2.204:
@ -3580,6 +3623,7 @@ packages:
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@swc/core-darwin-x64/1.2.204:
@ -3596,6 +3640,7 @@ packages:
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@swc/core-freebsd-x64/1.2.204:
@ -3612,6 +3657,7 @@ packages:
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-arm-gnueabihf/1.2.204:
@ -3628,6 +3674,7 @@ packages:
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-arm64-gnu/1.2.204:
@ -3644,6 +3691,7 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-arm64-musl/1.2.204:
@ -3660,6 +3708,7 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-x64-gnu/1.2.204:
@ -3676,6 +3725,7 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-linux-x64-musl/1.2.204:
@ -3692,6 +3742,7 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@swc/core-win32-arm64-msvc/1.2.204:
@ -3708,6 +3759,7 @@ packages:
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@swc/core-win32-ia32-msvc/1.2.204:
@ -3724,6 +3776,7 @@ packages:
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@swc/core-win32-x64-msvc/1.2.204:
@ -3740,6 +3793,7 @@ packages:
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@swc/core/1.2.204:
@ -3779,6 +3833,7 @@ packages:
'@swc/core-win32-arm64-msvc': 1.2.208
'@swc/core-win32-ia32-msvc': 1.2.208
'@swc/core-win32-x64-msvc': 1.2.208
dev: true
/@swc/helpers/0.4.2:
resolution: {integrity: sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw==}
@ -3821,7 +3876,7 @@ packages:
eslint: 8.19.0
eslint-config-galex: 3.6.5_eslint@8.19.0+jest@28.1.2
eslint-plugin-es: 4.1.0_eslint@8.19.0
jest: 28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy
jest: 28.1.2_@types+node@16.11.43
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -3885,15 +3940,19 @@ packages:
/@tsconfig/node10/1.0.9:
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
dev: true
/@tsconfig/node12/1.0.11:
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
dev: true
/@tsconfig/node14/1.0.3:
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
dev: true
/@tsconfig/node16/1.0.3:
resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==}
dev: true
/@types/accepts/1.3.5:
resolution: {integrity: sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==}
@ -4539,6 +4598,7 @@ packages:
/acorn-walk/8.2.0:
resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==}
engines: {node: '>=0.4.0'}
dev: true
/acorn/7.4.1:
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
@ -5002,6 +5062,7 @@ packages:
/arg/4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
dev: true
/arg/5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@ -6059,6 +6120,7 @@ packages:
/create-require/1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
dev: true
/cron/1.8.2:
resolution: {integrity: sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==}
@ -6360,6 +6422,7 @@ packages:
/diff/4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
dev: true
/diff/5.1.0:
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
@ -7000,7 +7063,7 @@ packages:
'@typescript-eslint/eslint-plugin': 5.12.0_byxz3mnn3ms3gbtbci7fo4cm6q
'@typescript-eslint/utils': 5.29.0_m32fwjepeyylyephxtubzxm4ui
eslint: 8.19.0
jest: 28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy
jest: 28.1.2_@types+node@16.11.43
transitivePeerDependencies:
- supports-color
- typescript
@ -8970,7 +9033,6 @@ packages:
- '@types/node'
- supports-color
- ts-node
dev: true
/jest-cli/28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy:
resolution: {integrity: sha512-l6eoi5Do/IJUXAFL9qRmDiFpBeEJAnjJb1dcd9i/VWfVWbp3mJhuH50dNtX67Ali4Ecvt4eBkWb4hXhPHkAZTw==}
@ -8998,6 +9060,7 @@ packages:
- '@types/node'
- supports-color
- ts-node
dev: true
/jest-config/28.1.2_4maxphccb5fztufhofwcslq6fm:
resolution: {integrity: sha512-g6EfeRqddVbjPVBVY4JWpUY4IvQoFRIZcv4V36QkqzE0IGhEC/VkugFeBMAeUE7PRgC8KJF0yvJNDeQRbamEVA==}
@ -9037,6 +9100,7 @@ packages:
ts-node: 10.8.2_pbcylixk5f7tclmtradmulh4qa
transitivePeerDependencies:
- supports-color
dev: true
/jest-config/28.1.2_@types+node@16.11.43:
resolution: {integrity: sha512-g6EfeRqddVbjPVBVY4JWpUY4IvQoFRIZcv4V36QkqzE0IGhEC/VkugFeBMAeUE7PRgC8KJF0yvJNDeQRbamEVA==}
@ -9075,7 +9139,6 @@ packages:
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
dev: true
/jest-config/28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy:
resolution: {integrity: sha512-g6EfeRqddVbjPVBVY4JWpUY4IvQoFRIZcv4V36QkqzE0IGhEC/VkugFeBMAeUE7PRgC8KJF0yvJNDeQRbamEVA==}
@ -9115,6 +9178,7 @@ packages:
ts-node: 10.8.2_pbcylixk5f7tclmtradmulh4qa
transitivePeerDependencies:
- supports-color
dev: true
/jest-diff/28.1.1:
resolution: {integrity: sha512-/MUUxeR2fHbqHoMMiffe/Afm+U8U4olFRJ0hiVG2lZatPJcnGxx292ustVu7bULhjV65IYMxRdploAKLbcrsyg==}
@ -9454,7 +9518,6 @@ packages:
- '@types/node'
- supports-color
- ts-node
dev: true
/jest/28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy:
resolution: {integrity: sha512-Tuf05DwLeCh2cfWCQbcz9UxldoDyiR1E9Igaei5khjonKncYdc6LDfynKCEWozK0oLE3GD+xKAo2u8x/0s6GOg==}
@ -9474,6 +9537,7 @@ packages:
- '@types/node'
- supports-color
- ts-node
dev: true
/joycon/3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
@ -10003,6 +10067,7 @@ packages:
/make-error/1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: true
/make-fetch-happen/8.0.14:
resolution: {integrity: sha512-EsS89h6l4vbfJEtBZnENTOFk8mCRpY5ru36Xe5bcX1KYIli2mkSHqoFsp5O1wMDvTJJzxe/4THpCTtygjeeGWQ==}
@ -11065,6 +11130,14 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/otplib/12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/preset-default': 12.0.1
'@otplib/preset-v11': 12.0.1
dev: false
/p-cancelable/1.1.0:
resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==}
engines: {node: '>=6'}
@ -11552,6 +11625,12 @@ packages:
engines: {node: '>=4'}
dev: true
/prettier/2.7.1:
resolution: {integrity: sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==}
engines: {node: '>=10.13.0'}
hasBin: true
dev: true
/pretty-bytes/6.0.0:
resolution: {integrity: sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==}
engines: {node: ^14.13.1 || >=16.0.0}
@ -11667,6 +11746,14 @@ packages:
engines: {node: '>= 14'}
dev: false
/qrcode.react/3.1.0_react@18.2.0:
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/queue-microtask/1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -12925,6 +13012,11 @@ packages:
any-promise: 1.3.0
dev: true
/thirty-two/1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
dev: false
/throat/6.0.1:
resolution: {integrity: sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==}
@ -13104,6 +13196,7 @@ packages:
typescript: 4.7.4
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
dev: true
/ts-node/9.1.1_typescript@4.7.4:
resolution: {integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==}
@ -13420,6 +13513,7 @@ packages:
resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/ua-parser-js/0.7.31:
resolution: {integrity: sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==}
@ -13652,6 +13746,7 @@ packages:
/v8-compile-cache-lib/3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
dev: true
/v8-compile-cache/2.3.0:
resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==}
@ -13999,6 +14094,7 @@ packages:
/yn/3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
dev: true
/yocto-queue/0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}