diff --git a/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx new file mode 100644 index 000000000..79767f58f --- /dev/null +++ b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx @@ -0,0 +1,158 @@ +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { Trans } from '@lingui/react/macro'; +import type React from 'react'; +import { useState } from 'react'; +import { type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; + +/** + * Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`. + */ +export const ZTwoFactorCodeFieldSchema = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), +}); + +export type TTwoFactorCodeFieldSchema = z.infer; + +export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode; + +type TwoFactorMethod = 'totp' | 'backup'; + +export type TwoFactorCodeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + isSubmitting?: boolean; + submitLabel: React.ReactNode; + + /** + * Called when the user submits the code. Typically the parent form's submit handler. + */ + onSubmit: () => void; +}; + +/** + * Collects a TOTP or backup code on top of an existing form, mirroring the + * sign in and disable 2FA dialogs. + * + * Must be rendered inside a `
` whose values include `totpCode` and `backupCode`. + */ +export const TwoFactorCodeDialog = ({ + open, + onOpenChange, + isSubmitting, + submitLabel, + onSubmit, +}: TwoFactorCodeDialogProps) => { + const form = useFormContext(); + + const [method, setMethod] = useState('totp'); + + const totpCodeName = 'totpCode' as Path; + const backupCodeName = 'backupCode' as Path; + + const onToggleMethod = () => { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + + setMethod((current) => (current === 'totp' ? 'backup' : 'totp')); + }; + + const handleOpenChange = (value: boolean) => { + if (isSubmitting) { + return; + } + + if (!value) { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + setMethod('totp'); + } + + onOpenChange(value); + }; + + return ( + + + + + Two-Factor Authentication + + + + {method === 'totp' ? ( + Enter the code from your authenticator app to continue. + ) : ( + Enter one of your backup codes to continue. + )} + + + +
+ {method === 'totp' && ( + ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {method === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request-button.tsx b/apps/remix/app/components/forms/password-setup-request-button.tsx new file mode 100644 index 000000000..bac09f11d --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request-button.tsx @@ -0,0 +1,53 @@ +import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { CheckIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +/** + * Compact "send me a setup link" button that reports via toast, for settings + * cards where the surrounding layout provides the explanation. + */ +export const PasswordSetupRequestButton = () => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { user } = useSession(); + + const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({ + onSuccess: () => { + toast({ + title: _(msg`Check your email`), + description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`), + duration: 5000, + }); + }, + onError: (errorCode) => { + toast({ + title: _(msg`An error occurred`), + description: match(errorCode) + .with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`)) + .otherwise(() => _(msg`We were unable to send the email. Please try again later.`)), + variant: 'destructive', + }); + }, + }); + + if (isSuccess) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request.tsx b/apps/remix/app/components/forms/password-setup-request.tsx new file mode 100644 index 000000000..35cfcaf80 --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request.tsx @@ -0,0 +1,61 @@ +import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { match } from 'ts-pattern'; + +export type PasswordSetupRequestProps = { + className?: string; +}; + +/** + * Inline "send me a setup link" control with its own sent/error states, for + * contexts like dialogs where a toast would be missed. + */ +export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => { + const { _ } = useLingui(); + const { user } = useSession(); + + const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest(); + + if (isSuccess) { + return ( + + + Check your email + + + + We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue. + + + + ); + } + + return ( +
+ {errorCode && ( + + + An error occurred + + + {match(errorCode) + .with('SIGNIN_DISABLED', () => + _(msg`Password sign in is disabled for this instance. Please contact support.`), + ) + .otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))} + + + )} + + +
+ ); +}; diff --git a/apps/remix/app/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx index d04f84131..65ba8c8c0 100644 --- a/apps/remix/app/components/forms/password.tsx +++ b/apps/remix/app/components/forms/password.tsx @@ -1,6 +1,6 @@ import { authClient } from '@documenso/auth/client'; import type { SessionUser } from '@documenso/auth/server/lib/session/session'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; -import { z } from 'zod'; +import type { z } from 'zod'; -export const ZPasswordFormSchema = z - .object({ - currentPassword: ZCurrentPasswordSchema, - password: ZPasswordSchema, - repeatedPassword: ZPasswordSchema, - }) - .refine((data) => data.password === data.repeatedPassword, { - message: 'Passwords do not match', - path: ['repeatedPassword'], - }); +import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog'; + +export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({ + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, +}).refine((data) => data.password === data.repeatedPassword, { + message: 'Passwords do not match', + path: ['repeatedPassword'], +}); export type TPasswordFormSchema = z.infer; @@ -33,29 +34,51 @@ export type PasswordFormProps = { user: SessionUser; }; -export const PasswordForm = ({ className }: PasswordFormProps) => { +export const PasswordForm = ({ className, user }: PasswordFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false); + const form = useForm({ values: { currentPassword: '', password: '', repeatedPassword: '', + totpCode: '', + backupCode: '', }, resolver: zodResolver(ZPasswordFormSchema), }); const isSubmitting = form.formState.isSubmitting; - const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { + const onFormSubmit = async (values: TPasswordFormSchema) => { + const { currentPassword, password, totpCode, backupCode } = values; + + // Collect the 2FA code in a dialog once the password fields are valid. + if (user.twoFactorEnabled && !hasTwoFactorCode(values)) { + if (isTwoFactorDialogOpen) { + const message = _(msg`A code is required`); + + form.setError('totpCode', { message }); + form.setError('backupCode', { message }); + } + + setIsTwoFactorDialogOpen(true); + return; + } + try { await authClient.emailPassword.updatePassword({ currentPassword, password, + totpCode: totpCode || undefined, + backupCode: backupCode || undefined, }); form.reset(); + setIsTwoFactorDialogOpen(false); toast({ title: _(msg`Password updated`), @@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { const error = AppError.parseError(err); const errorMessage = match(error.code) - .with('NO_PASSWORD', () => msg`User has no password.`) - .with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`) - .with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`) + .with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`) + .with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`) + .with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`) + .with( + AppErrorCode.INCORRECT_TWO_FACTOR_CODE, + AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS, + () => msg`The two factor code you provided is invalid. Please try again.`, + ) .otherwise( () => msg`We encountered an unknown error while attempting to update your password. Please try again later.`, ); @@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { return ( - + {/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */} +
{ + + + open={isTwoFactorDialogOpen} + onOpenChange={setIsTwoFactorDialogOpen} + isSubmitting={isSubmitting} + submitLabel={Update password} + onSubmit={form.handleSubmit(onFormSubmit)} + /> ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx index 2d1b51747..1d2806fda 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -1,5 +1,7 @@ import { AppError } from '@documenso/lib/errors/app-error'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { UserAuthMethod } from '@documenso/lib/types/user-auth-method'; +import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { Input } from '@documenso/ui/primitives/input'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; +import { Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password'; export type DocumentSigningAuthPasswordProps = { open: boolean; @@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({ }: DocumentSigningAuthPasswordProps) => { const { t } = useLingui(); - const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = - useRequiredDocumentSigningAuthContext(); + const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + + // Fetched on demand since this is only needed once the user opts for password auth. + const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, { + enabled: !!user, + }); const form = useForm({ resolver: zodResolver(ZPasswordAuthFormSchema), @@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({ const [formErrorCode, setFormErrorCode] = useState(null); + // If the query fails we fall through to the regular password form rather than blocking. + const isPasswordSetupRequired = + !!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD); + const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => { try { setIsCurrentlyAuthenticating(true); @@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({ const error = AppError.parseError(err); setFormErrorCode(error.code); - - // Todo: Alert. } }; @@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); + if (user && isAuthMethodsPending) { + return ( +
+ +
+ ); + } + + if (isPasswordSetupRequired) { + return ; + } + return (
- + {/* method="post" so a pre-hydration native submit can't leak the password into the URL. */} +
{formErrorCode && ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx new file mode 100644 index 000000000..b827c1cbf --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx @@ -0,0 +1,63 @@ +import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; + +import { PasswordSetupRequest } from '~/components/forms/password-setup-request'; + +export type DocumentSigningAuthSetPasswordProps = { + onOpenChange: (value: boolean) => void; +}; + +/** + * Shown in place of the password reauth form when the signed in user has no + * password (e.g. they signed up via OAuth or a passkey). + * + * Password based action auth is meant to prove more than possession of a session, + * so rather than letting the session set a password inline we send the user the + * verified reset link and ask them to come back. + */ +export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => { + const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email'); + + return ( +
+ {isEmailPasswordSigninEnabled ? ( + <> + + + No password set + + + + Signing this field requires a password, but your account does not have one. We can email you a link to + set one. Once done, sign in again and return to this document to continue. + + + + + + + ) : ( + + + Password authentication unavailable + + + + Your account does not have a password and password sign in is disabled for this instance. Please contact + the document sender to use a different authentication method. + + + + )} + + + + +
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx index d3a84c848..95844c027 100644 --- a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx +++ b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx @@ -1,6 +1,8 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { prisma } from '@documenso/prisma'; +import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth'; +import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods'; +import { UserAuthMethod } from '@documenso/lib/types/user-auth-method'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { msg } from '@lingui/core/macro'; @@ -12,6 +14,7 @@ import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-au import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog'; import { PasswordForm } from '~/components/forms/password'; +import { PasswordSetupRequestButton } from '~/components/forms/password-setup-request-button'; import { SettingsHeader } from '~/components/general/settings-header'; import { appMetaTags } from '~/utils/meta'; @@ -24,33 +27,10 @@ export function meta() { export async function loader({ request }: Route.LoaderArgs) { const { user } = await getSession(request); - // Todo: Use providers instead after RR7 migration. - // const accounts = await prisma.account.findMany({ - // where: { - // userId: user.id, - // }, - // select: { - // provider: true, - // }, - // }); - - // const providers = accounts.map((account) => account.provider); - // let hasEmailPasswordAccount = providers.includes('DOCUMENSO'); - - const hasEmailPasswordAccount: boolean = await prisma.user - .count({ - where: { - id: user.id, - password: { - not: null, - }, - }, - }) - .then((value) => value > 0); + const authMethods = await getUserAuthMethods({ userId: user.id }); return { - // providers, - hasEmailPasswordAccount, + hasEmailPasswordAccount: authMethods.includes(UserAuthMethod.PASSWORD), }; } @@ -60,14 +40,36 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) { const { _ } = useLingui(); const { user } = useSession(); + const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email'); + return (
+ {hasEmailPasswordAccount && } + {!hasEmailPasswordAccount && isEmailPasswordSigninEnabled && ( + +
+ + Set a password + + + + + Your account has no password. Add one to sign in with your email and to sign documents that require it. + We'll email you a link. + + +
+ + +
+ )} +
diff --git a/packages/app-tests/e2e/document-auth/action-auth-password.spec.ts b/packages/app-tests/e2e/document-auth/action-auth-password.spec.ts new file mode 100644 index 000000000..bb49fb91f --- /dev/null +++ b/packages/app-tests/e2e/document-auth/action-auth-password.spec.ts @@ -0,0 +1,193 @@ +import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { FieldType } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { waitForHydration } from '../fixtures/hydration'; +import { signSignaturePad } from '../fixtures/signature'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +const SEEDED_PASSWORD = 'password'; +const NEW_PASSWORD = 'Test123!'; + +/** + * Seed a document requiring PASSWORD action auth for a recipient with an account, + * and sign the recipient in on the signing page. + * + * Action auth is gated behind the cfr21 claim flag at write time only, so seeding + * the auth options directly bypasses the gate the same way action-auth.spec.ts does. + */ +const seedPasswordActionAuthDocument = async () => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner, + teamId: team.id, + recipients: [recipient], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: [], + globalActionAuth: ['PASSWORD'], + }), + }, + fields: [FieldType.SIGNATURE], + }); + + const { token, fields } = recipients[0]; + + const signatureField = fields.find((field) => field.type === FieldType.SIGNATURE); + + if (!signatureField) { + throw new Error('Expected a signature field to be seeded'); + } + + return { + recipient, + signUrl: `/sign/${token}`, + signatureField, + }; +}; + +test('[DOCUMENT_AUTH]: passwordless user is sent a setup link and can sign after setting a password', async ({ + page, +}) => { + const { recipient, signUrl, signatureField } = await seedPasswordActionAuthDocument(); + + await apiSignin({ + page, + email: recipient.email, + password: SEEDED_PASSWORD, + redirectPath: signUrl, + }); + + // Simulate an OAuth / passkey only account by removing the password after sign in. + await prisma.user.update({ + where: { id: recipient.id }, + data: { password: null }, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + await page.locator(`#field-${signatureField.id}`).getByRole('button').click(); + + await expect(page.getByText('Reauthentication is required to sign this field')).toBeVisible(); + await expect(page.getByText('No password set')).toBeVisible(); + + // A bare session must not be able to set a password inline; it gets emailed a link instead. + await expect(page.getByLabel('New password')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Send setup link' }).click(); + await expect(page.getByText('Check your email')).toBeVisible(); + + const resetToken = await prisma.passwordResetToken.findFirstOrThrow({ + where: { userId: recipient.id }, + }); + + // Complete the emailed flow, which also invalidates all sessions. + await page.goto(`/reset-password/${resetToken.token}`); + + // Filling controlled inputs before hydration gets reset by React. + await waitForHydration(page, 'input[name="password"]'); + + await page.getByLabel('Password', { exact: true }).fill(NEW_PASSWORD); + await page.getByLabel('Repeat Password').fill(NEW_PASSWORD); + await page.getByRole('button', { name: 'Reset Password' }).click(); + await expect(page.locator('body')).toContainText('Your password has been updated successfully.'); + + // Come back with the new password and the normal reauth form should now work. + await apiSignin({ + page, + email: recipient.email, + password: NEW_PASSWORD, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + await page.locator(`#field-${signatureField.id}`).getByRole('button').click(); + + const dialog = page.getByRole('dialog'); + + await expect(dialog.getByText('No password set')).not.toBeVisible(); + + await dialog.getByLabel('Password').fill(NEW_PASSWORD); + await dialog.getByRole('button', { name: 'Sign' }).click(); + + await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute('data-inserted', 'true'); +}); + +test('[DOCUMENT_AUTH]: user with a password sees the normal password reauth form', async ({ page }) => { + const { recipient, signUrl, signatureField } = await seedPasswordActionAuthDocument(); + + await apiSignin({ + page, + email: recipient.email, + password: SEEDED_PASSWORD, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + await page.locator(`#field-${signatureField.id}`).getByRole('button').click(); + + await expect(page.getByText('Reauthentication is required to sign this field')).toBeVisible(); + await expect(page.getByText('No password set')).not.toBeVisible(); + + const dialog = page.getByRole('dialog'); + + // Wrong password is rejected. + await dialog.getByLabel('Password').fill('wrong-password'); + await dialog.getByRole('button', { name: 'Sign' }).click(); + await expect(dialog.getByText('Unauthorized')).toBeVisible(); + + // Correct password signs the field. + await dialog.getByLabel('Password').fill(SEEDED_PASSWORD); + await dialog.getByRole('button', { name: 'Sign' }).click(); + + await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute('data-inserted', 'true'); +}); + +test('[DOCUMENT_AUTH]: passwordless user can request a setup link from security settings', async ({ page }) => { + const { user } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + password: SEEDED_PASSWORD, + redirectPath: '/settings/profile', + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { password: null }, + }); + + await page.goto('/settings/security'); + + await expect(page.getByRole('heading', { name: 'Set a password' })).toBeVisible(); + await expect(page.getByLabel('Current password')).not.toBeVisible(); + await expect(page.getByLabel('New password')).not.toBeVisible(); + + // Clicking before hydration is a no-op, so retry until the sent state appears. + await expect(async () => { + await page.getByRole('button', { name: 'Send setup link' }).click(); + await expect(page.getByRole('button', { name: 'Link sent' })).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + + const resetToken = await prisma.passwordResetToken.findFirst({ + where: { userId: user.id }, + }); + + expect(resetToken).not.toBeNull(); +}); diff --git a/packages/app-tests/e2e/fixtures/hydration.ts b/packages/app-tests/e2e/fixtures/hydration.ts new file mode 100644 index 000000000..97ef8feb8 --- /dev/null +++ b/packages/app-tests/e2e/fixtures/hydration.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test'; + +/** + * Wait for React to hydrate the element matching the given selector. + * + * Filling controlled inputs before hydration is racy since React resets them + * to their default values once it takes over the DOM. React attaches internal + * fiber keys to DOM nodes during hydration, so their presence is a reliable + * signal that the element is interactive. + */ +export const waitForHydration = async (page: Page, selector: string, timeout = 15_000) => { + await page.waitForSelector(selector, { timeout }); + + await page.waitForFunction( + (sel) => { + const element = document.querySelector(sel); + + if (!element) { + return false; + } + + return Object.keys(element).some((key) => key.startsWith('__reactFiber')); + }, + selector, + { timeout }, + ); +}; diff --git a/packages/app-tests/e2e/user/password-two-factor.spec.ts b/packages/app-tests/e2e/user/password-two-factor.spec.ts new file mode 100644 index 000000000..8c19adc8a --- /dev/null +++ b/packages/app-tests/e2e/user/password-two-factor.spec.ts @@ -0,0 +1,105 @@ +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; +import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { base32 } from '@scure/base'; +import { generateHOTP } from 'oslo/otp'; + +import { apiSignin } from '../fixtures/authentication'; +import { waitForHydration } from '../fixtures/hydration'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +/** + * Derive the current TOTP for a user the same way `verifyTwoFactorAuthenticationToken` does. + */ +const getCurrentTotpCode = async (userId: number) => { + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + + if (!DOCUMENSO_ENCRYPTION_KEY || !user.twoFactorSecret) { + throw new Error('Expected encryption key and 2FA secret'); + } + + const secret = Buffer.from(symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: user.twoFactorSecret })).toString( + 'utf-8', + ); + + return await generateHOTP(base32.decode(secret), Math.floor(Date.now() / 30_000)); +}; + +test('[USER] password update requires a 2FA code when 2FA is enabled', async ({ page }) => { + const oldPassword = 'password'; + const newPassword = 'Test123!'; + + const { user } = await seedUser({ password: oldPassword }); + + // Sign in before enabling 2FA since apiSignin does not send a code. + await apiSignin({ page, email: user.email, password: oldPassword, redirectPath: '/settings/profile' }); + + await setupTwoFactorAuthentication({ user }); + + const userWithSecret = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + + await enableTwoFactorAuthentication({ user: userWithSecret, code: await getCurrentTotpCode(user.id) }); + + await page.goto('/settings/security'); + await waitForHydration(page, 'input[name="currentPassword"]'); + + await page.getByLabel('Current password').fill(oldPassword); + await page.getByLabel('New password').fill(newPassword); + await page.getByLabel('Repeat password').fill(newPassword); + await page.getByRole('button', { name: 'Update password' }).click(); + + const dialog = page.getByRole('dialog'); + + await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible(); + + // Empty code is caught client-side. + await dialog.getByRole('button', { name: 'Update password' }).click(); + await expect(dialog.getByText('A code is required')).toBeVisible(); + + const codeInput = dialog.locator('input').first(); + + // Wrong code is rejected server-side and the dialog stays open. + await codeInput.fill('000000'); + await dialog.getByRole('button', { name: 'Update password' }).click(); + await expect(page.locator('body')).toContainText('The two factor code you provided is invalid'); + await expect(dialog).toBeVisible(); + + // Correct code updates the password. + await codeInput.fill(''); + await codeInput.fill(await getCurrentTotpCode(user.id)); + await dialog.getByRole('button', { name: 'Update password' }).click(); + await expect(page.locator('body')).toContainText('Password updated'); + await expect(dialog).not.toBeVisible(); + + const updatedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + + expect(updatedUser.password).not.toBe(userWithSecret.password); +}); + +test('[USER] password update API rejects a missing 2FA code when 2FA is enabled', async ({ page }) => { + const { user } = await seedUser(); + + await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' }); + + await setupTwoFactorAuthentication({ user }); + + const userWithSecret = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + + await enableTwoFactorAuthentication({ user: userWithSecret, code: await getCurrentTotpCode(user.id) }); + + const response = await page.request.post('/api/auth/email-password/update-password', { + data: { currentPassword: 'password', password: 'Test123!' }, + }); + + expect(response.status()).toBe(400); + expect(await response.json()).toMatchObject({ code: 'TWO_FACTOR_MISSING_CREDENTIALS' }); + + const unchangedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + + expect(unchangedUser.password).toBe(userWithSecret.password); +}); diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 8d890b81a..50df2a137 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -21,6 +21,7 @@ import { resendVerifyEmailRateLimit, resetPasswordRateLimit, signupRateLimit, + updatePasswordRateLimit, verifyEmailRateLimit, } from '@documenso/lib/server-only/rate-limit/rate-limits'; import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains'; @@ -248,7 +249,7 @@ export const emailPasswordRoute = new Hono() * Update password endpoint. */ .post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => { - const { password, currentPassword } = c.req.valid('json'); + const { password, currentPassword, totpCode, backupCode } = c.req.valid('json'); const requestMetadata = c.get('requestMetadata'); if (!isSigninEnabledForProvider('email')) { @@ -259,10 +260,25 @@ export const emailPasswordRoute = new Hono() const { session, user } = await getSession(c); + const updateLimitResult = await updatePasswordRateLimit.check({ + ip: requestMetadata.ipAddress ?? 'unknown', + identifier: String(user.id), + }); + + const updateLimited = rateLimitResponse(c, updateLimitResult); + + if (updateLimited) { + throw new HTTPException(429, { + res: updateLimited, + }); + } + await updatePassword({ userId: user.id, password, currentPassword, + totpCode, + backupCode, requestMetadata, }); diff --git a/packages/auth/server/types/email-password.ts b/packages/auth/server/types/email-password.ts index 5303d326c..dcdeeac06 100644 --- a/packages/auth/server/types/email-password.ts +++ b/packages/auth/server/types/email-password.ts @@ -70,6 +70,8 @@ export type TResendVerifyEmailSchema = z.infer; export const ZUpdatePasswordSchema = z.object({ currentPassword: ZCurrentPasswordSchema, password: ZPasswordSchema, + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), }); export type TUpdatePasswordSchema = z.infer; diff --git a/packages/email/templates/reset-password.tsx b/packages/email/templates/reset-password.tsx index 19c5979a3..ce7669023 100644 --- a/packages/email/templates/reset-password.tsx +++ b/packages/email/templates/reset-password.tsx @@ -1,3 +1,4 @@ +import type { TPasswordChangeSource } from '@documenso/lib/jobs/definitions/emails/send-password-reset-success-email'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; @@ -8,16 +9,19 @@ import { TemplateFooter } from '../template-components/template-footer'; import type { TemplateResetPasswordProps } from '../template-components/template-reset-password'; import { TemplateResetPassword } from '../template-components/template-reset-password'; -export type ResetPasswordTemplateProps = Partial; +export type ResetPasswordTemplateProps = Partial & { + source?: TPasswordChangeSource; +}; export const ResetPasswordTemplate = ({ userName = 'Lucas Smith', userEmail = 'lucas@documenso.com', assetBaseUrl = 'http://localhost:3002', + source = 'RESET', }: ResetPasswordTemplateProps) => { const { _ } = useLingui(); - const previewText = msg`Password Reset Successful`; + const previewText = source === 'RESET' ? msg`Password Reset Successful` : msg`Your password was changed`; return ( @@ -46,18 +50,37 @@ export const ResetPasswordTemplate = ({ - - We've changed your password as you asked. You can now sign in with your new password. - - - - Didn't request a password change? We are here to help you secure your account, just{' '} - - contact us - - . - - + {source === 'RESET' ? ( + <> + + We've changed your password as you asked. You can now sign in with your new password. + + + + Didn't request a password change? We are here to help you secure your account, just{' '} + + contact us + + . + + + + ) : ( + <> + + Your password was just changed from your account security settings. + + + + If this was you, no action is needed. If it wasn't, reset your password immediately and{' '} + + contact us + + . + + + + )} diff --git a/packages/lib/client-only/hooks/use-password-setup-request.ts b/packages/lib/client-only/hooks/use-password-setup-request.ts new file mode 100644 index 000000000..fd0e0ce47 --- /dev/null +++ b/packages/lib/client-only/hooks/use-password-setup-request.ts @@ -0,0 +1,31 @@ +import { authClient } from '@documenso/auth/client'; +import { useMutation } from '@tanstack/react-query'; + +import { AppError } from '../../errors/app-error'; +import { useSession } from '../providers/session'; + +export type UsePasswordSetupRequestOptions = { + onSuccess?: () => void; + onError?: (errorCode: string) => void; +}; + +/** + * Sends the signed in user the standard password reset email so they can set a + * password via a verified link, rather than letting a bare session mint one. + */ +export const usePasswordSetupRequest = ({ onSuccess, onError }: UsePasswordSetupRequestOptions = {}) => { + const { user } = useSession(); + + const { mutate, isPending, isSuccess, error } = useMutation({ + mutationFn: async () => authClient.emailPassword.forgotPassword({ email: user.email }), + onSuccess, + onError: (err) => onError?.(AppError.parseError(err).code), + }); + + return { + requestSetupLink: () => mutate(), + isPending, + isSuccess, + errorCode: error ? AppError.parseError(error).code : null, + }; +}; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 79d5235b2..108998eb2 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -23,6 +23,13 @@ export enum AppErrorCode { SCHEMA_FAILED = 'SCHEMA_FAILED', TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', TWO_FACTOR_AUTH_FAILED = 'TWO_FACTOR_AUTH_FAILED', + 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', + NO_PASSWORD = 'NO_PASSWORD', + INCORRECT_PASSWORD = 'INCORRECT_PASSWORD', + SAME_PASSWORD = 'SAME_PASSWORD', WEBHOOK_INVALID_REQUEST = 'WEBHOOK_INVALID_REQUEST', ENVELOPE_DRAFT = 'ENVELOPE_DRAFT', ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED', diff --git a/packages/lib/jobs/definitions/emails/send-password-reset-success-email.handler.ts b/packages/lib/jobs/definitions/emails/send-password-reset-success-email.handler.ts index 45b841c4c..3339128f0 100644 --- a/packages/lib/jobs/definitions/emails/send-password-reset-success-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-password-reset-success-email.handler.ts @@ -4,5 +4,6 @@ import type { TSendPasswordResetSuccessEmailJobDefinition } from './send-passwor export const run = async ({ payload }: { payload: TSendPasswordResetSuccessEmailJobDefinition }) => { await sendResetPassword({ userId: payload.userId, + source: payload.source ?? 'RESET', }); }; diff --git a/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts b/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts index d73246bb6..34b57a273 100644 --- a/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts +++ b/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts @@ -4,8 +4,20 @@ import type { JobDefinition } from '../../client/_internal/job'; const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID = 'send.password.reset.success.email'; +/** + * How the password came to be changed, so the email can say so. + * + * - RESET: via the emailed reset link, unauthenticated. + * - UPDATE: via account settings, while signed in. + */ +export const ZPasswordChangeSourceSchema = z.enum(['RESET', 'UPDATE']); + +export type TPasswordChangeSource = z.infer; + const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ userId: z.number(), + // Optional so jobs queued before this field existed still run; treated as RESET. + source: ZPasswordChangeSourceSchema.optional(), }); export type TSendPasswordResetSuccessEmailJobDefinition = z.infer< diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts index ebbeab5a7..d670eacff 100644 --- a/packages/lib/server-only/2fa/disable-2fa.ts +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -32,7 +32,7 @@ export const disableTwoFactorAuthentication = async ({ } if (!isValid) { - throw new AppError('INCORRECT_TWO_FACTOR_CODE'); + throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE); } await prisma.$transaction(async (tx) => { diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts index 4d6a86f1f..04da04af1 100644 --- a/packages/lib/server-only/2fa/enable-2fa.ts +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -1,7 +1,7 @@ import { prisma } from '@documenso/prisma'; import { type User, UserSecurityAuditLogType } from '@prisma/client'; -import { AppError } from '../../errors/app-error'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getBackupCodes } from './get-backup-code'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; @@ -22,13 +22,13 @@ export const enableTwoFactorAuthentication = async ({ } if (!user.twoFactorSecret) { - throw new AppError('TWO_FACTOR_SETUP_REQUIRED'); + throw new AppError(AppErrorCode.TWO_FACTOR_SETUP_REQUIRED); } const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code }); if (!isValidToken) { - throw new AppError('INCORRECT_TWO_FACTOR_CODE'); + throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE); } let recoveryCodes: string[] = []; diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts index 64a99adcc..e04623def 100644 --- a/packages/lib/server-only/2fa/validate-2fa.ts +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -1,6 +1,6 @@ import type { User } from '@prisma/client'; -import { AppError } from '../../errors/app-error'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; import { verifyBackupCode } from './verify-backup-code'; @@ -16,11 +16,11 @@ export const validateTwoFactorAuthentication = async ({ user, }: ValidateTwoFactorAuthenticationOptions) => { if (!user.twoFactorEnabled) { - throw new AppError('TWO_FACTOR_SETUP_REQUIRED'); + throw new AppError(AppErrorCode.TWO_FACTOR_SETUP_REQUIRED); } if (!user.twoFactorSecret) { - throw new AppError('TWO_FACTOR_MISSING_SECRET'); + throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_SECRET); } if (totpCode) { @@ -31,5 +31,5 @@ export const validateTwoFactorAuthentication = async ({ return verifyBackupCode({ user, backupCode }); } - throw new AppError('TWO_FACTOR_MISSING_CREDENTIALS'); + throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS); }; diff --git a/packages/lib/server-only/2fa/view-backup-codes.ts b/packages/lib/server-only/2fa/view-backup-codes.ts index 9ed75bde4..b693f7282 100644 --- a/packages/lib/server-only/2fa/view-backup-codes.ts +++ b/packages/lib/server-only/2fa/view-backup-codes.ts @@ -1,6 +1,6 @@ import type { User } from '@prisma/client'; -import { AppError } from '../../errors/app-error'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { getBackupCodes } from './get-backup-code'; import { validateTwoFactorAuthentication } from './validate-2fa'; @@ -17,7 +17,7 @@ export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) = } if (!isValid) { - throw new AppError('INCORRECT_TWO_FACTOR_CODE'); + throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE); } const backupCodes = getBackupCodes({ user }); diff --git a/packages/lib/server-only/auth/send-reset-password.ts b/packages/lib/server-only/auth/send-reset-password.ts index 3c4957d31..e87537f3f 100644 --- a/packages/lib/server-only/auth/send-reset-password.ts +++ b/packages/lib/server-only/auth/send-reset-password.ts @@ -1,17 +1,22 @@ import { mailer } from '@documenso/email/mailer'; import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password'; import { prisma } from '@documenso/prisma'; +import { msg } from '@lingui/core/macro'; import { createElement } from 'react'; +import { match } from 'ts-pattern'; +import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import type { TPasswordChangeSource } from '../../jobs/definitions/emails/send-password-reset-success-email'; import { env } from '../../utils/env'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; export interface SendResetPasswordOptions { userId: number; + source: TPasswordChangeSource; } -export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => { +export const sendResetPassword = async ({ userId, source }: SendResetPasswordOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { id: userId, @@ -24,6 +29,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => assetBaseUrl, userEmail: user.email, userName: user.name || '', + source, }); const [html, text] = await Promise.all([ @@ -31,6 +37,13 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => renderEmailWithI18N(template, { plainText: true }), ]); + const i18n = await getI18nInstance(); + + const subject = match(source) + .with('RESET', () => i18n._(msg`Password Reset Success!`)) + .with('UPDATE', () => i18n._(msg`Your password was changed`)) + .exhaustive(); + return await mailer.sendMail({ to: { address: user.email, @@ -40,7 +53,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso', address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com', }, - subject: 'Password Reset Success!', + subject, html, text, }); diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index 5dfa47450..1d59e5501 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -66,6 +66,18 @@ export const linkOrgAccountRateLimit = createRateLimit({ window: '1h', }); +// ---- Auth (Tier 3 - Authenticated, verifies secrets) ---- + +/** + * Bounds guessing of the current password and 2FA code via the update password endpoint. + */ +export const updatePasswordRateLimit = createRateLimit({ + action: 'auth.update-password', + max: 5, + globalMax: 20, + window: '15m', +}); + export const reportSenderRateLimit = createRateLimit({ action: 'recipient.report-sender', max: 1, diff --git a/packages/lib/server-only/user/get-user-auth-methods.ts b/packages/lib/server-only/user/get-user-auth-methods.ts new file mode 100644 index 000000000..447f9c6ac --- /dev/null +++ b/packages/lib/server-only/user/get-user-auth-methods.ts @@ -0,0 +1,39 @@ +import { prisma } from '@documenso/prisma'; + +import type { TUserAuthMethod } from '../../types/user-auth-method'; +import { deriveUserAuthMethods } from '../../utils/user-auth-methods'; + +export type GetUserAuthMethodsOptions = { + userId: number; +}; + +/** + * Get the distinct sign in methods available to a user, such as password, + * passkey or linked OAuth providers. + */ +export const getUserAuthMethods = async ({ userId }: GetUserAuthMethodsOptions): Promise => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + select: { + password: true, + accounts: { + select: { + provider: true, + }, + }, + _count: { + select: { + passkeys: true, + }, + }, + }, + }); + + return deriveUserAuthMethods({ + hasPassword: user.password !== null, + passkeyCount: user._count.passkeys, + accountProviders: user.accounts.map((account) => account.provider), + }); +}; diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts index f4d52c015..ddf3b9b76 100644 --- a/packages/lib/server-only/user/reset-password.ts +++ b/packages/lib/server-only/user/reset-password.ts @@ -47,7 +47,7 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP const isSamePassword = await compare(password, foundToken.user.password || ''); if (isSamePassword) { - throw new AppError('SAME_PASSWORD'); + throw new AppError(AppErrorCode.SAME_PASSWORD); } const hashedPassword = await hash(password, SALT_ROUNDS); @@ -82,6 +82,7 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP name: 'send.password.reset.success.email', payload: { userId: foundToken.userId, + source: 'RESET', }, }); diff --git a/packages/lib/server-only/user/update-password.ts b/packages/lib/server-only/user/update-password.ts index a0c31dcf4..547e2e025 100644 --- a/packages/lib/server-only/user/update-password.ts +++ b/packages/lib/server-only/user/update-password.ts @@ -4,41 +4,77 @@ import { prisma } from '@documenso/prisma'; import { compare, hash } from '@node-rs/bcrypt'; import { UserSecurityAuditLogType } from '@prisma/client'; -import { AppError } from '../../errors/app-error'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { jobsClient } from '../../jobs/client'; +import { validateTwoFactorAuthentication } from '../2fa/validate-2fa'; export type UpdatePasswordOptions = { userId: number; password: string; currentPassword: string; + totpCode?: string; + backupCode?: string; requestMetadata?: RequestMetadata; }; -export const updatePassword = async ({ userId, password, currentPassword, requestMetadata }: UpdatePasswordOptions) => { - // Existence check +/** + * Update the password for a user who already has one. + * + * Requires the current password, and a valid TOTP or backup code if the user + * has two factor authentication enabled. + */ +export const updatePassword = async ({ + userId, + password, + currentPassword, + totpCode, + backupCode, + requestMetadata, +}: UpdatePasswordOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { id: userId, }, + select: { + id: true, + email: true, + password: true, + twoFactorEnabled: true, + twoFactorSecret: true, + twoFactorBackupCodes: true, + }, }); if (!user.password) { - throw new AppError('NO_PASSWORD'); + throw new AppError(AppErrorCode.NO_PASSWORD); } const isCurrentPasswordValid = await compare(currentPassword, user.password); if (!isCurrentPasswordValid) { - throw new AppError('INCORRECT_PASSWORD'); + throw new AppError(AppErrorCode.INCORRECT_PASSWORD); + } + + if (user.twoFactorEnabled) { + if (!totpCode && !backupCode) { + throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS, { statusCode: 400 }); + } + + const isTwoFactorValid = await validateTwoFactorAuthentication({ user, totpCode, backupCode }); + + if (!isTwoFactorValid) { + throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE, { statusCode: 401 }); + } } // Compare the new password with the old password const isSamePassword = await compare(password, user.password); if (isSamePassword) { - throw new AppError('SAME_PASSWORD'); + throw new AppError(AppErrorCode.SAME_PASSWORD); } const hashedNewPassword = await hash(password, SALT_ROUNDS); - return await prisma.$transaction(async (tx) => { + const updatedUser = await prisma.$transaction(async (tx) => { await tx.userSecurityAuditLog.create({ data: { userId, @@ -63,4 +99,15 @@ export const updatePassword = async ({ userId, password, currentPassword, reques }, }); }); + + // Notify the user so a change made from a hijacked session does not go unnoticed. + await jobsClient.triggerJob({ + name: 'send.password.reset.success.email', + payload: { + userId, + source: 'UPDATE', + }, + }); + + return updatedUser; }; diff --git a/packages/lib/types/user-auth-method.ts b/packages/lib/types/user-auth-method.ts new file mode 100644 index 000000000..9f70fa3c7 --- /dev/null +++ b/packages/lib/types/user-auth-method.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +/** + * The methods a user can use to sign in to their account. + */ +export const UserAuthMethod = { + PASSWORD: 'PASSWORD', + PASSKEY: 'PASSKEY', + GOOGLE: 'GOOGLE', + MICROSOFT: 'MICROSOFT', + OIDC: 'OIDC', + ORGANISATION_SSO: 'ORGANISATION_SSO', +} as const; + +export const ZUserAuthMethodSchema = z.nativeEnum(UserAuthMethod); + +export type TUserAuthMethod = z.infer; diff --git a/packages/lib/utils/user-auth-methods.test.ts b/packages/lib/utils/user-auth-methods.test.ts new file mode 100644 index 000000000..522fc8bf2 --- /dev/null +++ b/packages/lib/utils/user-auth-methods.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { UserAuthMethod } from '../types/user-auth-method'; +import { deriveUserAuthMethods } from './user-auth-methods'; + +describe('deriveUserAuthMethods', () => { + it('returns an empty list when the user has no sign in methods', () => { + expect(deriveUserAuthMethods({ hasPassword: false, passkeyCount: 0, accountProviders: [] })).toEqual([]); + }); + + it('includes PASSWORD when the user has a password', () => { + expect(deriveUserAuthMethods({ hasPassword: true, passkeyCount: 0, accountProviders: [] })).toEqual([ + UserAuthMethod.PASSWORD, + ]); + }); + + it('includes PASSKEY when the user has at least one passkey', () => { + expect(deriveUserAuthMethods({ hasPassword: false, passkeyCount: 2, accountProviders: [] })).toEqual([ + UserAuthMethod.PASSKEY, + ]); + }); + + it('maps built in OAuth providers to their auth method', () => { + expect( + deriveUserAuthMethods({ + hasPassword: false, + passkeyCount: 0, + accountProviders: ['google', 'microsoft', 'oidc'], + }), + ).toEqual([UserAuthMethod.GOOGLE, UserAuthMethod.MICROSOFT, UserAuthMethod.OIDC]); + }); + + it('treats unknown providers as organisation SSO', () => { + expect( + deriveUserAuthMethods({ + hasPassword: false, + passkeyCount: 0, + accountProviders: ['org_abc123'], + }), + ).toEqual([UserAuthMethod.ORGANISATION_SSO]); + }); + + it('deduplicates repeated providers', () => { + expect( + deriveUserAuthMethods({ + hasPassword: true, + passkeyCount: 0, + accountProviders: ['google', 'google', 'org_a', 'org_b'], + }), + ).toEqual([UserAuthMethod.PASSWORD, UserAuthMethod.GOOGLE, UserAuthMethod.ORGANISATION_SSO]); + }); +}); diff --git a/packages/lib/utils/user-auth-methods.ts b/packages/lib/utils/user-auth-methods.ts new file mode 100644 index 000000000..c9960b4f5 --- /dev/null +++ b/packages/lib/utils/user-auth-methods.ts @@ -0,0 +1,53 @@ +import { type TUserAuthMethod, UserAuthMethod } from '../types/user-auth-method'; + +type DeriveUserAuthMethodsOptions = { + /** + * Whether the user has a password hash stored. + */ + hasPassword: boolean; + + /** + * The number of passkeys registered to the user. + */ + passkeyCount: number; + + /** + * The `provider` values of the user's linked `Account` rows. + */ + accountProviders: string[]; +}; + +const OAUTH_PROVIDER_AUTH_METHODS: Record = { + google: UserAuthMethod.GOOGLE, + microsoft: UserAuthMethod.MICROSOFT, + oidc: UserAuthMethod.OIDC, +}; + +/** + * Derive the distinct set of sign in methods available to a user. + * + * Any `Account.provider` value that is not one of the built in OAuth providers + * is treated as an organisation authentication portal, since those accounts use + * the organisation ID as the provider. + */ +export const deriveUserAuthMethods = ({ + hasPassword, + passkeyCount, + accountProviders, +}: DeriveUserAuthMethodsOptions): TUserAuthMethod[] => { + const authMethods = new Set(); + + if (hasPassword) { + authMethods.add(UserAuthMethod.PASSWORD); + } + + if (passkeyCount > 0) { + authMethods.add(UserAuthMethod.PASSKEY); + } + + for (const provider of accountProviders) { + authMethods.add(OAUTH_PROVIDER_AUTH_METHODS[provider] ?? UserAuthMethod.ORGANISATION_SSO); + } + + return Array.from(authMethods); +}; diff --git a/packages/trpc/server/auth-router/get-auth-methods.ts b/packages/trpc/server/auth-router/get-auth-methods.ts new file mode 100644 index 000000000..bf018d8b7 --- /dev/null +++ b/packages/trpc/server/auth-router/get-auth-methods.ts @@ -0,0 +1,19 @@ +import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods'; + +import { authenticatedProcedure } from '../trpc'; +import { ZGetAuthMethodsResponseSchema } from './get-auth-methods.types'; + +/** + * Get the sign in methods available to the current user. + */ +export const getAuthMethodsRoute = authenticatedProcedure + .output(ZGetAuthMethodsResponseSchema) + .query(async ({ ctx }) => { + const authMethods = await getUserAuthMethods({ + userId: ctx.user.id, + }); + + return { + authMethods, + }; + }); diff --git a/packages/trpc/server/auth-router/get-auth-methods.types.ts b/packages/trpc/server/auth-router/get-auth-methods.types.ts new file mode 100644 index 000000000..18a324c82 --- /dev/null +++ b/packages/trpc/server/auth-router/get-auth-methods.types.ts @@ -0,0 +1,8 @@ +import { ZUserAuthMethodSchema } from '@documenso/lib/types/user-auth-method'; +import { z } from 'zod'; + +export const ZGetAuthMethodsResponseSchema = z.object({ + authMethods: z.array(ZUserAuthMethodSchema), +}); + +export type TGetAuthMethodsResponse = z.infer; diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 5fc4b2c99..3ea834507 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -5,9 +5,11 @@ import { createPasskeyRegistrationOptionsRoute } from './create-passkey-registra import { createPasskeySigninOptionsRoute } from './create-passkey-signin-options'; import { deletePasskeyRoute } from './delete-passkey'; import { findPasskeysRoute } from './find-passkeys'; +import { getAuthMethodsRoute } from './get-auth-methods'; import { updatePasskeyRoute } from './update-passkey'; export const authRouter = router({ + getAuthMethods: getAuthMethodsRoute, passkey: router({ create: createPasskeyRoute, createAuthenticationOptions: createPasskeyAuthenticationOptionsRoute,