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 Mythie
parent 83153cee32
commit 792158c2cb
42 changed files with 2056 additions and 92 deletions

View File

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

View File

@ -10,6 +10,8 @@ import GoogleProvider 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';
@ -25,13 +27,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);
@ -47,6 +55,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

@ -25,6 +25,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",
@ -34,6 +36,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);
};