feat: add two factor auth (#643)

Add two factor authentication for users who wish to enhance the security of their accounts.
This commit is contained in:
Nafees Nazik
2023-12-01 05:52:16 +05:30
committed by GitHub
parent 65cc26fd02
commit 8d0e815b0f
42 changed files with 2055 additions and 91 deletions

View File

@ -0,0 +1 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;

View File

@ -7,6 +7,8 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { ErrorCode } from './error-codes';
@ -22,13 +24,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
totpCode: {
label: 'Two-factor Code',
type: 'input',
placeholder: 'Code from authenticator app',
},
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
},
authorize: async (credentials, _req) => {
if (!credentials) {
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
}
const { email, password } = credentials;
const { email, password, backupCode, totpCode } = credentials;
const user = await getUserByEmail({ email }).catch(() => {
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
@ -44,6 +52,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
throw new Error(
totpCode
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
);
}
}
return {
id: Number(user.id),
email: user.email,

View File

@ -8,4 +8,15 @@ export const ErrorCode = {
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
} as const;

View File

@ -24,6 +24,8 @@
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
@ -33,6 +35,7 @@
"nanoid": "^4.0.2",
"next": "14.0.0",
"next-auth": "4.24.3",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"remeda": "^1.27.1",

View File

@ -0,0 +1,48 @@
import { compare } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
backupCode: string;
password: string;
};
export const disableTwoFactorAuthentication = async ({
backupCode,
user,
password,
}: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
if (!isValid) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
return true;
};

View File

@ -0,0 +1,47 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = {
user: User;
code: string;
};
export const enableTwoFactorAuthentication = async ({
user,
code,
}: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
}
if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
return { recoveryCodes };
};

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
interface GetBackupCodesOptions {
user: User;
}
const ZBackupCodeSchema = z.array(z.string());
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorEnabled) {
throw new Error('User has not enabled 2FA');
}
if (!user.twoFactorBackupCodes) {
throw new Error('User has no backup codes');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
'utf-8',
);
const data = JSON.parse(secret);
const result = ZBackupCodeSchema.safeParse(data);
if (result.success) {
return result.data;
}
return null;
};

View File

@ -0,0 +1,17 @@
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
type IsTwoFactorAuthenticationEnabledOptions = {
user: User;
};
export const isTwoFactorAuthenticationEnabled = ({
user,
}: IsTwoFactorAuthenticationEnabledOptions) => {
return (
user.twoFactorEnabled &&
user.identityProvider === 'DOCUMENSO' &&
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
);
};

View File

@ -0,0 +1,76 @@
import { base32 } from '@scure/base';
import { compare } from 'bcrypt';
import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';
type SetupTwoFactorAuthenticationOptions = {
user: User;
password: string;
};
const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({
user,
password,
}: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
}
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const secret = crypto.randomBytes(10);
const backupCodes = new Array(10)
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
const accountName = user.email;
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
const encodedSecret = base32.encode(secret);
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
}),
twoFactorSecret: symmetricEncrypt({
data: encodedSecret,
key: key,
}),
},
});
return {
secret: encodedSecret,
uri,
};
};

View File

@ -0,0 +1,35 @@
import { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
import { verifyBackupCode } from './verify-backup-code';
type ValidateTwoFactorAuthenticationOptions = {
totpCode?: string;
backupCode?: string;
user: User;
};
export const validateTwoFactorAuthentication = async ({
backupCode,
totpCode,
user,
}: ValidateTwoFactorAuthenticationOptions) => {
if (!user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
}
if (totpCode) {
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
}
if (backupCode) {
return await verifyBackupCode({ user, backupCode });
}
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
};

View File

@ -0,0 +1,33 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
const totp = new TOTPController();
type VerifyTwoFactorAuthenticationTokenOptions = {
user: User;
totpCode: string;
};
export const verifyTwoFactorAuthenticationToken = async ({
user,
totpCode,
}: VerifyTwoFactorAuthenticationTokenOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorSecret) {
throw new Error('user missing 2fa secret');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
'utf-8',
);
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
return isValidToken;
};

View File

@ -0,0 +1,18 @@
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
type VerifyBackupCodeParams = {
user: User;
backupCode: string;
};
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
const userBackupCodes = await getBackupCodes({ user });
if (!userBackupCodes) {
throw new Error('User has no backup codes');
}
return userBackupCodes.includes(backupCode);
};

View File

@ -1,4 +1,4 @@
import { hashSync as bcryptHashSync } from 'bcrypt';
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
import { SALT_ROUNDS } from '../../constants/auth';
@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
export const hashSync = (password: string) => {
return bcryptHashSync(password, SALT_ROUNDS);
};
export const compareSync = (password: string, hash: string) => {
return bcryptCompareSync(password, hash);
};

View File

@ -0,0 +1,32 @@
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
import { sha256 } from '@noble/hashes/sha256';
export type SymmetricEncryptOptions = {
key: string;
data: string;
};
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = utf8ToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
return bytesToHex(chacha.encrypt(dataAsBytes));
};
export type SymmetricDecryptOptions = {
key: string;
data: string;
};
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = hexToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
return chacha.decrypt(dataAsBytes);
};

View File

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

View File

@ -19,25 +19,28 @@ enum Role {
}
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now())
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription?
PasswordResetToken PasswordResetToken[]
VerificationToken VerificationToken[]
id Int @id @default(autoincrement())
name String?
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now())
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription?
PasswordResetToken PasswordResetToken[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
VerificationToken VerificationToken[]
@@index([email])
}

View File

@ -19,5 +19,6 @@
"@trpc/server": "^10.36.0",
"superjson": "^1.13.1",
"zod": "^3.22.4"
}
},
"devDependencies": {}
}

View File

@ -1,10 +1,12 @@
import { TRPCError } from '@trpc/server';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { procedure, router } from '../trpc';
import { ZSignUpMutationSchema } from './schema';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
export const authRouter = router({
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
@ -30,4 +32,23 @@ export const authRouter = router({
});
}
}),
verifyPassword: authenticatedProcedure
.input(ZVerifyPasswordMutationSchema)
.mutation(({ ctx, input }) => {
const user = ctx.user;
const { password } = input;
if (!user.password) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: ErrorCode.INCORRECT_PASSWORD,
});
}
const valid = compareSync(password, user.password);
return valid;
}),
});

View File

@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });

View File

@ -5,6 +5,7 @@ import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
import { shareLinkRouter } from './share-link-router/router';
import { procedure, router } from './trpc';
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
export const appRouter = router({
health: procedure.query(() => {
@ -16,6 +17,7 @@ export const appRouter = router({
field: fieldRouter,
admin: adminRouter,
shareLink: shareLinkRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,105 @@
import { TRPCError } from '@trpc/server';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { authenticatedProcedure, router } from '../trpc';
import {
ZDisableTwoFactorAuthenticationMutationSchema,
ZEnableTwoFactorAuthenticationMutationSchema,
ZSetupTwoFactorAuthenticationMutationSchema,
ZViewRecoveryCodesMutationSchema,
} from './schema';
export const twoFactorAuthenticationRouter = router({
setup: authenticatedProcedure
.input(ZSetupTwoFactorAuthenticationMutationSchema)
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
const { password } = input;
return await setupTwoFactorAuthentication({ user, password });
}),
enable: authenticatedProcedure
.input(ZEnableTwoFactorAuthenticationMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const user = ctx.user;
const { code } = input;
return await enableTwoFactorAuthentication({ user, code });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to enable two-factor authentication. Please try again later.',
});
}
}),
disable: authenticatedProcedure
.input(ZDisableTwoFactorAuthenticationMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const user = ctx.user;
const { password, backupCode } = input;
return await disableTwoFactorAuthentication({ user, password, backupCode });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to disable two-factor authentication. Please try again later.',
});
}
}),
viewRecoveryCodes: authenticatedProcedure
.input(ZViewRecoveryCodesMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const user = ctx.user;
const { password } = input;
if (!user.twoFactorEnabled) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED,
});
}
if (!user.password || !compareSync(password, user.password)) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: ErrorCode.INCORRECT_PASSWORD,
});
}
const recoveryCodes = await getBackupCodes({ user });
return { recoveryCodes };
} catch (err) {
console.error(err);
if (err instanceof TRPCError) {
throw err;
}
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to view your recovery codes. Please try again later.',
});
}
}),
});

View File

@ -0,0 +1,32 @@
import { z } from 'zod';
export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(1),
});
export type TSetupTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZSetupTwoFactorAuthenticationMutationSchema
>;
export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({
code: z.string().min(6).max(6),
});
export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZEnableTwoFactorAuthenticationMutationSchema
>;
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(6).max(72),
backupCode: z.string().trim(),
});
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZDisableTwoFactorAuthenticationMutationSchema
>;
export const ZViewRecoveryCodesMutationSchema = z.object({
password: z.string().min(6).max(72),
});
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;

View File

@ -7,6 +7,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;

View File

@ -1,6 +1,9 @@
import * as React from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
@ -25,4 +28,38 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
Input.displayName = 'Input';
export { Input };
const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
className={cn('pr-10', className)}
ref={ref}
{...props}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff aria-hidden className="text-muted-foreground h-5 w-5" />
) : (
<Eye aria-hidden className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
);
},
);
PasswordInput.displayName = 'Input';
export { Input, PasswordInput };