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>
116 lines
3.5 KiB
TypeScript
116 lines
3.5 KiB
TypeScript
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
|
|
import { NextFunction, Request, Response } from 'express';
|
|
import { ExtractJwt } from 'passport-jwt';
|
|
|
|
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
|
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
|
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
|
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
|
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
|
class GraphqlTokenValidationProxy {
|
|
private accessTokenService: AccessTokenService;
|
|
|
|
constructor(accessTokenService: AccessTokenService) {
|
|
this.accessTokenService = accessTokenService;
|
|
}
|
|
|
|
async validateToken(req: Request) {
|
|
try {
|
|
return await this.accessTokenService.validateTokenByRequest(req);
|
|
} catch (error) {
|
|
const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter();
|
|
|
|
throw authGraphqlApiExceptionFilter.catch(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
@Injectable()
|
|
export class GraphQLHydrateRequestFromTokenMiddleware
|
|
implements NestMiddleware
|
|
{
|
|
constructor(
|
|
private readonly accessTokenService: AccessTokenService,
|
|
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
|
|
private readonly exceptionHandlerService: ExceptionHandlerService,
|
|
) {}
|
|
|
|
async use(req: Request, res: Response, next: NextFunction) {
|
|
const body = req.body;
|
|
|
|
const excludedOperations = [
|
|
'GetClientConfig',
|
|
'GetCurrentUser',
|
|
'GetWorkspaceFromInviteHash',
|
|
'Track',
|
|
'CheckUserExists',
|
|
'Challenge',
|
|
'Verify',
|
|
'GetLoginTokenFromEmailVerificationToken',
|
|
'ResendEmailVerificationToken',
|
|
'SignUp',
|
|
'RenewToken',
|
|
'EmailPasswordResetLink',
|
|
'ValidatePasswordResetToken',
|
|
'UpdatePasswordViaResetToken',
|
|
'IntrospectionQuery',
|
|
'ExchangeAuthorizationCode',
|
|
'GetAuthorizationUrl',
|
|
'GetPublicWorkspaceDataBySubdomain',
|
|
];
|
|
|
|
if (
|
|
!this.isTokenPresent(req) &&
|
|
(!body?.operationName || excludedOperations.includes(body.operationName))
|
|
) {
|
|
return next();
|
|
}
|
|
|
|
let data: AuthContext;
|
|
|
|
try {
|
|
const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy(
|
|
this.accessTokenService,
|
|
);
|
|
|
|
data = await graphqlTokenValidationProxy.validateToken(req);
|
|
const metadataVersion =
|
|
await this.workspaceStorageCacheService.getMetadataVersion(
|
|
data.workspace.id,
|
|
);
|
|
|
|
req.user = data.user;
|
|
req.apiKey = data.apiKey;
|
|
req.workspace = data.workspace;
|
|
req.workspaceId = data.workspace.id;
|
|
req.workspaceMetadataVersion = metadataVersion;
|
|
req.workspaceMemberId = data.workspaceMemberId;
|
|
} catch (error) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.write(
|
|
JSON.stringify({
|
|
errors: [
|
|
handleExceptionAndConvertToGraphQLError(
|
|
error,
|
|
this.exceptionHandlerService,
|
|
),
|
|
],
|
|
}),
|
|
);
|
|
res.end();
|
|
|
|
return;
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
isTokenPresent(request: Request): boolean {
|
|
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
|
|
|
return !!token;
|
|
}
|
|
}
|