Merge branch 'main' into feat/commodifying-signing

This commit is contained in:
Timur Ercan
2024-01-25 15:26:25 +01:00
committed by GitHub
13 changed files with 204 additions and 6 deletions

View File

@@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY=""
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
# [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""

View File

@@ -1,6 +1,8 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
@@ -17,7 +19,7 @@ export default function SignUpPage() {
signing is within your grasp.
</p>
<SignUpForm className="mt-4" />
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}

View File

@@ -3,6 +3,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
@@ -23,6 +24,8 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
const SIGN_UP_REDIRECT_PATH = '/documents';
export const ZSignUpFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
@@ -37,9 +40,10 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
export type SignUpFormProps = {
className?: string;
isGoogleSSOEnabled?: boolean;
};
export const SignUpForm = ({ className }: SignUpFormProps) => {
export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
@@ -64,7 +68,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
await signIn('credentials', {
email,
password,
callbackUrl: '/',
callbackUrl: SIGN_UP_REDIRECT_PATH,
});
analytics.capture('App: User Sign Up', {
@@ -89,6 +93,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
@@ -166,6 +183,28 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</Button>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
</form>
</Form>
);

View File

@@ -1 +1,23 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
}
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error(
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
);
}
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
console.warn('*********************************************************************');
console.warn('*');
console.warn('*');
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
console.warn('*');
console.warn('*');
console.warn('*********************************************************************');
}

View File

@@ -9,6 +9,7 @@ import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
@@ -93,7 +94,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}),
],
callbacks: {
async jwt({ token, user }) {
async jwt({ token, user, trigger, account }) {
const merged = {
...token,
...user,
@@ -138,6 +139,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
}
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
merged.emailVerified = user?.emailVerified
? new Date(user.emailVerified).toISOString()
: new Date().toISOString();
await prisma.user.update({
where: {
id: Number(merged.id),
},
data: {
emailVerified: merged.emailVerified,
identityProvider: IdentityProvider.GOOGLE,
},
});
}
return {
id: merged.id,
name: merged.name,

View File

@@ -0,0 +1,33 @@
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
/**
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
*
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
* @returns The decrypted value, or `null` if the data is invalid or expired.
*/
export const decryptSecondaryData = (encryptedData: string): string | null => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const decryptedBufferValue = symmetricDecrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: encryptedData,
});
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
if (!result.success) {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
};

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
export const ZEncryptedDataSchema = z.object({
data: z.string(),
expiresAt: z.number().optional(),
});
export type EncryptDataOptions = {
data: string;
/**
* When the data should no longer be allowed to be decrypted.
*
* Leave this empty to never expire the data.
*/
expiresAt?: number;
};
/**
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
*
* @returns The encrypted data.
*/
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
data,
expiresAt,
};
return symmetricEncrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: JSON.stringify(dataToEncrypt),
});
};

View File

@@ -42,6 +42,11 @@ export const getStats = async ({ user }: GetStatsInput) => {
_all: true,
},
where: {
User: {
email: {
not: user.email,
},
},
OR: [
{
status: ExtendedDocumentStatus.PENDING,

View File

@@ -0,0 +1,17 @@
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { procedure, router } from '../trpc';
import { ZEncryptSecondaryDataMutationSchema } from './schema';
export const cryptoRouter = router({
encryptSecondaryData: procedure
.input(ZEncryptSecondaryDataMutationSchema)
.mutation(({ input }) => {
try {
return encryptSecondaryData(input);
} catch {
// Never leak errors for crypto.
throw new Error('Failed to encrypt data');
}
}),
});

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const ZEncryptSecondaryDataMutationSchema = z.object({
data: z.string(),
expiresAt: z.number().optional(),
});
export const ZDecryptDataMutationSchema = z.object({
data: z.string(),
});
export type TEncryptSecondaryDataMutationSchema = z.infer<
typeof ZEncryptSecondaryDataMutationSchema
>;
export type TDecryptDataMutationSchema = z.infer<typeof ZDecryptDataMutationSchema>;

View File

@@ -1,5 +1,6 @@
import { adminRouter } from './admin-router/router';
import { authRouter } from './auth-router/router';
import { cryptoRouter } from './crypto/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
@@ -12,6 +13,7 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route
export const appRouter = router({
auth: authRouter,
crypto: cryptoRouter,
profile: profileRouter,
document: documentRouter,
field: fieldRouter,

View File

@@ -8,6 +8,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;

View File

@@ -34,6 +34,7 @@
"globalEnv": [
"APP_VERSION",
"NEXT_PRIVATE_ENCRYPTION_KEY",
"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_PROJECT",