Add Email Verification for non-Microsoft/Google Emails (#9288)
Closes twentyhq/twenty#8240 This PR introduces email verification for non-Microsoft/Google Emails: ## Email Verification SignInUp Flow: https://github.com/user-attachments/assets/740e9714-5413-4fd8-b02e-ace728ea47ef The email verification link is sent as part of the `SignInUpStep.EmailVerification`. The email verification token validation is handled on a separate page (`AppPath.VerifyEmail`). A verification email resend can be triggered from both pages. ## Email Verification Flow Screenshots (In Order):    ## Sent Email Details (Subject & Template):   ### Successful Email Verification Redirect:  ### Unsuccessful Email Verification (invalid token, invalid email, token expired, user does not exist, etc.):  ### Force Sign In When Email Not Verified:  # TODOs: ## Sign Up Process - [x] Introduce server-level environment variable IS_EMAIL_VERIFICATION_REQUIRED (defaults to false) - [x] Ensure users joining an existing workspace through an invite are not required to validate their email - [x] Generate an email verification token - [x] Store the token in appToken - [x] Send email containing the verification link - [x] Create new email template for email verification - [x] Create a frontend page to handle verification requests ## Sign In Process - [x] After verifying user credentials, check if user's email is verified and prompt to to verify - [x] Show an option to resend the verification email ## Database - [x] Rename the `emailVerified` colum on `user` to to `isEmailVerified` for consistency ## During Deployment - [x] Run a script/sql query to set `isEmailVerified` to `true` for all users with a Google/Microsoft email and all users that show an indication of a valid subscription (e.g. linked credit card) - I have created a draft migration file below that shows one possible approach to implementing this change: ```typescript import { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateEmailVerifiedForActiveUsers1733318043628 implements MigrationInterface { name = 'UpdateEmailVerifiedForActiveUsers1733318043628'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` CREATE TABLE core."user_email_verified_backup" AS SELECT id, email, "isEmailVerified" FROM core."user" WHERE "deletedAt" IS NULL; `); await queryRunner.query(` -- Update isEmailVerified for users who have been part of workspaces with active subscriptions UPDATE core."user" u SET "isEmailVerified" = true WHERE EXISTS ( -- Check if user has been part of a workspace through userWorkspace table SELECT 1 FROM core."userWorkspace" uw JOIN core."workspace" w ON uw."workspaceId" = w.id WHERE uw."userId" = u.id -- Check for valid subscription indicators AND ( w."activationStatus" = 'ACTIVE' -- Add any other subscription-related conditions here ) ) AND u."deletedAt" IS NULL; `); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` UPDATE core."user" u SET "isEmailVerified" = b."isEmailVerified" FROM core."user_email_verified_backup" b WHERE u.id = b.id; `); await queryRunner.query(`DROP TABLE core."user_email_verified_backup";`); } } ``` --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -9,6 +9,7 @@ export class AuthException extends CustomException {
|
||||
|
||||
export enum AuthExceptionCode {
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
|
||||
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
|
||||
@ -25,6 +25,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
@ -80,6 +81,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
WorkspaceSSOModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceInvitationModule,
|
||||
EmailVerificationModule,
|
||||
],
|
||||
controllers: [
|
||||
GoogleAuthController,
|
||||
|
||||
@ -3,11 +3,12 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@ -16,6 +17,7 @@ import { AuthService } from './services/auth.service';
|
||||
// import { OAuthService } from './services/oauth.service';
|
||||
import { ResetPasswordService } from './services/reset-password.service';
|
||||
import { SwitchWorkspaceService } from './services/switch-workspace.service';
|
||||
import { EmailVerificationTokenService } from './token/services/email-verification-token.service';
|
||||
import { LoginTokenService } from './token/services/login-token.service';
|
||||
import { RenewTokenService } from './token/services/renew-token.service';
|
||||
import { TransientTokenService } from './token/services/transient-token.service';
|
||||
@ -80,6 +82,14 @@ describe('AuthResolver', () => {
|
||||
provide: TransientTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EmailVerificationService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EmailVerificationTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
// {
|
||||
// provide: OAuthService,
|
||||
// useValue: {},
|
||||
|
||||
@ -18,30 +18,34 @@ import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dt
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
|
||||
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
|
||||
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
|
||||
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||
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 { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
|
||||
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
|
||||
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
|
||||
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
|
||||
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
|
||||
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
|
||||
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.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 { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { ChallengeInput } from './dto/challenge.input';
|
||||
import { LoginToken } from './dto/login-token.entity';
|
||||
@ -68,8 +72,11 @@ export class AuthResolver {
|
||||
private loginTokenService: LoginTokenService,
|
||||
private switchWorkspaceService: SwitchWorkspaceService,
|
||||
private transientTokenService: TransientTokenService,
|
||||
private emailVerificationService: EmailVerificationService,
|
||||
// private oauthService: OAuthService,
|
||||
private domainManagerService: DomainManagerService,
|
||||
private userWorkspaceService: UserWorkspaceService,
|
||||
private emailVerificationTokenService: EmailVerificationTokenService,
|
||||
) {}
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@ -116,7 +123,6 @@ export class AuthResolver {
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const user = await this.authService.challenge(challengeInput, workspace);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
@ -126,6 +132,41 @@ export class AuthResolver {
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@Mutation(() => LoginToken)
|
||||
async getLoginTokenFromEmailVerificationToken(
|
||||
@Args()
|
||||
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
|
||||
@OriginHeader() origin: string,
|
||||
) {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
);
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const user =
|
||||
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
|
||||
getLoginTokenFromEmailVerificationTokenInput.emailVerificationToken,
|
||||
);
|
||||
|
||||
await this.userService.markEmailAsVerified(user.id);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@Mutation(() => SignUpOutput)
|
||||
async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> {
|
||||
@ -170,6 +211,12 @@ export class AuthResolver {
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailVerificationService.sendVerificationEmail(
|
||||
user.id,
|
||||
user.email,
|
||||
workspace.subdomain,
|
||||
);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
@ -333,6 +380,6 @@ export class AuthResolver {
|
||||
async findAvailableWorkspacesByEmail(
|
||||
@Args('email') email: string,
|
||||
): Promise<AvailableWorkspaceOutput[]> {
|
||||
return this.authService.findAvailableWorkspacesByEmail(email);
|
||||
return this.userWorkspaceService.findAvailableWorkspacesByEmail(email);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class GetLoginTokenFromEmailVerificationTokenInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
emailVerificationToken: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
captchaToken?: string;
|
||||
}
|
||||
@ -9,6 +9,9 @@ export class UserExists {
|
||||
|
||||
@Field(() => [AvailableWorkspaceOutput])
|
||||
availableWorkspaces: Array<AvailableWorkspaceOutput>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailVerified: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import {
|
||||
AuthenticationError,
|
||||
EmailNotVerifiedError,
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
@ -20,6 +21,8 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
|
||||
throw new NotFoundError(exception.message);
|
||||
case AuthExceptionCode.INVALID_INPUT:
|
||||
throw new UserInputError(exception.message);
|
||||
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
|
||||
throw new EmailNotVerifiedError(exception.message);
|
||||
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
|
||||
throw new ForbiddenError(exception.message);
|
||||
case AuthExceptionCode.UNAUTHENTICATED:
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import { expect, jest } from '@jest/globals';
|
||||
import { Repository } from 'typeorm';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@ -31,9 +31,10 @@ const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
|
||||
const workspaceInvitationValidateInvitationMock = jest.fn();
|
||||
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
|
||||
|
||||
const environmentServiceGetMock = jest.fn();
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@ -66,7 +67,9 @@ describe('AuthService', () => {
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
useValue: {
|
||||
get: environmentServiceGetMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
@ -112,10 +115,10 @@ describe('AuthService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
appTokenRepository = module.get<Repository<AppToken>>(
|
||||
getRepositoryToken(AppToken, 'core'),
|
||||
);
|
||||
beforeEach(() => {
|
||||
environmentServiceGetMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should be defined', async () => {
|
||||
|
||||
@ -26,7 +26,6 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
|
||||
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
|
||||
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
|
||||
@ -157,6 +156,17 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
const isEmailVerificationRequired = this.environmentService.get(
|
||||
'IS_EMAIL_VERIFICATION_REQUIRED',
|
||||
);
|
||||
|
||||
if (isEmailVerificationRequired && !user.isEmailVerified) {
|
||||
throw new AuthException(
|
||||
'Email is not verified',
|
||||
AuthExceptionCode.EMAIL_NOT_VERIFIED,
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@ -260,7 +270,9 @@ export class AuthService {
|
||||
if (userValidator.isDefined(user)) {
|
||||
return {
|
||||
exists: true,
|
||||
availableWorkspaces: await this.findAvailableWorkspacesByEmail(email),
|
||||
availableWorkspaces:
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
|
||||
isEmailVerified: user.isEmailVerified,
|
||||
};
|
||||
}
|
||||
|
||||
@ -463,48 +475,6 @@ export class AuthService {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async findAvailableWorkspacesByEmail(email: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
relations: [
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceSSOIdentityProviders',
|
||||
],
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
|
||||
id: userWorkspace.workspaceId,
|
||||
displayName: userWorkspace.workspace.displayName,
|
||||
subdomain: userWorkspace.workspace.subdomain,
|
||||
logo: userWorkspace.workspace.logo,
|
||||
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
|
||||
(acc, identityProvider) =>
|
||||
acc.concat(
|
||||
identityProvider.status === 'Inactive'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: identityProvider.id,
|
||||
name: identityProvider.name,
|
||||
issuer: identityProvider.issuer,
|
||||
type: identityProvider.type,
|
||||
status: identityProvider.status,
|
||||
},
|
||||
],
|
||||
),
|
||||
[] as AvailableWorkspaceOutput['sso'],
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async findInvitationForSignInUp({
|
||||
currentWorkspace,
|
||||
workspacePersonalInviteToken,
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
WorkspaceActivationStatus,
|
||||
} from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
jest.mock('src/utils/image', () => {
|
||||
return {
|
||||
@ -36,8 +37,6 @@ describe('SignInUpService', () => {
|
||||
let fileUploadService: FileUploadService;
|
||||
let workspaceInvitationService: WorkspaceInvitationService;
|
||||
let userWorkspaceService: UserWorkspaceService;
|
||||
let onboardingService: OnboardingService;
|
||||
let httpService: HttpService;
|
||||
let environmentService: EnvironmentService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
|
||||
@ -98,6 +97,16 @@ describe('SignInUpService', () => {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {
|
||||
markEmailAsVerified: jest.fn().mockReturnValue({
|
||||
id: 'test-user-id',
|
||||
email: 'test@test.com',
|
||||
isEmailVerified: true,
|
||||
} as User),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
@ -116,8 +125,6 @@ describe('SignInUpService', () => {
|
||||
);
|
||||
userWorkspaceService =
|
||||
module.get<UserWorkspaceService>(UserWorkspaceService);
|
||||
onboardingService = module.get<OnboardingService>(OnboardingService);
|
||||
httpService = module.get<HttpService>(HttpService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
SignInUpBaseParams,
|
||||
SignInUpNewUserPayload,
|
||||
} from 'src/engine/core-modules/auth/types/signInUp.type';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -57,6 +58,7 @@ export class SignInUpService {
|
||||
private readonly httpService: HttpService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
async computeParamsForNewUser(
|
||||
@ -194,6 +196,8 @@ export class SignInUpService {
|
||||
email,
|
||||
);
|
||||
|
||||
await this.userService.markEmailAsVerified(updatedUser.id);
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
EmailVerificationException,
|
||||
EmailVerificationExceptionCode,
|
||||
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
import { EmailVerificationTokenService } from './email-verification-token.service';
|
||||
|
||||
describe('EmailVerificationTokenService', () => {
|
||||
let service: EmailVerificationTokenService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailVerificationTokenService,
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EmailVerificationTokenService>(
|
||||
EmailVerificationTokenService,
|
||||
);
|
||||
appTokenRepository = module.get<Repository<AppToken>>(
|
||||
getRepositoryToken(AppToken, 'core'),
|
||||
);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('should generate a verification token successfully', async () => {
|
||||
const userId = 'test-user-id';
|
||||
const email = 'test@example.com';
|
||||
const mockExpiresIn = '24h';
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
|
||||
jest.spyOn(appTokenRepository, 'create').mockReturnValue({} as AppToken);
|
||||
jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
|
||||
|
||||
const result = await service.generateToken(userId, email);
|
||||
|
||||
expect(result).toHaveProperty('token');
|
||||
expect(result).toHaveProperty('expiresAt');
|
||||
expect(result.token).toHaveLength(64); // 32 bytes in hex = 64 characters
|
||||
expect(appTokenRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
context: { email },
|
||||
}),
|
||||
);
|
||||
expect(appTokenRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEmailVerificationTokenOrThrow', () => {
|
||||
it('should validate token successfully and return user', async () => {
|
||||
const plainToken = 'plain-token';
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(plainToken)
|
||||
.digest('hex');
|
||||
const mockUser = { id: 'user-id', email: 'test@example.com' };
|
||||
const mockAppToken = {
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
expiresAt: new Date(Date.now() + 86400000), // 24h from now
|
||||
context: { email: 'test@example.com' },
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockAppToken as AppToken);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'remove')
|
||||
.mockResolvedValue(mockAppToken as AppToken);
|
||||
|
||||
const result =
|
||||
await service.validateEmailVerificationTokenOrThrow(plainToken);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
value: hashedToken,
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
expect(appTokenRepository.remove).toHaveBeenCalledWith(mockAppToken);
|
||||
});
|
||||
|
||||
it('should throw exception for invalid token', async () => {
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validateEmailVerificationTokenOrThrow('invalid-token'),
|
||||
).rejects.toThrow(
|
||||
new EmailVerificationException(
|
||||
'Invalid email verification token',
|
||||
EmailVerificationExceptionCode.INVALID_TOKEN,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception for wrong token type', async () => {
|
||||
const mockAppToken = {
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: new Date(Date.now() + 86400000),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockAppToken as AppToken);
|
||||
|
||||
await expect(
|
||||
service.validateEmailVerificationTokenOrThrow('wrong-type-token'),
|
||||
).rejects.toThrow(
|
||||
new EmailVerificationException(
|
||||
'Invalid email verification token type',
|
||||
EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception for expired token', async () => {
|
||||
const mockAppToken = {
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
expiresAt: new Date(Date.now() - 86400000), // 24h ago
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockAppToken as AppToken);
|
||||
|
||||
await expect(
|
||||
service.validateEmailVerificationTokenOrThrow('expired-token'),
|
||||
).rejects.toThrow(
|
||||
new EmailVerificationException(
|
||||
'Email verification token expired',
|
||||
EmailVerificationExceptionCode.TOKEN_EXPIRED,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when email is missing in context', async () => {
|
||||
const mockAppToken = {
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
expiresAt: new Date(Date.now() + 86400000),
|
||||
context: {},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockAppToken as AppToken);
|
||||
|
||||
await expect(
|
||||
service.validateEmailVerificationTokenOrThrow('valid-token'),
|
||||
).rejects.toThrow(
|
||||
new EmailVerificationException(
|
||||
'Email missing in email verification token context',
|
||||
EmailVerificationExceptionCode.EMAIL_MISSING,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import {
|
||||
EmailVerificationException,
|
||||
EmailVerificationExceptionCode,
|
||||
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class EmailVerificationTokenService {
|
||||
constructor(
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async generateToken(userId: string, email: string): Promise<AuthToken> {
|
||||
const expiresIn = this.environmentService.get(
|
||||
'EMAIL_VERIFICATION_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const plainToken = crypto.randomBytes(32).toString('hex');
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(plainToken)
|
||||
.digest('hex');
|
||||
|
||||
const verificationToken = this.appTokenRepository.create({
|
||||
userId,
|
||||
expiresAt,
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
value: hashedToken,
|
||||
context: { email },
|
||||
});
|
||||
|
||||
await this.appTokenRepository.save(verificationToken);
|
||||
|
||||
return {
|
||||
token: plainToken,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async validateEmailVerificationTokenOrThrow(emailVerificationToken: string) {
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(emailVerificationToken)
|
||||
.digest('hex');
|
||||
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: hashedToken,
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!appToken) {
|
||||
throw new EmailVerificationException(
|
||||
'Invalid email verification token',
|
||||
EmailVerificationExceptionCode.INVALID_TOKEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (appToken.type !== AppTokenType.EmailVerificationToken) {
|
||||
throw new EmailVerificationException(
|
||||
'Invalid email verification token type',
|
||||
EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
if (new Date() > appToken.expiresAt) {
|
||||
throw new EmailVerificationException(
|
||||
'Email verification token expired',
|
||||
EmailVerificationExceptionCode.TOKEN_EXPIRED,
|
||||
);
|
||||
}
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new EmailVerificationException(
|
||||
'Email missing in email verification token context',
|
||||
EmailVerificationExceptionCode.EMAIL_MISSING,
|
||||
);
|
||||
}
|
||||
|
||||
await this.appTokenRepository.remove(appToken);
|
||||
|
||||
return appToken.user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user