fix(email-verification): prevent double email validation (#12250)

Fix #12177 
Fix #12171

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-05-23 18:24:26 +02:00
committed by GitHub
parent 8de85eea61
commit 5428348d7f
14 changed files with 206 additions and 43 deletions

View File

@ -812,17 +812,6 @@ export enum HealthIndicatorId {
worker = 'worker'
}
export type IdFilter = {
eq?: InputMaybe<Scalars['ID']['input']>;
gt?: InputMaybe<Scalars['ID']['input']>;
gte?: InputMaybe<Scalars['ID']['input']>;
in?: InputMaybe<Array<Scalars['ID']['input']>>;
is?: InputMaybe<FilterIs>;
lt?: InputMaybe<Scalars['ID']['input']>;
lte?: InputMaybe<Scalars['ID']['input']>;
neq?: InputMaybe<Scalars['ID']['input']>;
};
export enum IdentityProviderType {
OIDC = 'OIDC',
SAML = 'SAML'
@ -1254,6 +1243,7 @@ export type MutationGetLoginTokenFromCredentialsArgs = {
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
emailVerificationToken: Scalars['String']['input'];
origin: Scalars['String']['input'];
};
@ -1552,7 +1542,7 @@ export type ObjectRecordFilterInput = {
and?: InputMaybe<Array<ObjectRecordFilterInput>>;
createdAt?: InputMaybe<DateFilter>;
deletedAt?: InputMaybe<DateFilter>;
id?: InputMaybe<IdFilter>;
id?: InputMaybe<UuidFilter>;
not?: InputMaybe<ObjectRecordFilterInput>;
or?: InputMaybe<Array<ObjectRecordFilterInput>>;
updatedAt?: InputMaybe<DateFilter>;
@ -2317,6 +2307,17 @@ export type TransientToken = {
transientToken: AuthToken;
};
export type UuidFilter = {
eq?: InputMaybe<Scalars['UUID']['input']>;
gt?: InputMaybe<Scalars['UUID']['input']>;
gte?: InputMaybe<Scalars['UUID']['input']>;
in?: InputMaybe<Array<Scalars['UUID']['input']>>;
is?: InputMaybe<FilterIs>;
lt?: InputMaybe<Scalars['UUID']['input']>;
lte?: InputMaybe<Scalars['UUID']['input']>;
neq?: InputMaybe<Scalars['UUID']['input']>;
};
export type UuidFilterComparison = {
eq?: InputMaybe<Scalars['UUID']['input']>;
gt?: InputMaybe<Scalars['UUID']['input']>;

View File

@ -1129,6 +1129,7 @@ export type MutationGetLoginTokenFromCredentialsArgs = {
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
emailVerificationToken: Scalars['String'];
origin: Scalars['String'];
};
@ -2626,6 +2627,7 @@ export type GetLoginTokenFromCredentialsMutation = { __typename?: 'Mutation', ge
export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
emailVerificationToken: Scalars['String'];
email: Scalars['String'];
captchaToken?: InputMaybe<Scalars['String']>;
origin: Scalars['String'];
}>;
@ -2749,6 +2751,7 @@ export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typ
export type SearchQueryVariables = Exact<{
searchInput: Scalars['String'];
limit: Scalars['Int'];
after?: InputMaybe<Scalars['String']>;
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
filter?: InputMaybe<ObjectRecordFilterInput>;
@ -3885,9 +3888,10 @@ export type GetLoginTokenFromCredentialsMutationHookResult = ReturnType<typeof u
export type GetLoginTokenFromCredentialsMutationResult = Apollo.MutationResult<GetLoginTokenFromCredentialsMutation>;
export type GetLoginTokenFromCredentialsMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromCredentialsMutation, GetLoginTokenFromCredentialsMutationVariables>;
export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $captchaToken: String, $origin: String!) {
mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $email: String!, $captchaToken: String, $origin: String!) {
getLoginTokenFromEmailVerificationToken(
emailVerificationToken: $emailVerificationToken
email: $email
captchaToken: $captchaToken
origin: $origin
) {
@ -3917,6 +3921,7 @@ export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationF
* const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
* variables: {
* emailVerificationToken: // value for 'emailVerificationToken'
* email: // value for 'email'
* captchaToken: // value for 'captchaToken'
* origin: // value for 'origin'
* },
@ -4641,10 +4646,11 @@ export type GetClientConfigQueryHookResult = ReturnType<typeof useGetClientConfi
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
export const SearchDocument = gql`
query Search($searchInput: String!, $limit: Int!, $excludedObjectNameSingulars: [String!], $includedObjectNameSingulars: [String!], $filter: ObjectRecordFilterInput) {
query Search($searchInput: String!, $limit: Int!, $after: String, $excludedObjectNameSingulars: [String!], $includedObjectNameSingulars: [String!], $filter: ObjectRecordFilterInput) {
search(
searchInput: $searchInput
limit: $limit
after: $after
excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
@ -4682,6 +4688,7 @@ export const SearchDocument = gql`
* variables: {
* searchInput: // value for 'searchInput'
* limit: // value for 'limit'
* after: // value for 'after'
* excludedObjectNameSingulars: // value for 'excludedObjectNameSingulars'
* includedObjectNameSingulars: // value for 'includedObjectNameSingulars'
* filter: // value for 'filter'

View File

@ -2,6 +2,7 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
@ -41,7 +42,10 @@ export const VerifyEmailEffect = () => {
try {
const { loginToken, workspaceUrls } =
await getLoginTokenFromEmailVerificationToken(emailVerificationToken);
await getLoginTokenFromEmailVerificationToken(
emailVerificationToken,
email,
);
enqueueSnackBar(t`Email verified.`, {
dedupeKey: 'email-verification-dedupe-key',
@ -56,10 +60,23 @@ export const VerifyEmailEffect = () => {
}
verifyLoginToken(loginToken.token);
} catch (error) {
enqueueSnackBar(t`Email verification failed.`, {
const message: string =
error instanceof ApolloError
? error.message
: 'Email verification failed';
enqueueSnackBar(t`${message}`, {
dedupeKey: 'email-verification-error-dedupe-key',
variant: SnackBarVariant.Error,
});
if (
error instanceof ApolloError &&
error.graphQLErrors[0].extensions?.subCode ===
'EMAIL_ALREADY_VERIFIED'
) {
navigate(AppPath.SignInUp);
}
setIsError(true);
}
};

View File

@ -3,11 +3,13 @@ import { gql } from '@apollo/client';
export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
mutation GetLoginTokenFromEmailVerificationToken(
$emailVerificationToken: String!
$email: String!
$captchaToken: String
$origin: String!
) {
getLoginTokenFromEmailVerificationToken(
emailVerificationToken: $emailVerificationToken
email: $email
captchaToken: $captchaToken
origin: $origin
) {

View File

@ -210,9 +210,14 @@ export const useAuth = () => {
);
const handleGetLoginTokenFromEmailVerificationToken = useCallback(
async (emailVerificationToken: string, captchaToken?: string) => {
async (
emailVerificationToken: string,
email: string,
captchaToken?: string,
) => {
const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
variables: {
email,
emailVerificationToken,
captchaToken,
origin,

View File

@ -9,10 +9,10 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthResolver } from './auth.resolver';
@ -34,7 +34,7 @@ describe('AuthResolver', () => {
providers: [
AuthResolver,
{
provide: getRepositoryToken(Workspace, 'core'),
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{

View File

@ -53,6 +53,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
@ -75,6 +76,8 @@ export class AuthResolver {
constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private authService: AuthService,
private renewTokenService: RenewTokenService,
private userService: UserService,
@ -168,21 +171,24 @@ export class AuthResolver {
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
@Args('origin') origin: string,
) {
const user =
const appToken =
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
getLoginTokenFromEmailVerificationTokenInput.emailVerificationToken,
getLoginTokenFromEmailVerificationTokenInput,
);
const workspace =
(await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
)) ??
(await this.userWorkspaceService.findFirstWorkspaceByUserId(user.id));
(await this.userWorkspaceService.findFirstWorkspaceByUserId(
appToken.user.id,
));
await this.userService.markEmailAsVerified(user.id);
await this.userService.markEmailAsVerified(appToken.user.id);
await this.appTokenRepository.remove(appToken);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
appToken.user.email,
workspace.id,
);

View File

@ -9,6 +9,11 @@ export class GetLoginTokenFromEmailVerificationTokenInput {
@IsString()
emailVerificationToken: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
email: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()

View File

@ -14,12 +14,14 @@ import {
EmailVerificationExceptionCode,
} from 'src/engine/core-modules/email-verification/email-verification.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EmailVerificationTokenService } from './email-verification-token.service';
describe('EmailVerificationTokenService', () => {
let service: EmailVerificationTokenService;
let appTokenRepository: Repository<AppToken>;
let userRepository: Repository<User>;
let twentyConfigService: TwentyConfigService;
beforeEach(async () => {
@ -30,6 +32,12 @@ describe('EmailVerificationTokenService', () => {
provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {
findOne: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
@ -45,6 +53,9 @@ describe('EmailVerificationTokenService', () => {
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
});
@ -92,14 +103,14 @@ describe('EmailVerificationTokenService', () => {
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
jest
.spyOn(appTokenRepository, 'remove')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
const result =
await service.validateEmailVerificationTokenOrThrow(plainToken);
const result = await service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: plainToken,
email: 'test@example.com',
});
expect(result).toEqual(mockUser);
expect(result).toEqual(mockAppToken);
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
where: {
value: hashedToken,
@ -107,14 +118,17 @@ describe('EmailVerificationTokenService', () => {
},
relations: ['user'],
});
expect(appTokenRepository.remove).toHaveBeenCalledWith(mockAppToken);
});
it('should throw exception for invalid token', async () => {
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validateEmailVerificationTokenOrThrow('invalid-token'),
service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: 'invalid-token',
email: 'test@twenty.com',
}),
).rejects.toThrow(
new EmailVerificationException(
'Invalid email verification token',
@ -123,6 +137,63 @@ describe('EmailVerificationTokenService', () => {
);
});
it('should throw exception for already validated token', async () => {
const mockUser = {
id: 'user-id',
email: 'test@example.com',
isEmailVerified: true,
};
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
await expect(
service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: 'invalid-token',
email: 'test@example.com',
}),
).rejects.toThrow(
new EmailVerificationException(
'Email already verified',
EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED,
),
);
});
it('should throw exception when email does not match appToken email', async () => {
const mockUser = {
id: 'user-id',
email: 'test@example.com',
isEmailVerified: false,
};
const mockAppToken = {
type: AppTokenType.EmailVerificationToken,
expiresAt: new Date(Date.now() + 86400000), // 24h from now
context: { email: 'other-email@example.com' },
user: {
email: 'other-email@example.com',
},
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: 'valid-token',
email: mockUser.email,
}),
).rejects.toThrow(
new EmailVerificationException(
'Email does not match token',
EmailVerificationExceptionCode.INVALID_EMAIL,
),
);
});
it('should throw exception for wrong token type', async () => {
const mockAppToken = {
type: AppTokenType.PasswordResetToken,
@ -132,9 +203,13 @@ describe('EmailVerificationTokenService', () => {
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validateEmailVerificationTokenOrThrow('wrong-type-token'),
service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: 'wrong-type-token',
email: 'test@example.com',
}),
).rejects.toThrow(
new EmailVerificationException(
'Invalid email verification token type',
@ -152,9 +227,13 @@ describe('EmailVerificationTokenService', () => {
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validateEmailVerificationTokenOrThrow('expired-token'),
service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: 'expired-token',
email: 'test@example.com',
}),
).rejects.toThrow(
new EmailVerificationException(
'Email verification token expired',
@ -173,9 +252,13 @@ describe('EmailVerificationTokenService', () => {
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validateEmailVerificationTokenOrThrow('valid-token'),
service.validateEmailVerificationTokenOrThrow({
emailVerificationToken: 'valid-token',
email: 'test@example.com',
}),
).rejects.toThrow(
new EmailVerificationException(
'Email missing in email verification token context',

View File

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { isDefined } from 'twenty-shared/utils';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { Repository } from 'typeorm';
@ -17,12 +18,15 @@ import {
EmailVerificationExceptionCode,
} from 'src/engine/core-modules/email-verification/email-verification.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class EmailVerificationTokenService {
constructor(
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly twentyConfigService: TwentyConfigService,
) {}
@ -54,7 +58,27 @@ export class EmailVerificationTokenService {
};
}
async validateEmailVerificationTokenOrThrow(emailVerificationToken: string) {
async validateEmailVerificationTokenOrThrow({
emailVerificationToken,
email,
}: {
emailVerificationToken: string;
email: string;
}) {
const user = await this.userRepository.findOne({
where: {
email,
isEmailVerified: true,
},
});
if (isDefined(user)) {
throw new EmailVerificationException(
'Email already verified',
EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED,
);
}
const hashedToken = crypto
.createHash('sha256')
.update(emailVerificationToken)
@ -96,8 +120,13 @@ export class EmailVerificationTokenService {
);
}
await this.appTokenRepository.remove(appToken);
if (appToken.context?.email !== email) {
throw new EmailVerificationException(
'Email does not match token',
EmailVerificationExceptionCode.INVALID_EMAIL,
);
}
return appToken.user;
return appToken;
}
}

View File

@ -17,11 +17,16 @@ export class EmailVerificationExceptionFilter implements ExceptionFilter {
case EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE:
case EmailVerificationExceptionCode.TOKEN_EXPIRED:
case EmailVerificationExceptionCode.RATE_LIMIT_EXCEEDED:
throw new ForbiddenError(exception.message);
throw new ForbiddenError(exception.message, {
subCode: exception.code,
});
case EmailVerificationExceptionCode.EMAIL_MISSING:
case EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED:
case EmailVerificationExceptionCode.INVALID_EMAIL:
case EmailVerificationExceptionCode.EMAIL_VERIFICATION_NOT_REQUIRED:
throw new UserInputError(exception.message);
throw new UserInputError(exception.message, {
subCode: exception.code,
});
default: {
const _exhaustiveCheck: never = exception.code;

View File

@ -14,5 +14,6 @@ export enum EmailVerificationExceptionCode {
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
EMAIL_MISSING = 'EMAIL_MISSING',
EMAIL_ALREADY_VERIFIED = 'EMAIL_ALREADY_VERIFIED',
INVALID_EMAIL = 'INVALID_EMAIL',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
}

View File

@ -10,10 +10,11 @@ import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { User } from 'src/engine/core-modules/user/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([AppToken], 'core'),
TypeOrmModule.forFeature([AppToken, User], 'core'),
EmailModule,
TwentyConfigModule,
DomainManagerModule,

View File

@ -150,8 +150,9 @@ export class PersistedQueryNotSupportedError extends BaseGraphQLError {
}
export class UserInputError extends BaseGraphQLError {
constructor(message: string) {
super(message, ErrorCode.BAD_USER_INPUT);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(message: string, extensions?: Record<string, any>) {
super(message, ErrorCode.BAD_USER_INPUT, extensions);
Object.defineProperty(this, 'name', { value: 'UserInputError' });
}