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

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

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean } from 'class-validator';
@ObjectType()
export class ResendEmailVerificationTokenOutput {
@IsBoolean()
@Field(() => Boolean)
success: boolean;
}

View File

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

View File

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

View File

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

View File

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