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):

![image](https://github.com/user-attachments/assets/d52237dc-fcc6-4754-a40f-b7d6294eebad)

![image](https://github.com/user-attachments/assets/263a4b6b-db49-406b-9e43-6c0f90488bb8)

![image](https://github.com/user-attachments/assets/0343ae51-32ef-48b8-8167-a96deb7db99e)

## Sent Email Details (Subject & Template):
![Screenshot 2025-01-05 at 11 56
56 PM](https://github.com/user-attachments/assets/475840d1-7d47-4792-b8c6-5c9ef5e02229)

![image](https://github.com/user-attachments/assets/a41b3b36-a36f-4a8e-b1f9-beeec7fe23e4)

### Successful Email Verification Redirect:

![image](https://github.com/user-attachments/assets/e2fad9e2-f4b1-485e-8f4a-32163c2718e7)

### Unsuccessful Email Verification (invalid token, invalid email, token
expired, user does not exist, etc.):

![image](https://github.com/user-attachments/assets/92f4b65e-2971-4f26-a9fa-7aafadd2b305)

### Force Sign In When Email Not Verified:

![image](https://github.com/user-attachments/assets/86d0f188-cded-49a6-bde9-9630fd18d71e)

# 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:
Samyak Piya
2025-01-15 12:43:40 -05:00
committed by GitHub
parent 266b771a5b
commit f722a2d619
61 changed files with 1460 additions and 171 deletions

View File

@ -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',

View File

@ -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,

View File

@ -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: {},

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -9,6 +9,9 @@ export class UserExists {
@Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>;
@Field(() => Boolean)
isEmailVerified: boolean;
}
@ObjectType()

View File

@ -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:

View File

@ -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 () => {

View File

@ -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,

View File

@ -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);

View File

@ -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;
}

View File

@ -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,
),
);
});
});
});

View File

@ -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;
}
}