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:
@ -812,17 +812,6 @@ export enum HealthIndicatorId {
|
|||||||
worker = 'worker'
|
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 {
|
export enum IdentityProviderType {
|
||||||
OIDC = 'OIDC',
|
OIDC = 'OIDC',
|
||||||
SAML = 'SAML'
|
SAML = 'SAML'
|
||||||
@ -1254,6 +1243,7 @@ export type MutationGetLoginTokenFromCredentialsArgs = {
|
|||||||
|
|
||||||
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
|
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
|
||||||
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
email: Scalars['String']['input'];
|
||||||
emailVerificationToken: Scalars['String']['input'];
|
emailVerificationToken: Scalars['String']['input'];
|
||||||
origin: Scalars['String']['input'];
|
origin: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
@ -1552,7 +1542,7 @@ export type ObjectRecordFilterInput = {
|
|||||||
and?: InputMaybe<Array<ObjectRecordFilterInput>>;
|
and?: InputMaybe<Array<ObjectRecordFilterInput>>;
|
||||||
createdAt?: InputMaybe<DateFilter>;
|
createdAt?: InputMaybe<DateFilter>;
|
||||||
deletedAt?: InputMaybe<DateFilter>;
|
deletedAt?: InputMaybe<DateFilter>;
|
||||||
id?: InputMaybe<IdFilter>;
|
id?: InputMaybe<UuidFilter>;
|
||||||
not?: InputMaybe<ObjectRecordFilterInput>;
|
not?: InputMaybe<ObjectRecordFilterInput>;
|
||||||
or?: InputMaybe<Array<ObjectRecordFilterInput>>;
|
or?: InputMaybe<Array<ObjectRecordFilterInput>>;
|
||||||
updatedAt?: InputMaybe<DateFilter>;
|
updatedAt?: InputMaybe<DateFilter>;
|
||||||
@ -2317,6 +2307,17 @@ export type TransientToken = {
|
|||||||
transientToken: AuthToken;
|
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 = {
|
export type UuidFilterComparison = {
|
||||||
eq?: InputMaybe<Scalars['UUID']['input']>;
|
eq?: InputMaybe<Scalars['UUID']['input']>;
|
||||||
gt?: InputMaybe<Scalars['UUID']['input']>;
|
gt?: InputMaybe<Scalars['UUID']['input']>;
|
||||||
|
|||||||
@ -1129,6 +1129,7 @@ export type MutationGetLoginTokenFromCredentialsArgs = {
|
|||||||
|
|
||||||
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
|
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
|
||||||
captchaToken?: InputMaybe<Scalars['String']>;
|
captchaToken?: InputMaybe<Scalars['String']>;
|
||||||
|
email: Scalars['String'];
|
||||||
emailVerificationToken: Scalars['String'];
|
emailVerificationToken: Scalars['String'];
|
||||||
origin: Scalars['String'];
|
origin: Scalars['String'];
|
||||||
};
|
};
|
||||||
@ -2626,6 +2627,7 @@ export type GetLoginTokenFromCredentialsMutation = { __typename?: 'Mutation', ge
|
|||||||
|
|
||||||
export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
|
export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
|
||||||
emailVerificationToken: Scalars['String'];
|
emailVerificationToken: Scalars['String'];
|
||||||
|
email: Scalars['String'];
|
||||||
captchaToken?: InputMaybe<Scalars['String']>;
|
captchaToken?: InputMaybe<Scalars['String']>;
|
||||||
origin: Scalars['String'];
|
origin: Scalars['String'];
|
||||||
}>;
|
}>;
|
||||||
@ -2749,6 +2751,7 @@ export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typ
|
|||||||
export type SearchQueryVariables = Exact<{
|
export type SearchQueryVariables = Exact<{
|
||||||
searchInput: Scalars['String'];
|
searchInput: Scalars['String'];
|
||||||
limit: Scalars['Int'];
|
limit: Scalars['Int'];
|
||||||
|
after?: InputMaybe<Scalars['String']>;
|
||||||
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
|
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
|
||||||
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
|
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
|
||||||
filter?: InputMaybe<ObjectRecordFilterInput>;
|
filter?: InputMaybe<ObjectRecordFilterInput>;
|
||||||
@ -3885,9 +3888,10 @@ export type GetLoginTokenFromCredentialsMutationHookResult = ReturnType<typeof u
|
|||||||
export type GetLoginTokenFromCredentialsMutationResult = Apollo.MutationResult<GetLoginTokenFromCredentialsMutation>;
|
export type GetLoginTokenFromCredentialsMutationResult = Apollo.MutationResult<GetLoginTokenFromCredentialsMutation>;
|
||||||
export type GetLoginTokenFromCredentialsMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromCredentialsMutation, GetLoginTokenFromCredentialsMutationVariables>;
|
export type GetLoginTokenFromCredentialsMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromCredentialsMutation, GetLoginTokenFromCredentialsMutationVariables>;
|
||||||
export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
|
export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
|
||||||
mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $captchaToken: String, $origin: String!) {
|
mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $email: String!, $captchaToken: String, $origin: String!) {
|
||||||
getLoginTokenFromEmailVerificationToken(
|
getLoginTokenFromEmailVerificationToken(
|
||||||
emailVerificationToken: $emailVerificationToken
|
emailVerificationToken: $emailVerificationToken
|
||||||
|
email: $email
|
||||||
captchaToken: $captchaToken
|
captchaToken: $captchaToken
|
||||||
origin: $origin
|
origin: $origin
|
||||||
) {
|
) {
|
||||||
@ -3917,6 +3921,7 @@ export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationF
|
|||||||
* const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
|
* const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
|
||||||
* variables: {
|
* variables: {
|
||||||
* emailVerificationToken: // value for 'emailVerificationToken'
|
* emailVerificationToken: // value for 'emailVerificationToken'
|
||||||
|
* email: // value for 'email'
|
||||||
* captchaToken: // value for 'captchaToken'
|
* captchaToken: // value for 'captchaToken'
|
||||||
* origin: // value for 'origin'
|
* origin: // value for 'origin'
|
||||||
* },
|
* },
|
||||||
@ -4641,10 +4646,11 @@ export type GetClientConfigQueryHookResult = ReturnType<typeof useGetClientConfi
|
|||||||
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
|
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
|
||||||
export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
|
export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
|
||||||
export const SearchDocument = gql`
|
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(
|
search(
|
||||||
searchInput: $searchInput
|
searchInput: $searchInput
|
||||||
limit: $limit
|
limit: $limit
|
||||||
|
after: $after
|
||||||
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
||||||
includedObjectNameSingulars: $includedObjectNameSingulars
|
includedObjectNameSingulars: $includedObjectNameSingulars
|
||||||
filter: $filter
|
filter: $filter
|
||||||
@ -4682,6 +4688,7 @@ export const SearchDocument = gql`
|
|||||||
* variables: {
|
* variables: {
|
||||||
* searchInput: // value for 'searchInput'
|
* searchInput: // value for 'searchInput'
|
||||||
* limit: // value for 'limit'
|
* limit: // value for 'limit'
|
||||||
|
* after: // value for 'after'
|
||||||
* excludedObjectNameSingulars: // value for 'excludedObjectNameSingulars'
|
* excludedObjectNameSingulars: // value for 'excludedObjectNameSingulars'
|
||||||
* includedObjectNameSingulars: // value for 'includedObjectNameSingulars'
|
* includedObjectNameSingulars: // value for 'includedObjectNameSingulars'
|
||||||
* filter: // value for 'filter'
|
* filter: // value for 'filter'
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useAuth } from '@/auth/hooks/useAuth';
|
|||||||
import { AppPath } from '@/types/AppPath';
|
import { AppPath } from '@/types/AppPath';
|
||||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
import { ApolloError } from '@apollo/client';
|
||||||
|
|
||||||
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
|
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
|
||||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||||
@ -41,7 +42,10 @@ export const VerifyEmailEffect = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { loginToken, workspaceUrls } =
|
const { loginToken, workspaceUrls } =
|
||||||
await getLoginTokenFromEmailVerificationToken(emailVerificationToken);
|
await getLoginTokenFromEmailVerificationToken(
|
||||||
|
emailVerificationToken,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
|
||||||
enqueueSnackBar(t`Email verified.`, {
|
enqueueSnackBar(t`Email verified.`, {
|
||||||
dedupeKey: 'email-verification-dedupe-key',
|
dedupeKey: 'email-verification-dedupe-key',
|
||||||
@ -56,10 +60,23 @@ export const VerifyEmailEffect = () => {
|
|||||||
}
|
}
|
||||||
verifyLoginToken(loginToken.token);
|
verifyLoginToken(loginToken.token);
|
||||||
} catch (error) {
|
} 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',
|
dedupeKey: 'email-verification-error-dedupe-key',
|
||||||
variant: SnackBarVariant.Error,
|
variant: SnackBarVariant.Error,
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
error instanceof ApolloError &&
|
||||||
|
error.graphQLErrors[0].extensions?.subCode ===
|
||||||
|
'EMAIL_ALREADY_VERIFIED'
|
||||||
|
) {
|
||||||
|
navigate(AppPath.SignInUp);
|
||||||
|
}
|
||||||
|
|
||||||
setIsError(true);
|
setIsError(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import { gql } from '@apollo/client';
|
|||||||
export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
|
export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
|
||||||
mutation GetLoginTokenFromEmailVerificationToken(
|
mutation GetLoginTokenFromEmailVerificationToken(
|
||||||
$emailVerificationToken: String!
|
$emailVerificationToken: String!
|
||||||
|
$email: String!
|
||||||
$captchaToken: String
|
$captchaToken: String
|
||||||
$origin: String!
|
$origin: String!
|
||||||
) {
|
) {
|
||||||
getLoginTokenFromEmailVerificationToken(
|
getLoginTokenFromEmailVerificationToken(
|
||||||
emailVerificationToken: $emailVerificationToken
|
emailVerificationToken: $emailVerificationToken
|
||||||
|
email: $email
|
||||||
captchaToken: $captchaToken
|
captchaToken: $captchaToken
|
||||||
origin: $origin
|
origin: $origin
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -210,9 +210,14 @@ export const useAuth = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleGetLoginTokenFromEmailVerificationToken = useCallback(
|
const handleGetLoginTokenFromEmailVerificationToken = useCallback(
|
||||||
async (emailVerificationToken: string, captchaToken?: string) => {
|
async (
|
||||||
|
emailVerificationToken: string,
|
||||||
|
email: string,
|
||||||
|
captchaToken?: string,
|
||||||
|
) => {
|
||||||
const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
|
const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
|
||||||
variables: {
|
variables: {
|
||||||
|
email,
|
||||||
emailVerificationToken,
|
emailVerificationToken,
|
||||||
captchaToken,
|
captchaToken,
|
||||||
origin,
|
origin,
|
||||||
|
|||||||
@ -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 { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
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 { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.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 { 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';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ describe('AuthResolver', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
AuthResolver,
|
AuthResolver,
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(Workspace, 'core'),
|
provide: getRepositoryToken(AppToken, 'core'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -53,6 +53,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
|||||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-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 { 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 { 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 { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
|
||||||
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
|
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
|
||||||
@ -75,6 +76,8 @@ export class AuthResolver {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User, 'core')
|
@InjectRepository(User, 'core')
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(AppToken, 'core')
|
||||||
|
private readonly appTokenRepository: Repository<AppToken>,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private renewTokenService: RenewTokenService,
|
private renewTokenService: RenewTokenService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
@ -168,21 +171,24 @@ export class AuthResolver {
|
|||||||
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
|
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
|
||||||
@Args('origin') origin: string,
|
@Args('origin') origin: string,
|
||||||
) {
|
) {
|
||||||
const user =
|
const appToken =
|
||||||
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
|
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
|
||||||
getLoginTokenFromEmailVerificationTokenInput.emailVerificationToken,
|
getLoginTokenFromEmailVerificationTokenInput,
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspace =
|
const workspace =
|
||||||
(await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
(await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||||
origin,
|
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(
|
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||||
user.email,
|
appToken.user.email,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,11 @@ export class GetLoginTokenFromEmailVerificationTokenInput {
|
|||||||
@IsString()
|
@IsString()
|
||||||
emailVerificationToken: string;
|
emailVerificationToken: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -14,12 +14,14 @@ import {
|
|||||||
EmailVerificationExceptionCode,
|
EmailVerificationExceptionCode,
|
||||||
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
||||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
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';
|
import { EmailVerificationTokenService } from './email-verification-token.service';
|
||||||
|
|
||||||
describe('EmailVerificationTokenService', () => {
|
describe('EmailVerificationTokenService', () => {
|
||||||
let service: EmailVerificationTokenService;
|
let service: EmailVerificationTokenService;
|
||||||
let appTokenRepository: Repository<AppToken>;
|
let appTokenRepository: Repository<AppToken>;
|
||||||
|
let userRepository: Repository<User>;
|
||||||
let twentyConfigService: TwentyConfigService;
|
let twentyConfigService: TwentyConfigService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -30,6 +32,12 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
provide: getRepositoryToken(AppToken, 'core'),
|
provide: getRepositoryToken(AppToken, 'core'),
|
||||||
useClass: Repository,
|
useClass: Repository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(User, 'core'),
|
||||||
|
useValue: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: TwentyConfigService,
|
provide: TwentyConfigService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@ -45,6 +53,9 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
appTokenRepository = module.get<Repository<AppToken>>(
|
appTokenRepository = module.get<Repository<AppToken>>(
|
||||||
getRepositoryToken(AppToken, 'core'),
|
getRepositoryToken(AppToken, 'core'),
|
||||||
);
|
);
|
||||||
|
userRepository = module.get<Repository<User>>(
|
||||||
|
getRepositoryToken(User, 'core'),
|
||||||
|
);
|
||||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -92,14 +103,14 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(appTokenRepository, 'findOne')
|
.spyOn(appTokenRepository, 'findOne')
|
||||||
.mockResolvedValue(mockAppToken as AppToken);
|
.mockResolvedValue(mockAppToken as AppToken);
|
||||||
jest
|
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||||
.spyOn(appTokenRepository, 'remove')
|
|
||||||
.mockResolvedValue(mockAppToken as AppToken);
|
|
||||||
|
|
||||||
const result =
|
const result = await service.validateEmailVerificationTokenOrThrow({
|
||||||
await service.validateEmailVerificationTokenOrThrow(plainToken);
|
emailVerificationToken: plainToken,
|
||||||
|
email: 'test@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
expect(result).toEqual(mockAppToken);
|
||||||
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
|
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
value: hashedToken,
|
value: hashedToken,
|
||||||
@ -107,14 +118,17 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
},
|
},
|
||||||
relations: ['user'],
|
relations: ['user'],
|
||||||
});
|
});
|
||||||
expect(appTokenRepository.remove).toHaveBeenCalledWith(mockAppToken);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw exception for invalid token', async () => {
|
it('should throw exception for invalid token', async () => {
|
||||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||||
|
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.validateEmailVerificationTokenOrThrow('invalid-token'),
|
service.validateEmailVerificationTokenOrThrow({
|
||||||
|
emailVerificationToken: 'invalid-token',
|
||||||
|
email: 'test@twenty.com',
|
||||||
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
new EmailVerificationException(
|
new EmailVerificationException(
|
||||||
'Invalid email verification token',
|
'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 () => {
|
it('should throw exception for wrong token type', async () => {
|
||||||
const mockAppToken = {
|
const mockAppToken = {
|
||||||
type: AppTokenType.PasswordResetToken,
|
type: AppTokenType.PasswordResetToken,
|
||||||
@ -132,9 +203,13 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(appTokenRepository, 'findOne')
|
.spyOn(appTokenRepository, 'findOne')
|
||||||
.mockResolvedValue(mockAppToken as AppToken);
|
.mockResolvedValue(mockAppToken as AppToken);
|
||||||
|
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.validateEmailVerificationTokenOrThrow('wrong-type-token'),
|
service.validateEmailVerificationTokenOrThrow({
|
||||||
|
emailVerificationToken: 'wrong-type-token',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
new EmailVerificationException(
|
new EmailVerificationException(
|
||||||
'Invalid email verification token type',
|
'Invalid email verification token type',
|
||||||
@ -152,9 +227,13 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(appTokenRepository, 'findOne')
|
.spyOn(appTokenRepository, 'findOne')
|
||||||
.mockResolvedValue(mockAppToken as AppToken);
|
.mockResolvedValue(mockAppToken as AppToken);
|
||||||
|
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.validateEmailVerificationTokenOrThrow('expired-token'),
|
service.validateEmailVerificationTokenOrThrow({
|
||||||
|
emailVerificationToken: 'expired-token',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
new EmailVerificationException(
|
new EmailVerificationException(
|
||||||
'Email verification token expired',
|
'Email verification token expired',
|
||||||
@ -173,9 +252,13 @@ describe('EmailVerificationTokenService', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(appTokenRepository, 'findOne')
|
.spyOn(appTokenRepository, 'findOne')
|
||||||
.mockResolvedValue(mockAppToken as AppToken);
|
.mockResolvedValue(mockAppToken as AppToken);
|
||||||
|
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.validateEmailVerificationTokenOrThrow('valid-token'),
|
service.validateEmailVerificationTokenOrThrow({
|
||||||
|
emailVerificationToken: 'valid-token',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
new EmailVerificationException(
|
new EmailVerificationException(
|
||||||
'Email missing in email verification token context',
|
'Email missing in email verification token context',
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { addMilliseconds } from 'date-fns';
|
import { addMilliseconds } from 'date-fns';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@ -17,12 +18,15 @@ import {
|
|||||||
EmailVerificationExceptionCode,
|
EmailVerificationExceptionCode,
|
||||||
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
||||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||||
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailVerificationTokenService {
|
export class EmailVerificationTokenService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AppToken, 'core')
|
@InjectRepository(AppToken, 'core')
|
||||||
private readonly appTokenRepository: Repository<AppToken>,
|
private readonly appTokenRepository: Repository<AppToken>,
|
||||||
|
@InjectRepository(User, 'core')
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
private readonly twentyConfigService: TwentyConfigService,
|
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
|
const hashedToken = crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(emailVerificationToken)
|
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,16 @@ export class EmailVerificationExceptionFilter implements ExceptionFilter {
|
|||||||
case EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE:
|
case EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE:
|
||||||
case EmailVerificationExceptionCode.TOKEN_EXPIRED:
|
case EmailVerificationExceptionCode.TOKEN_EXPIRED:
|
||||||
case EmailVerificationExceptionCode.RATE_LIMIT_EXCEEDED:
|
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_MISSING:
|
||||||
case EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED:
|
case EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED:
|
||||||
|
case EmailVerificationExceptionCode.INVALID_EMAIL:
|
||||||
case EmailVerificationExceptionCode.EMAIL_VERIFICATION_NOT_REQUIRED:
|
case EmailVerificationExceptionCode.EMAIL_VERIFICATION_NOT_REQUIRED:
|
||||||
throw new UserInputError(exception.message);
|
throw new UserInputError(exception.message, {
|
||||||
|
subCode: exception.code,
|
||||||
|
});
|
||||||
default: {
|
default: {
|
||||||
const _exhaustiveCheck: never = exception.code;
|
const _exhaustiveCheck: never = exception.code;
|
||||||
|
|
||||||
|
|||||||
@ -14,5 +14,6 @@ export enum EmailVerificationExceptionCode {
|
|||||||
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
||||||
EMAIL_MISSING = 'EMAIL_MISSING',
|
EMAIL_MISSING = 'EMAIL_MISSING',
|
||||||
EMAIL_ALREADY_VERIFIED = 'EMAIL_ALREADY_VERIFIED',
|
EMAIL_ALREADY_VERIFIED = 'EMAIL_ALREADY_VERIFIED',
|
||||||
|
INVALID_EMAIL = 'INVALID_EMAIL',
|
||||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||||
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([AppToken], 'core'),
|
TypeOrmModule.forFeature([AppToken, User], 'core'),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
TwentyConfigModule,
|
TwentyConfigModule,
|
||||||
DomainManagerModule,
|
DomainManagerModule,
|
||||||
|
|||||||
@ -150,8 +150,9 @@ export class PersistedQueryNotSupportedError extends BaseGraphQLError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UserInputError extends BaseGraphQLError {
|
export class UserInputError extends BaseGraphQLError {
|
||||||
constructor(message: string) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
super(message, ErrorCode.BAD_USER_INPUT);
|
constructor(message: string, extensions?: Record<string, any>) {
|
||||||
|
super(message, ErrorCode.BAD_USER_INPUT, extensions);
|
||||||
|
|
||||||
Object.defineProperty(this, 'name', { value: 'UserInputError' });
|
Object.defineProperty(this, 'name', { value: 'UserInputError' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user