From 2389b4f807f6b5d8ac2d95b68c9fdf16931171c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:52:05 +0200 Subject: [PATCH] Add captcha and throttling to the password reset link (#23372) The public `emailPasswordResetLink` mutation was the only email-taking auth mutation without `CaptchaGuard`, so bots could drive reset email spam against arbitrary addresses. - Adds `CaptchaGuard` and a `captchaToken` argument (no-op when no captcha provider is configured). The frontend sends it like sign-in does, and `/settings/profile` joins the captcha-protected paths so the Change Password button keeps working - Throttles reset emails per address, 3 per 15 minutes, and surfaces a rate limit error once the bucket is empty - Acknowledges the request as soon as the throttle passes and generates the link off the request path, so the response time no longer depends on whether the address is registered - Returns a generic success instead of distinguishing found from not-found, with matching frontend copy - Rotates the reset token in a single transaction, so a failed write can no longer revoke a still valid link This does not close user enumeration on its own: `checkUserExists` exposes `exists` on the same unauthenticated surface, and sign-in returns distinguishable errors. Tracked in #23711. Review in cubic --- .../src/metadata/generated/schema.graphql | 2 +- .../src/metadata/generated/schema.ts | 2 +- .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 4 +- .../mutations/emailPasswordResetLink.ts | 12 +- .../__tests__/useHandleResetPassword.test.ts | 37 ++- .../hooks/useHandleResetPassword.ts | 21 +- .../constants/CaptchaProtectedPaths.ts | 4 +- .../engine/core-modules/auth/auth.module.ts | 2 + .../core-modules/auth/auth.resolver.spec.ts | 123 ++++++++- .../engine/core-modules/auth/auth.resolver.ts | 42 ++- .../dto/email-password-reset-link.input.ts | 13 +- .../services/reset-password.service.spec.ts | 245 +++++++++++------- .../auth/services/reset-password.service.ts | 193 ++++++++------ ...word-reset-token-generation-result.type.ts | 13 + .../throttler-graphql-api-exception.filter.ts | 11 + 16 files changed, 523 insertions(+), 204 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token-generation-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/throttler/filters/throttler-graphql-api-exception.filter.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 7694bb2c26..ad2d54e701 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3597,7 +3597,7 @@ type Mutation { renewToken(appToken: String!): AuthTokens! generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken! generatePlaygroundToken: AuthToken! - emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink! + emailPasswordResetLink(email: String!, workspaceId: UUID, captchaToken: String): EmailPasswordResetLink! updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword! initiateOTPProvisioning(loginToken: String!, origin: String!): InitiateTwoFactorAuthenticationProvisioning! initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index ba8b76e581..d1ace18c7c 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -6436,7 +6436,7 @@ export interface MutationGenqlSelection{ renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} }) generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} }) generatePlaygroundToken?: AuthTokenGenqlSelection - emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} }) + emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null), captchaToken?: (Scalars['String'] | null)} }) updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} }) initiateOTPProvisioning?: (InitiateTwoFactorAuthenticationProvisioningGenqlSelection & { __args: {loginToken: Scalars['String'], origin: Scalars['String']} }) initiateOTPProvisioningForAuthenticatedUser?: InitiateTwoFactorAuthenticationProvisioningGenqlSelection diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index a3a3038f7b..c84cae5f09 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -9280,6 +9280,9 @@ export default { ], "workspaceId": [ 4 + ], + "captchaToken": [ + 1 ] } ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9f799486d5..4e3f8761c0 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -3338,6 +3338,7 @@ export type MutationEditSsoIdentityProviderArgs = { export type MutationEmailPasswordResetLinkArgs = { + captchaToken?: InputMaybe; email: Scalars['String']['input']; workspaceId?: InputMaybe; }; @@ -7019,6 +7020,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __ export type EmailPasswordResetLinkMutationVariables = Exact<{ email: Scalars['String']['input']; workspaceId?: InputMaybe; + captchaToken?: InputMaybe; }>; @@ -9230,7 +9232,7 @@ export const FindOneApplicationNameDocument = {"kind":"Document","definitions":[ export const FindOneApplicationSummaryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneApplicationSummary"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneApplication"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const IsApplicationStoppedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IsApplicationStopped"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationUniversalIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isApplicationStopped"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationUniversalIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationUniversalIdentifier"}}}]}]}}]} as unknown as DocumentNode; export const AuthorizeAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authorizeApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"codeChallenge"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"redirectUrl"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"state"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorizeApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"clientId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}}},{"kind":"Argument","name":{"kind":"Name","value":"codeChallenge"},"value":{"kind":"Variable","name":{"kind":"Name","value":"codeChallenge"}}},{"kind":"Argument","name":{"kind":"Name","value":"redirectUrl"},"value":{"kind":"Variable","name":{"kind":"Name","value":"redirectUrl"}}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"Variable","name":{"kind":"Name","value":"state"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"redirectUrl"}}]}}]}}]} as unknown as DocumentNode; -export const EmailPasswordResetLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EmailPasswordResetLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"emailPasswordResetLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; +export const EmailPasswordResetLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EmailPasswordResetLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"emailPasswordResetLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const GenerateApiKeyTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GenerateApiKeyToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiresAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateApiKeyToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"apiKeyId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiresAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiresAt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]} as unknown as DocumentNode; export const GeneratePlaygroundTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GeneratePlaygroundToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generatePlaygroundToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; export const GenerateTransientTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts index a6db1ef07a..0f956a8a01 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts @@ -1,8 +1,16 @@ import { gql } from '@apollo/client'; export const EMAIL_PASSWORD_RESET_LINK = gql` - mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID) { - emailPasswordResetLink(email: $email, workspaceId: $workspaceId) { + mutation EmailPasswordResetLink( + $email: String! + $workspaceId: UUID + $captchaToken: String + ) { + emailPasswordResetLink( + email: $email + workspaceId: $workspaceId + captchaToken: $captchaToken + ) { success } } diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts index a7ee0704ee..9292b7eb72 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts @@ -6,6 +6,8 @@ import { Provider as JotaiProvider } from 'jotai'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; +import { useCaptcha } from '@/client-config/hooks/useCaptcha'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; @@ -16,6 +18,8 @@ import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; // Mocks jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar'); jest.mock('@apollo/client/react'); +jest.mock('@/captcha/hooks/useReadCaptchaToken'); +jest.mock('@/client-config/hooks/useCaptcha'); dynamicActivate(SOURCE_LOCALE); @@ -64,6 +68,10 @@ describe('useHandleResetPassword', () => { (useMutation as unknown as jest.Mock).mockReturnValue([ emailPasswordResetLinkMock, ]); + (useCaptcha as jest.Mock).mockReturnValue({ isCaptchaReady: true }); + (useReadCaptchaToken as jest.Mock).mockReturnValue({ + readCaptchaToken: () => 'mock-captcha-token', + }); }); it('should show error message if email is invalid', async () => { @@ -84,10 +92,15 @@ describe('useHandleResetPassword', () => { await act(() => result.current.handleResetPassword('test@example.com')()); expect(emailPasswordResetLinkMock).toHaveBeenCalledWith({ - variables: { email: 'test@example.com', workspaceId: 'workspace-id' }, + variables: { + email: 'test@example.com', + workspaceId: 'workspace-id', + captchaToken: 'mock-captcha-token', + }, }); expect(enqueueSuccessSnackBarMock).toHaveBeenCalledWith({ - message: 'Password reset link has been sent to the email', + message: + 'If this email is registered, a password reset link has been sent', }); }); @@ -100,13 +113,29 @@ describe('useHandleResetPassword', () => { await act(() => result.current.handleResetPassword('test@example.com')()); expect(emailPasswordResetLinkMock).toHaveBeenCalledWith({ - variables: { email: 'test@example.com' }, + variables: { + email: 'test@example.com', + captchaToken: 'mock-captcha-token', + }, }); expect(enqueueSuccessSnackBarMock).toHaveBeenCalledWith({ - message: 'Password reset link has been sent to the email', + message: + 'If this email is registered, a password reset link has been sent', }); }); + it('should show error message if captcha is not ready', async () => { + (useCaptcha as jest.Mock).mockReturnValue({ isCaptchaReady: false }); + + const { result } = renderHooks(); + await act(() => result.current.handleResetPassword('test@example.com')()); + + expect(enqueueErrorSnackBarMock).toHaveBeenCalledWith({ + message: 'Captcha (anti-bot check) is still loading, try again', + }); + expect(emailPasswordResetLinkMock).not.toHaveBeenCalled(); + }); + it('should show error message if sending reset link fails', async () => { emailPasswordResetLinkMock.mockResolvedValue({ data: { emailPasswordResetLink: { success: false } }, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts index 30f61c936b..1bb214b27a 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts @@ -2,6 +2,8 @@ import { useCallback } from 'react'; import { currentUserState } from '@/auth/states/currentUserState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; +import { useCaptcha } from '@/client-config/hooks/useCaptcha'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { CombinedGraphQLErrors } from '@apollo/client/errors'; import { useLingui } from '@lingui/react/macro'; @@ -14,6 +16,8 @@ export const useHandleResetPassword = () => { const [emailPasswordResetLink] = useMutation(EmailPasswordResetLinkDocument); const workspacePublicData = useAtomStateValue(workspacePublicDataState); const currentUser = useAtomStateValue(currentUserState); + const { isCaptchaReady } = useCaptcha(); + const { readCaptchaToken } = useReadCaptchaToken(); const { t } = useLingui(); @@ -27,16 +31,25 @@ export const useHandleResetPassword = () => { return; } + if (!isCaptchaReady) { + enqueueErrorSnackBar({ + message: t`Captcha (anti-bot check) is still loading, try again`, + }); + return; + } + + const captchaToken = readCaptchaToken(); + try { const { data } = await emailPasswordResetLink({ variables: workspacePublicData?.id - ? { email, workspaceId: workspacePublicData.id } - : { email }, + ? { email, workspaceId: workspacePublicData.id, captchaToken } + : { email, captchaToken }, }); if (data?.emailPasswordResetLink?.success === true) { enqueueSuccessSnackBar({ - message: t`Password reset link has been sent to the email`, + message: t`If this email is registered, a password reset link has been sent`, }); } else { enqueueErrorSnackBar({}); @@ -57,6 +70,8 @@ export const useHandleResetPassword = () => { enqueueSuccessSnackBar, t, emailPasswordResetLink, + isCaptchaReady, + readCaptchaToken, ], ); diff --git a/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts b/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts index a48182f145..78d95dbce9 100644 --- a/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts +++ b/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts @@ -1,4 +1,5 @@ -import { AppPath } from 'twenty-shared/types'; +import { AppPath, SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; export const CAPTCHA_PROTECTED_PATHS: string[] = [ AppPath.SignInUp, @@ -6,4 +7,5 @@ export const CAPTCHA_PROTECTED_PATHS: string[] = [ AppPath.VerifyEmail, AppPath.ResetPassword, AppPath.Invite, + getSettingsPath(SettingsPath.ProfilePage), ]; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index eb318ecfb6..368efc7692 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -54,6 +54,7 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; +import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; @@ -84,6 +85,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; JwtModule, WorkspaceDomainsModule, TokenModule, + ThrottlerModule, UserModule, TypeOrmModule.forFeature([ WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 05c10509c7..a96b1f1912 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -1,4 +1,4 @@ -import { type CanActivate } from '@nestjs/common'; +import { type CanActivate, Logger } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -12,6 +12,13 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; +import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; +import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type'; +import { + ThrottlerException, + ThrottlerExceptionCode, +} from 'src/engine/core-modules/throttler/throttler.exception'; +import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service'; import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service'; @@ -37,6 +44,8 @@ import { TransientTokenService } from './token/services/transient-token.service' describe('AuthResolver', () => { let resolver: AuthResolver; + let resetPasswordService: ResetPasswordService; + let throttlerService: ThrottlerService; const mock_CaptchaGuard: CanActivate = { canActivate: jest.fn(() => true) }; beforeEach(async () => { @@ -105,7 +114,17 @@ describe('AuthResolver', () => { }, { provide: ResetPasswordService, - useValue: {}, + useValue: { + generateAndSendPasswordResetLink: jest + .fn() + .mockResolvedValue(undefined), + }, + }, + { + provide: ThrottlerService, + useValue: { + tokenBucketThrottleOrThrow: jest.fn(), + }, }, { provide: LoginTokenService, @@ -170,9 +189,109 @@ describe('AuthResolver', () => { .compile(); resolver = module.get(AuthResolver); + resetPasswordService = + module.get(ResetPasswordService); + throttlerService = module.get(ThrottlerService); }); it('should be defined', () => { expect(resolver).toBeDefined(); }); + + describe('emailPasswordResetLink', () => { + const emailPasswordResetInput = { + email: 'test@example.com', + workspaceId: 'workspace-id', + } as EmailPasswordResetLinkInput; + const context = { req: { locale: 'en' } } as I18nContext; + + it('should send the password reset link and return success', async () => { + const result = await resolver.emailPasswordResetLink( + emailPasswordResetInput, + context, + ); + + expect(result).toEqual({ success: true }); + expect( + resetPasswordService.generateAndSendPasswordResetLink, + ).toHaveBeenCalledWith({ + email: 'test@example.com', + workspaceId: 'workspace-id', + locale: 'en', + }); + }); + + it('should return success without waiting for the link to be sent', async () => { + const loggerErrorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(); + + ( + resetPasswordService.generateAndSendPasswordResetLink as jest.Mock + ).mockRejectedValue(new Error('database down')); + + const result = await resolver.emailPasswordResetLink( + emailPasswordResetInput, + context, + ); + + expect(result).toEqual({ success: true }); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to send the password reset link', + expect.any(Error), + ); + }); + + it('should throttle and send with a normalized email address', async () => { + await resolver.emailPasswordResetLink( + { + email: 'TeSt@Example.com', + } as EmailPasswordResetLinkInput, + context, + ); + + expect(throttlerService.tokenBucketThrottleOrThrow).toHaveBeenCalledWith( + 'password-reset-email:test@example.com', + 1, + expect.any(Number), + expect.any(Number), + ); + expect( + resetPasswordService.generateAndSendPasswordResetLink, + ).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@example.com' }), + ); + }); + + it('should surface the throttling error without sending the link', async () => { + ( + throttlerService.tokenBucketThrottleOrThrow as jest.Mock + ).mockRejectedValue( + new ThrottlerException( + 'Limit reached', + ThrottlerExceptionCode.LIMIT_REACHED, + ), + ); + + await expect( + resolver.emailPasswordResetLink(emailPasswordResetInput, context), + ).rejects.toThrow(ThrottlerException); + expect( + resetPasswordService.generateAndSendPasswordResetLink, + ).not.toHaveBeenCalled(); + }); + + it('should rethrow non throttling errors', async () => { + ( + throttlerService.tokenBucketThrottleOrThrow as jest.Mock + ).mockRejectedValue(new Error('cache down')); + + await expect( + resolver.emailPasswordResetLink(emailPasswordResetInput, context), + ).rejects.toThrow('cache down'); + expect( + resetPasswordService.generateAndSendPasswordResetLink, + ).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 4c81e86e93..7f23cff3b4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -1,4 +1,4 @@ -import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Logger, UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Context, Mutation, Query } from '@nestjs/graphql'; import { InjectRepository } from '@nestjs/typeorm'; @@ -42,6 +42,8 @@ import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dt import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.dto'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; +import { ThrottlerGraphqlApiExceptionFilter } from 'src/engine/core-modules/throttler/filters/throttler-graphql-api-exception.filter'; +import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service'; @@ -109,6 +111,9 @@ import { WorkspaceInviteHashValidDTO } from './dto/workspace-invite-hash-valid.d import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; +const PASSWORD_RESET_EMAIL_RATE_LIMIT_MAX = 3; +const PASSWORD_RESET_EMAIL_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; + @UsePipes(ResolverValidationPipe) @MetadataResolver() @UseFilters( @@ -118,10 +123,14 @@ import { AuthService } from './services/auth.service'; EmailVerificationExceptionFilter, TwoFactorAuthenticationExceptionFilter, WorkspaceGraphqlApiExceptionFilter, + ThrottlerGraphqlApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter, ) export class AuthResolver { + private readonly logger = new Logger(AuthResolver.name); + constructor( + private readonly throttlerService: ThrottlerService, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, @InjectRepository(AppTokenEntity) @@ -921,22 +930,31 @@ export class AuthResolver { } @Mutation(() => EmailPasswordResetLinkDTO) - @UseGuards(PublicEndpointGuard, NoPermissionGuard) + @UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard) async emailPasswordResetLink( @Args() emailPasswordResetInput: EmailPasswordResetLinkInput, @Context() context: I18nContext, ): Promise { - const resetToken = - await this.resetPasswordService.generatePasswordResetToken( - emailPasswordResetInput.email, - emailPasswordResetInput.workspaceId, - ); + const normalizedEmail = emailPasswordResetInput.email.toLowerCase(); - return await this.resetPasswordService.sendEmailPasswordResetLink({ - resetToken, - email: emailPasswordResetInput.email, - locale: context.req.locale, - }); + await this.throttlerService.tokenBucketThrottleOrThrow( + `password-reset-email:${normalizedEmail}`, + 1, + PASSWORD_RESET_EMAIL_RATE_LIMIT_MAX, + PASSWORD_RESET_EMAIL_RATE_LIMIT_WINDOW_MS, + ); + + void this.resetPasswordService + .generateAndSendPasswordResetLink({ + email: normalizedEmail, + workspaceId: emailPasswordResetInput.workspaceId, + locale: context.req.locale, + }) + .catch((error) => { + this.logger.error('Failed to send the password reset link', error); + }); + + return { success: true }; } @Mutation(() => InvalidatePasswordDTO) diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts index a0afd5c4eb..92fc01bf3e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts @@ -1,6 +1,12 @@ import { ArgsType, Field } from '@nestjs/graphql'; -import { IsEmail, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -15,4 +21,9 @@ export class EmailPasswordResetLinkInput { @IsOptional() @IsUUID() workspaceId?: string; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + captchaToken?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts index 5b7045c4c4..aaf559eb64 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts @@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { addMilliseconds } from 'date-fns'; -import { Repository } from 'typeorm'; +import { type EntityManager, Repository } from 'typeorm'; import { AppTokenEntity, @@ -52,7 +52,7 @@ describe('ResetPasswordService', () => { { provide: UserService, useValue: { - findUserByEmailOrThrow: jest.fn(), + findUserByEmail: jest.fn(), findUserByIdOrThrow: jest.fn(), }, }, @@ -67,7 +67,7 @@ describe('ResetPasswordService', () => { { provide: EmailService, useValue: { - send: jest.fn().mockResolvedValue({ success: true }), + send: jest.fn().mockResolvedValue(undefined), }, }, { @@ -108,6 +108,25 @@ describe('ResetPasswordService', () => { ); }); + const mockAppTokenTransaction = () => { + const updateSpy = jest.fn().mockResolvedValue({ affected: 1 }); + const saveSpy = jest.fn().mockResolvedValue({} as AppTokenEntity); + + Object.defineProperty(appTokenRepository, 'manager', { + configurable: true, + value: { + transaction: ( + runInTransaction: (entityManager: EntityManager) => Promise, + ) => + runInTransaction({ + getRepository: () => ({ update: updateSpy, save: saveSpy }), + } as unknown as EntityManager), + }, + }); + + return { updateSpy, saveSpy }; + }; + it('should be defined', () => { expect(service).toBeDefined(); }); @@ -117,15 +136,11 @@ describe('ResetPasswordService', () => { const mockUser = { id: '1', email: 'test@example.com' }; jest - .spyOn(userService, 'findUserByEmailOrThrow') + .spyOn(userService, 'findUserByEmail') .mockResolvedValue(mockUser as UserEntity); jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - jest - .spyOn(appTokenRepository, 'save') - .mockResolvedValue({} as AppTokenEntity); jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); const result = await service.generatePasswordResetToken( @@ -133,13 +148,14 @@ describe('ResetPasswordService', () => { 'workspace-id', ); - expect(result.passwordResetToken).toBeDefined(); - expect(result.passwordResetTokenExpiresAt).toBeDefined(); - expect(appTokenRepository.save).toHaveBeenCalledWith( + expect(result).toEqual( expect.objectContaining({ - userId: '1', - workspaceId: 'workspace-id', - type: AppTokenType.PasswordResetToken, + status: 'TOKEN_GENERATED', + resetToken: expect.objectContaining({ + passwordResetToken: expect.any(String), + passwordResetTokenExpiresAt: expect.any(Date), + workspaceId: 'workspace-id', + }), }), ); expect(workspaceRepository.findOne).toHaveBeenCalledWith( @@ -156,7 +172,7 @@ describe('ResetPasswordService', () => { const mockUser = { id: '1', email: 'test@example.com' }; jest - .spyOn(userService, 'findUserByEmailOrThrow') + .spyOn(userService, 'findUserByEmail') .mockResolvedValue(mockUser as UserEntity); jest .spyOn(workspaceRepository, 'findOne') @@ -164,10 +180,6 @@ describe('ResetPasswordService', () => { .mockResolvedValueOnce({ id: 'fallback-workspace-id', } as WorkspaceEntity); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - jest - .spyOn(appTokenRepository, 'save') - .mockResolvedValue({} as AppTokenEntity); jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); const result = await service.generatePasswordResetToken( @@ -175,10 +187,13 @@ describe('ResetPasswordService', () => { 'foreign-workspace-id', ); - expect(result.workspaceId).toBe('fallback-workspace-id'); - expect(appTokenRepository.save).toHaveBeenCalledWith( + expect(result).toEqual( expect.objectContaining({ - workspaceId: 'fallback-workspace-id', + status: 'TOKEN_GENERATED', + resetToken: expect.objectContaining({ + workspaceId: 'fallback-workspace-id', + }), + workspace: expect.objectContaining({ id: 'fallback-workspace-id' }), }), ); }); @@ -188,80 +203,157 @@ describe('ResetPasswordService', () => { const mockWorkspace = { id: 'resolved-workspace-id' }; jest - .spyOn(userService, 'findUserByEmailOrThrow') + .spyOn(userService, 'findUserByEmail') .mockResolvedValue(mockUser as UserEntity); jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace as WorkspaceEntity); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - jest - .spyOn(appTokenRepository, 'save') - .mockResolvedValue({} as AppTokenEntity); jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); const result = await service.generatePasswordResetToken('test@example.com'); - expect(result.workspaceId).toBe('resolved-workspace-id'); - expect(appTokenRepository.save).toHaveBeenCalledWith( + expect(result).toEqual( expect.objectContaining({ - workspaceId: 'resolved-workspace-id', + status: 'TOKEN_GENERATED', + resetToken: expect.objectContaining({ + workspaceId: 'resolved-workspace-id', + }), + workspace: expect.objectContaining({ id: 'resolved-workspace-id' }), }), ); }); - it('should throw an error if no password auth enabled workspace found', async () => { + it('should return a status instead of sending when no password auth enabled workspace is found', async () => { const mockUser = { id: '1', email: 'test@example.com' }; jest - .spyOn(userService, 'findUserByEmailOrThrow') + .spyOn(userService, 'findUserByEmail') .mockResolvedValue(mockUser as UserEntity); jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); - await expect( - service.generatePasswordResetToken('test@example.com'), - ).rejects.toThrow(AuthException); + const result = + await service.generatePasswordResetToken('test@example.com'); + + expect(result).toEqual({ + status: 'NO_PASSWORD_AUTH_ENABLED_WORKSPACE_FOUND', + }); }); - it('should throw an error if user is not found', async () => { - jest - .spyOn(userService, 'findUserByEmailOrThrow') - .mockRejectedValue( - new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), - ); + it('should return a status instead of sending when the user is unknown', async () => { + jest.spyOn(userService, 'findUserByEmail').mockResolvedValue(null); - await expect( - service.generatePasswordResetToken( - 'nonexistent@example.com', - 'workspace-id', - ), - ).rejects.toThrow(AuthException); + const result = await service.generatePasswordResetToken( + 'nonexistent@example.com', + 'workspace-id', + ); + + expect(result).toEqual({ status: 'USER_NOT_FOUND' }); }); - it('should throw an error if a token already exists', async () => { + it('should throw when the reset token expiration config is missing', async () => { const mockUser = { id: '1', email: 'test@example.com' }; - const mockExistingToken = { - userId: '1', - type: AppTokenType.PasswordResetToken, - workspaceId: 'workspace-id', - expiresAt: addMilliseconds(new Date(), 3600000), - }; jest - .spyOn(userService, 'findUserByEmailOrThrow') + .spyOn(userService, 'findUserByEmail') .mockResolvedValue(mockUser as UserEntity); jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity); - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockExistingToken as AppTokenEntity); - jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); + jest.spyOn(twentyConfigService, 'get').mockReturnValue(undefined); await expect( service.generatePasswordResetToken('test@example.com', 'workspace-id'), - ).rejects.toThrow(AuthException); + ).rejects.toMatchObject({ + code: AuthExceptionCode.INTERNAL_SERVER_ERROR, + }); + }); + }); + + describe('generateAndSendPasswordResetLink', () => { + it('should rotate the token and send the email when a token is generated', async () => { + const { updateSpy, saveSpy } = mockAppTokenTransaction(); + + jest.spyOn(userService, 'findUserByEmail').mockResolvedValue({ + id: '1', + email: 'test@example.com', + } as UserEntity); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity); + jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); + jest + .spyOn(workspaceDomainsService, 'buildWorkspaceURL') + .mockReturnValue(new URL('https://subdomain.localhost.com:3000/reset')); + + await service.generateAndSendPasswordResetLink({ + email: 'test@example.com', + workspaceId: 'workspace-id', + locale: 'en', + }); + + expect(updateSpy).toHaveBeenCalled(); + expect(saveSpy).toHaveBeenCalled(); + expect(emailService.send).toHaveBeenCalled(); + }); + + it.each(['USER_NOT_FOUND', 'NO_PASSWORD_AUTH_ENABLED_WORKSPACE_FOUND'])( + 'should skip sending the email when generation status is %s', + async (status) => { + jest + .spyOn(service, 'generatePasswordResetToken') + .mockResolvedValue({ status } as never); + + await service.generateAndSendPasswordResetLink({ + email: 'test@example.com', + locale: 'en', + }); + + expect(emailService.send).not.toHaveBeenCalled(); + }, + ); + }); + + describe('rotatePasswordResetToken', () => { + const mockResetToken = { + workspaceId: 'workspace-id', + passwordResetToken: 'plain-token', + passwordResetTokenExpiresAt: addMilliseconds(new Date(), 3600000), + }; + + it('should revoke the previous tokens and save the hashed one in a single transaction', async () => { + const { updateSpy, saveSpy } = mockAppTokenTransaction(); + + await service.rotatePasswordResetToken({ + userId: '1', + resetToken: mockResetToken, + }); + + expect(updateSpy).toHaveBeenCalledWith( + { userId: '1', type: AppTokenType.PasswordResetToken }, + { revokedAt: expect.any(Date) }, + ); + expect(saveSpy).toHaveBeenCalledWith({ + userId: '1', + workspaceId: 'workspace-id', + value: expect.any(String), + expiresAt: mockResetToken.passwordResetTokenExpiresAt, + type: AppTokenType.PasswordResetToken, + }); + expect(saveSpy.mock.calls[0][0].value).not.toBe('plain-token'); + }); + + it('should rethrow repository errors', async () => { + const { saveSpy } = mockAppTokenTransaction(); + + saveSpy.mockRejectedValue(new Error('db down')); + + await expect( + service.rotatePasswordResetToken({ + userId: '1', + resetToken: mockResetToken, + }), + ).rejects.toThrow('db down'); }); }); @@ -274,12 +366,6 @@ describe('ResetPasswordService', () => { passwordResetTokenExpiresAt: new Date(), }; - jest - .spyOn(userService, 'findUserByEmailOrThrow') - .mockResolvedValue(mockUser as UserEntity); - jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity); jest .spyOn(twentyConfigService, 'get') .mockReturnValue('http://localhost:3000'); @@ -293,35 +379,14 @@ describe('ResetPasswordService', () => { const result = await service.sendEmailPasswordResetLink({ resetToken: mockToken, - email: 'test@example.com', + user: mockUser as UserEntity, + workspace: { id: 'workspace-id' } as WorkspaceEntity, locale: 'en', }); expect(result.success).toBe(true); expect(emailService.send).toHaveBeenCalled(); }); - - it('should throw an error if user is not found', async () => { - const mockToken = { - workspaceId: 'workspace-id', - passwordResetToken: 'token123', - passwordResetTokenExpiresAt: new Date(), - }; - - jest - .spyOn(userService, 'findUserByEmailOrThrow') - .mockRejectedValue( - new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), - ); - - await expect( - service.sendEmailPasswordResetLink({ - resetToken: mockToken, - email: 'nonexistent@example.com', - locale: 'en', - }), - ).rejects.toThrow(AuthException); - }); }); describe('validatePasswordResetToken', () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts index 68a0fbc7c5..76cfc49d23 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import crypto from 'crypto'; @@ -9,11 +9,7 @@ import ms from 'ms'; import { PasswordResetLinkEmail, renderEmail } from 'twenty-emails'; import { type APP_LOCALES } from 'twenty-shared/translations'; import { AppPath } from 'twenty-shared/types'; -import { - assertIsDefinedOrThrow, - getAppPath, - isDefined, -} from 'twenty-shared/utils'; +import { getAppPath, isDefined } from 'twenty-shared/utils'; import { IsNull, MoreThan, Repository } from 'typeorm'; import { @@ -28,16 +24,19 @@ import { type EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto import { type InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto'; import { type ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto'; import { type PasswordResetToken } from 'src/engine/core-modules/auth/types/password-reset-token.type'; +import { type PasswordResetTokenGenerationResult } from 'src/engine/core-modules/auth/types/password-reset-token-generation-result.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; @Injectable() export class ResetPasswordService { + private readonly logger = new Logger(ResetPasswordService.name); + constructor( private readonly twentyConfigService: TwentyConfigService, private readonly workspaceDomainsService: WorkspaceDomainsService, @@ -50,22 +49,60 @@ export class ResetPasswordService { private readonly userService: UserService, ) {} + async generateAndSendPasswordResetLink({ + email, + workspaceId, + locale, + }: { + email: string; + workspaceId?: string; + locale: keyof typeof APP_LOCALES; + }): Promise { + const generationResult = await this.generatePasswordResetToken( + email, + workspaceId, + ); + + if (generationResult.status !== 'TOKEN_GENERATED') { + this.logger.warn( + `Password reset request silently ignored: ${generationResult.status}`, + ); + + return; + } + + await this.rotatePasswordResetToken({ + userId: generationResult.user.id, + resetToken: generationResult.resetToken, + }); + + await this.sendEmailPasswordResetLink({ + resetToken: generationResult.resetToken, + user: generationResult.user, + workspace: generationResult.workspace, + locale, + }); + } + async generatePasswordResetToken( email: string, workspaceId?: string, - ): Promise { - const user = await this.userService.findUserByEmailOrThrow( - email, - new AuthException('User not found', AuthExceptionCode.INVALID_INPUT, { - userFriendlyMessage: msg`User not found.`, - }), - ); + ): Promise { + const user = await this.userService.findUserByEmail(email); - const targetWorkspaceId = await this.resolveTargetWorkspaceId( + if (!isDefined(user)) { + return { status: 'USER_NOT_FOUND' }; + } + + const targetWorkspace = await this.resolveTargetWorkspace( user.id, workspaceId, ); + if (!isDefined(targetWorkspace)) { + return { status: 'NO_PASSWORD_AUTH_ENABLED_WORKSPACE_FOUND' }; + } + const expiresIn = this.twentyConfigService.get( 'PASSWORD_RESET_TOKEN_EXPIRES_IN', ); @@ -79,57 +116,61 @@ export class ResetPasswordService { const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const existingToken = await this.appTokenRepository.findOne({ - where: { - userId: user.id, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - - if (existingToken) { - const timeToWait = ms( - differenceInMilliseconds(existingToken.expiresAt, new Date()), - { long: true }, - ); - - throw new AuthException( - `Token has already been generated. Please wait for ${timeToWait} to generate again.`, - AuthExceptionCode.INVALID_INPUT, - { - userFriendlyMessage: msg`Password reset token has already been generated. Please wait for ${timeToWait} to generate again.`, - }, - ); - } - const plainResetToken = crypto.randomBytes(32).toString('hex'); - const hashedResetToken = crypto - .createHash('sha256') - .update(plainResetToken) - .digest('hex'); - - await this.appTokenRepository.save({ - userId: user.id, - workspaceId: targetWorkspaceId, - value: hashedResetToken, - expiresAt, - type: AppTokenType.PasswordResetToken, - }); return { - workspaceId: targetWorkspaceId, - passwordResetToken: plainResetToken, - passwordResetTokenExpiresAt: expiresAt, + status: 'TOKEN_GENERATED', + resetToken: { + workspaceId: targetWorkspace.id, + passwordResetToken: plainResetToken, + passwordResetTokenExpiresAt: expiresAt, + }, + user, + workspace: targetWorkspace, }; } - private async resolveTargetWorkspaceId( + async rotatePasswordResetToken({ + userId, + resetToken, + }: { + userId: string; + resetToken: PasswordResetToken; + }): Promise { + const hashedResetToken = crypto + .createHash('sha256') + .update(resetToken.passwordResetToken) + .digest('hex'); + + await this.appTokenRepository.manager.transaction(async (entityManager) => { + const appTokenRepository = entityManager.getRepository(AppTokenEntity); + + await appTokenRepository.update( + { + userId, + type: AppTokenType.PasswordResetToken, + }, + { + revokedAt: new Date(), + }, + ); + + await appTokenRepository.save({ + userId, + workspaceId: resetToken.workspaceId, + value: hashedResetToken, + expiresAt: resetToken.passwordResetTokenExpiresAt, + type: AppTokenType.PasswordResetToken, + }); + }); + } + + private async resolveTargetWorkspace( userId: string, workspaceId?: string, - ): Promise { + ): Promise { if (!isDefined(workspaceId)) { - return this.findFirstPasswordAuthEnabledWorkspaceIdOrThrow(userId); + return this.findFirstPasswordAuthEnabledWorkspace(userId); } const requestedWorkspace = await this.workspaceRepository.findOne({ @@ -145,35 +186,27 @@ export class ResetPasswordService { }); return isDefined(requestedWorkspace) - ? requestedWorkspace.id - : this.findFirstPasswordAuthEnabledWorkspaceIdOrThrow(userId); + ? requestedWorkspace + : this.findFirstPasswordAuthEnabledWorkspace(userId); } async sendEmailPasswordResetLink({ resetToken, - email, + user, + workspace, locale, }: { resetToken: PasswordResetToken; - email: string; + user: UserEntity; + workspace: WorkspaceEntity; locale: keyof typeof APP_LOCALES; }): Promise { - const user = await this.userService.findUserByEmailOrThrow( - email, - new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), - ); const hasPassword = isDefined(user.passwordHash); const resetPasswordPath = getAppPath(AppPath.ResetPassword, { passwordResetToken: resetToken.passwordResetToken, }); - const workspace = await this.workspaceRepository.findOneBy({ - id: resetToken.workspaceId, - }); - - assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); - const link = this.workspaceDomainsService.buildWorkspaceURL({ workspace, pathname: resetPasswordPath, @@ -275,10 +308,10 @@ export class ResetPasswordService { return { success: true }; } - private async findFirstPasswordAuthEnabledWorkspaceIdOrThrow( + private async findFirstPasswordAuthEnabledWorkspace( userId: string, - ): Promise { - const workspace = await this.workspaceRepository.findOne({ + ): Promise { + return await this.workspaceRepository.findOne({ where: { workspaceUsers: { user: { @@ -291,17 +324,5 @@ export class ResetPasswordService { createdAt: 'ASC', }, }); - - if (!isDefined(workspace)) { - throw new AuthException( - 'No password auth enabled workspace found', - AuthExceptionCode.INVALID_INPUT, - { - userFriendlyMessage: msg`No workspace found with password auth enabled.`, - }, - ); - } - - return workspace.id; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token-generation-result.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token-generation-result.type.ts new file mode 100644 index 0000000000..89c18aeeb6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token-generation-result.type.ts @@ -0,0 +1,13 @@ +import { type PasswordResetToken } from 'src/engine/core-modules/auth/types/password-reset-token.type'; +import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +export type PasswordResetTokenGenerationResult = + | { + status: 'TOKEN_GENERATED'; + resetToken: PasswordResetToken; + user: UserEntity; + workspace: WorkspaceEntity; + } + | { status: 'USER_NOT_FOUND' } + | { status: 'NO_PASSWORD_AUTH_ENABLED_WORKSPACE_FOUND' }; diff --git a/packages/twenty-server/src/engine/core-modules/throttler/filters/throttler-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/throttler/filters/throttler-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..f93c85783c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/throttler/filters/throttler-graphql-api-exception.filter.ts @@ -0,0 +1,11 @@ +import { Catch, type ExceptionFilter } from '@nestjs/common'; + +import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception'; +import { throttlerToGraphqlApiExceptionHandler } from 'src/engine/core-modules/throttler/utils/throttler-to-graphql-api-exception-handler.util'; + +@Catch(ThrottlerException) +export class ThrottlerGraphqlApiExceptionFilter implements ExceptionFilter { + catch(exception: ThrottlerException) { + return throttlerToGraphqlApiExceptionHandler(exception); + } +}