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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23372?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-08-03 15:52:05 +02:00
committed by GitHub
parent 96ea1e1ffc
commit 2389b4f807
16 changed files with 523 additions and 204 deletions
@@ -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!
@@ -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
@@ -9280,6 +9280,9 @@ export default {
],
"workspaceId": [
4
],
"captchaToken": [
1
]
}
],
@@ -3338,6 +3338,7 @@ export type MutationEditSsoIdentityProviderArgs = {
export type MutationEmailPasswordResetLinkArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
workspaceId?: InputMaybe<Scalars['UUID']['input']>;
};
@@ -7019,6 +7020,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __
export type EmailPasswordResetLinkMutationVariables = Exact<{
email: Scalars['String']['input'];
workspaceId?: InputMaybe<Scalars['UUID']['input']>;
captchaToken?: InputMaybe<Scalars['String']['input']>;
}>;
@@ -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<FindOneApplicationSummaryQuery, FindOneApplicationSummaryQueryVariables>;
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<IsApplicationStoppedQuery, IsApplicationStoppedQueryVariables>;
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<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
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<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
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<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
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<GenerateApiKeyTokenMutation, GenerateApiKeyTokenMutationVariables>;
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<GeneratePlaygroundTokenMutation, GeneratePlaygroundTokenMutationVariables>;
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<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
@@ -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
}
}
@@ -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 } },
@@ -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,
],
);
@@ -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),
];
@@ -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,
@@ -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>(AuthResolver);
resetPasswordService =
module.get<ResetPasswordService>(ResetPasswordService);
throttlerService = module.get<ThrottlerService>(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();
});
});
});
@@ -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<UserWorkspaceEntity>,
@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<EmailPasswordResetLinkDTO> {
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)
@@ -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;
}
@@ -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<unknown>,
) =>
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', () => {
@@ -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<void> {
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<PasswordResetToken> {
const user = await this.userService.findUserByEmailOrThrow(
email,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT, {
userFriendlyMessage: msg`User not found.`,
}),
);
): Promise<PasswordResetTokenGenerationResult> {
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<void> {
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<string> {
): Promise<WorkspaceEntity | null> {
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<EmailPasswordResetLinkDTO> {
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<string> {
const workspace = await this.workspaceRepository.findOne({
): Promise<WorkspaceEntity | null> {
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;
}
}
@@ -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' };
@@ -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);
}
}