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.
---
.../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);
+ }
+}