From 6a99b40cba6b1b52c11d605e76b2a6c333c7ba2e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 11 Sep 2026 14:17:34 +1000 Subject: [PATCH] fix: improve action auth flow for passwordless users (#3358) Selecting password auth failed with a generic "Unauthorized" for users who signed up via OAuth or passkey, with no way to set one. Detect the missing password and email the existing reset link from the signing dialog and security settings. Require a 2FA code and rate limit update-password. --- .../forms/2fa/two-factor-code-dialog.tsx | 158 ++++++++++++++ .../forms/password-setup-request-button.tsx | 53 +++++ .../forms/password-setup-request.tsx | 61 ++++++ apps/remix/app/components/forms/password.tsx | 77 +++++-- .../document-signing-auth-password.tsx | 33 ++- .../document-signing-auth-set-password.tsx | 63 ++++++ .../settings+/security._index.tsx | 54 ++--- .../action-auth-password.spec.ts | 193 ++++++++++++++++++ packages/app-tests/e2e/fixtures/hydration.ts | 27 +++ .../e2e/user/password-two-factor.spec.ts | 105 ++++++++++ packages/auth/server/routes/email-password.ts | 18 +- packages/auth/server/types/email-password.ts | 2 + packages/email/templates/reset-password.tsx | 51 +++-- .../hooks/use-password-setup-request.ts | 31 +++ packages/lib/errors/app-error.ts | 7 + ...nd-password-reset-success-email.handler.ts | 1 + .../send-password-reset-success-email.ts | 12 ++ packages/lib/server-only/2fa/disable-2fa.ts | 2 +- packages/lib/server-only/2fa/enable-2fa.ts | 6 +- packages/lib/server-only/2fa/validate-2fa.ts | 8 +- .../lib/server-only/2fa/view-backup-codes.ts | 4 +- .../server-only/auth/send-reset-password.ts | 17 +- .../lib/server-only/rate-limit/rate-limits.ts | 12 ++ .../server-only/user/get-user-auth-methods.ts | 39 ++++ .../lib/server-only/user/reset-password.ts | 3 +- .../lib/server-only/user/update-password.ts | 61 +++++- packages/lib/types/user-auth-method.ts | 17 ++ packages/lib/utils/user-auth-methods.test.ts | 52 +++++ packages/lib/utils/user-auth-methods.ts | 53 +++++ .../server/auth-router/get-auth-methods.ts | 19 ++ .../auth-router/get-auth-methods.types.ts | 8 + packages/trpc/server/auth-router/router.ts | 2 + 32 files changed, 1165 insertions(+), 84 deletions(-) create mode 100644 apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx create mode 100644 apps/remix/app/components/forms/password-setup-request-button.tsx create mode 100644 apps/remix/app/components/forms/password-setup-request.tsx create mode 100644 apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx create mode 100644 packages/app-tests/e2e/document-auth/action-auth-password.spec.ts create mode 100644 packages/app-tests/e2e/fixtures/hydration.ts create mode 100644 packages/app-tests/e2e/user/password-two-factor.spec.ts create mode 100644 packages/lib/client-only/hooks/use-password-setup-request.ts create mode 100644 packages/lib/server-only/user/get-user-auth-methods.ts create mode 100644 packages/lib/types/user-auth-method.ts create mode 100644 packages/lib/utils/user-auth-methods.test.ts create mode 100644 packages/lib/utils/user-auth-methods.ts create mode 100644 packages/trpc/server/auth-router/get-auth-methods.ts create mode 100644 packages/trpc/server/auth-router/get-auth-methods.types.ts 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,