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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -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)
|
||||
|
||||
+12
-1
@@ -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;
|
||||
}
|
||||
|
||||
+155
-90
@@ -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', () => {
|
||||
|
||||
+107
-86
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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' };
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user