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:
@ -0,0 +1,11 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class ResendEmailVerificationTokenInput {
|
||||
@Field(() => String)
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
@ObjectType()
|
||||
export class ResendEmailVerificationTokenOutput {
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
success: boolean;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class EmailVerificationException extends CustomException {
|
||||
constructor(message: string, code: EmailVerificationExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum EmailVerificationExceptionCode {
|
||||
EMAIL_VERIFICATION_NOT_REQUIRED = 'EMAIL_VERIFICATION_NOT_REQUIRED',
|
||||
INVALID_TOKEN = 'INVALID_TOKEN',
|
||||
INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',
|
||||
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
||||
EMAIL_MISSING = 'EMAIL_MISSING',
|
||||
EMAIL_ALREADY_VERIFIED = 'EMAIL_ALREADY_VERIFIED',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { EmailVerificationResolver } from 'src/engine/core-modules/email-verification/email-verification.resolver';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AppToken], 'core'),
|
||||
EmailModule,
|
||||
EnvironmentModule,
|
||||
DomainManagerModule,
|
||||
UserModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
providers: [
|
||||
EmailVerificationService,
|
||||
EmailVerificationResolver,
|
||||
EmailVerificationTokenService,
|
||||
],
|
||||
exports: [EmailVerificationService, EmailVerificationTokenService],
|
||||
})
|
||||
export class EmailVerificationModule {}
|
||||
@ -0,0 +1,35 @@
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { ResendEmailVerificationTokenInput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input';
|
||||
import { ResendEmailVerificationTokenOutput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
|
||||
|
||||
@Resolver()
|
||||
export class EmailVerificationResolver {
|
||||
constructor(
|
||||
private readonly emailVerificationService: EmailVerificationService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => ResendEmailVerificationTokenOutput)
|
||||
async resendEmailVerificationToken(
|
||||
@Args()
|
||||
resendEmailVerificationTokenInput: ResendEmailVerificationTokenInput,
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<ResendEmailVerificationTokenOutput> {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
);
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
return await this.emailVerificationService.resendEmailVerificationToken(
|
||||
resendEmailVerificationTokenInput.email,
|
||||
workspace.subdomain,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import {
|
||||
EmailVerificationException,
|
||||
EmailVerificationExceptionCode,
|
||||
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class EmailVerificationService {
|
||||
constructor(
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly userService: UserService,
|
||||
private readonly emailVerificationTokenService: EmailVerificationTokenService,
|
||||
) {}
|
||||
|
||||
async sendVerificationEmail(
|
||||
userId: string,
|
||||
email: string,
|
||||
workspaceSubdomain?: string,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const { token: emailVerificationToken } =
|
||||
await this.emailVerificationTokenService.generateToken(userId, email);
|
||||
|
||||
const verificationLink =
|
||||
this.domainManagerService.buildEmailVerificationURL({
|
||||
emailVerificationToken,
|
||||
email,
|
||||
workspaceSubdomain,
|
||||
});
|
||||
|
||||
const emailData = {
|
||||
link: verificationLink.toString(),
|
||||
};
|
||||
|
||||
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
|
||||
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
subject: 'Welcome to Twenty: Please Confirm Your Email',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async resendEmailVerificationToken(
|
||||
email: string,
|
||||
workspaceSubdomain?: string,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
throw new EmailVerificationException(
|
||||
'Email verification token cannot be sent because email verification is not required',
|
||||
EmailVerificationExceptionCode.EMAIL_VERIFICATION_NOT_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userService.getUserByEmail(email);
|
||||
|
||||
if (user.isEmailVerified) {
|
||||
throw new EmailVerificationException(
|
||||
'Email already verified',
|
||||
EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED,
|
||||
);
|
||||
}
|
||||
|
||||
const existingToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: AppTokenType.EmailVerificationToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
const cooldownDuration = ms('1m');
|
||||
const timeToWaitMs = differenceInMilliseconds(
|
||||
addMilliseconds(existingToken.createdAt, cooldownDuration),
|
||||
new Date(),
|
||||
);
|
||||
|
||||
if (timeToWaitMs > 0) {
|
||||
throw new EmailVerificationException(
|
||||
`Please wait ${ms(timeToWaitMs, { long: true })} before requesting another verification email`,
|
||||
EmailVerificationExceptionCode.RATE_LIMIT_EXCEEDED,
|
||||
);
|
||||
}
|
||||
|
||||
await this.appTokenRepository.delete(existingToken.id);
|
||||
}
|
||||
|
||||
await this.sendVerificationEmail(user.id, email, workspaceSubdomain);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user