Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Email Verification for non-Microsoft/Google Emails #9288

Open
wants to merge 46 commits into
base: main
Choose a base branch
from

Conversation

samyakpiya
Copy link
Contributor

@samyakpiya samyakpiya commented Dec 31, 2024

Closes #8240

This PR introduces email verification for non-Microsoft/Google Emails:

Email Verification SignInUp Flow:

Sign.in.or.Create.an.account.1.mp4

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
image
image

Sent Email Details (Subject & Template):

Screenshot 2025-01-05 at 11 56 56 PM
image

Successful Email Verification Redirect:

image

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

image

Force Sign In When Email Not Verified:

image

TODOs:

Sign Up Process

  • Introduce server-level environment variable IS_EMAIL_VERIFICATION_REQUIRED (defaults to false)
  • Ensure users joining an existing workspace through an invite are not required to validate their email
  • Generate an email verification token
  • Store the token in appToken
  • Send email containing the verification link
    • Create new email template for email verification
  • Create a frontend page to handle verification requests

Sign In Process

  • After verifying user credentials, check if user's email is verified and prompt to to verify
  • Show an option to resend the verification email

Database

  • Rename the emailVerified colum on user to to isEmailVerified for consistency

During Deployment

  • 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:
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";`);
  }
}

Copy link

github-actions bot commented Dec 31, 2024

Warnings
⚠️ Changes were made to the environment variables, but not to the documentation - Please review your changes and check if a change needs to be documented!

TODOs/FIXMEs:

  • // TODO: Get intellisense for graphql error extensions code (codegen?): packages/twenty-front/src/modules/auth/hooks/useAuth.ts

Generated by 🚫 dangerJS against bbffad8

- Add token generation for email verification
- Store verification tokens in appToken
- Implement verification email sending
- Add verification status check post-login
- Display verification prompt for unverified users
- Handle email verification flow in auth process
…Service to prevent circular dependency issue
@samyakpiya samyakpiya marked this pull request as ready for review January 4, 2025 15:17
@samyakpiya
Copy link
Contributor Author

@FelixMalfait I spent way too long trying to figure out why the frontend couldn't make a mutation request without an auth token. Turns out there was middleware in place even though no auth guards were on the resolver. Probably should’ve just asked someone, lol.

Still need to rename the column to isEmailVerified, but other than that, it’s ready for review!

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Summary

This PR implements a comprehensive email verification system for non-Microsoft/Google email signups, with configurable settings and secure token handling.

  • Added new SendEmailVerificationLinkEmail template with verification link and standardized footer component
  • Implemented secure token generation and verification flow in EmailVerificationService with proper expiration handling
  • Added frontend verification page and UI components for handling verification states and resending tokens
  • Created GraphQL mutations/queries for email verification with proper DTOs and exception handling
  • Added environment variables IS_EMAIL_VERIFICATION_REQUIRED and EMAIL_VERIFICATION_TOKEN_EXPIRES_IN for configuration

44 file(s) reviewed, 34 comment(s)
Edit PR Review Bot Settings | Greptile

Comment on lines +4 to +7
mutation ResendEmailVerificationToken($email: String!) {
resendEmailVerificationToken(email: $email) {
success
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: mutation returns only success status - consider returning additional fields like expiresAt or emailSent for better UX feedback

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might need the design for the modal that prompts user to verify email in general, but also if we want to include additional information like when the token expires.

@FelixMalfait
Copy link
Member

Wow, big piece!!! Thanks a lot!

@samyakpiya
Copy link
Contributor Author

Sure thing! I didn’t realize how much work this PR would entail when I started, but it was a great opportunity to learn more about the codebase. Let me know if I can provide any additional context to make reviewing easier.

As a follow-up, I’m considering redesigning the email verification sign-up flow. Currently, /verify-email handles both email sending and validating using searchParams, but I’m thinking of shifting the initial email send to a new added SignInUpStep of type EmailVerification on the /welcome page, leaving /verify-email to focus solely on token validation.

After generating a migration to rename emailVerified to isEmailVerified and updating the GraphQL schema for twenty-front, I noticed a mismatch in twenty-chrome-extension. I regenerated its GraphQL schema and found several outdated mutation definitions that might need a new PR to fix.

- Move email verification link sending from /verify-email to SignInUp page
- Add EmailVerification step to SignInUp flow
- Simplify VerifyEmail component to only handle token validation
- Update auth hooks and states to support new verification flow
- Extract email verification UI into separate components
- Consolidate email verification UI into EmailVerificationSent component
- Add direct navigation to sign-in after successful verification
- Remove redundant EmailVerificationStatus component
- Add loading state to resend verification token hook
- Improve error handling and feedback messages
Handle edge cases for workspace display name in the sign-in page:
- Show "Your Workspace" when displayName is an empty string
- Show default workspace name when displayName is undefined
- Show actual displayName in all other cases
@samyakpiya
Copy link
Contributor Author

@FelixMalfait I've redesigned the email verification flow since my last comment and also made all the necessary changes to handle most of the edge cases and improve UX. Please refer to the PR description for the updated video recording and screenshots. Thanks!

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why handle this as an error? If email verification isn't require it should just pass silently no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frontend uses the same env to conditionally invoke the mutation that leads to sendVerificationEmail(). This made sense to me as it would avoid unnecessary calls to the backend when IS_EMAIL_VERIFCATION_REQUIRED is false.

To answer your question, I am throwing this here and in some other methods because I thought it might confuse the users if, for instance, they're trying to verify their email or resend the email verification token but it seems like it's not working (cause it's passing silently) and they don't realize the env has been reset or not set to true in the first place.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see! Thanks

The frontend uses the same env to conditionally invoke the mutation that leads to sendVerificationEmail().

This is not something that you would typically do on the frontend. If you have doubts, usually the more goes in the backend the better! (because tomorrow their might be a mobile app or other frontends).

Make sure that this error isn't triggered for someone who have IS_EMAIL_VERIFCATION_REQUIRED set to false. I haven't pulled the code yet (will do), but when @Bonapara did he saw this exception (with IS_EMAIL_VERIFCATION_REQUIRED not set initially)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on when @Bonapara pulled the code, that might have thrown an error. It should work now though if you pull the latest changes. Let me know if you're still seeing the exception.

<br />
San Francisco, CA 94114
</ShadowText>
<Footer />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. We should probably add this to every email as a BaseFooter like the base header?

Copy link
Contributor Author

@samyakpiya samyakpiya Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some emails (as seen in Figma) that don't have footer links in them:

Screenshot 2025-01-06 at 10 19 36 AM

However, the Footer is already being used in all the places it was before as it's there as a default in WhatIsTwenty. Should I extract Footer outside of it and add Footer after all the places WhatIsTwenty was being used?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. But I think we'll want a footer on every emails for consistency (e.g. one day we'll need to add "manage your preferences" button). We might edit the footer in a followup issue to make it smaller and better suited for all emails, but let's include it in all emails for now

cc @Bonapara let me know if you disagree

@@ -229,6 +231,8 @@ export class SignInUpService {
email,
);

await this.userService.markEmailAsVerified(updatedUser.id);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now we don't force the user who signs up with an invitation to use the same email their were invited on so technically we can't be 100% sure that email is verified, unless we force signing up with the same email as in the invitation. I'm not sure if it's worth doing though...

Copy link
Contributor Author

@samyakpiya samyakpiya Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually do enforce that:

if (!appToken.context?.email || appToken.context?.email !== email) {

I verified by sending an invite to [email protected] and changing the sign up email to [email protected] and [email protected]. We get back an error:

image

@@ -48,6 +48,8 @@ export class GraphQLHydrateRequestFromTokenMiddleware
'CheckUserExists',
'Challenge',
'Verify',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like how we named this Verify function, it's unclear what it does. Could we rename it? GetAuthTokensFromLoginToken?
Also Challenge could be GetLoginTokenFromCredentials or something like that.

Sorry this isn't directly related to your changes but always good to use this as opportunities to improve the API :). Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem! It's wasn't intuitive what Verify and Challenge to me either when I discovered it. I'll make this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also rename the corresponding frontend functions (challenge, verify, verifyEmail)?

return {
    challenge: handleChallenge,
    verify: handleVerify,
    verifyEmail: handleVerifyEmail,

    loadCurrentUser,

    checkUserExists: { checkUserExistsData, checkUserExistsQuery },
    clearSession,
    signOut: handleSignOut,
    signUpWithCredentials: handleCredentialsSignUp,
    signInWithCredentials: handleCrendentialsSignIn,
    signInWithGoogle: handleGoogleLogin,
    signInWithMicrosoft: handleMicrosoftLogin,
  };

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, can I open up a new issue for this and have myself assigned? This PR is getting too big.

async (loginToken: string) => {
async (loginToken: string, email: string) => {
if (isEmailVerificationRequired) {
const maybeUser = await checkUserExistsQuery({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this endpoint is protected by captcha guard

Copy link
Contributor Author

@samyakpiya samyakpiya Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a problem? My understanding based on this snippet is that:

https://github.com/twentyhq/twenty/blob/b22a598d7d1901b940ad91be47358f696d31abf9/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx#L155C1-L164C77

since the handleVerify only ever gets called from the AppPath.SignInUp page, the requests made will have a valid captcha token. Is that correct?

I was actually thinking of replacing the checkUserExistsQuery with something like checkUserEmailVerified, but decided to return the emailVerified attribute in checkUserExistsQuery itself to reuse what was already existing. Do you think its okay to introduce checkUserEmailVerified? Another reason to do this would be that findAvailableWorkspacesByEmail() that checkUserExistsQuery runs is an expensive operation, but I'm not sure.

Copy link
Contributor Author

@samyakpiya samyakpiya Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixMalfait Never mind my previous comment. This was my understanding previously and what I was about to ask:


`CheckUserExists`, `Challenge`, `SignUp` are mutations that are guarded by the `CaptchaGuard` in `auth.resolver.ts`. When I defined a new resolver for `VerifyEmail`, it made sense to captcha guard that endpoint.

After doing so, I followed existing patterns in the frontend to define the captchaToken and pass it along when invoking the client-side graphql mutation for VerifyEmail. This request kept failing. After logging the error in the browser, it said Apollo Error: missing authentication token.

After some debugging, I wrote a console.log(captchaToken) almost everywhere it was used. Then, went to the corresponding frontend pages and carried out the action that uses it. In every case I tested, the value of the captchaToken was undefined in the frontend.

Apparently, the backend is never receiving a captcha token at all. However, it is still able to carry out the request because we seem to be excluding those operations in the middleware:
https://github.com/twentyhq/twenty/blob/c61831c78a67add7f90b0c3cb39afa5421f6b250/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts#L43C1-L61C1

CheckUserExists, Challenge, and SignUp are all in this list. So, I'm assuming this cancels the effect of the captcha guard altogether?


But I just now realized captcha token driver is not set by default and the middleware is checking for authentication token (JWT) and not bypassing the captcha guard.

@@ -267,7 +281,23 @@ export const useAuth = () => {
]);

const handleVerify = useCallback(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this pattern a bit strange. Verify should be used to exchange a login token (temporary) against an auth token pair (access token + refresh token). I haven't finished reading through the code but it feels like what you're doing here should be on the backend (for security reasons OR just to avoid duplication if you also checked isEmailVerified on the backend already))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on this and your feedback about it being better to have more stuff in the backend, I've made the changes in my local repo that follows the above pattern you've mentioned.

Whenever authService.verify() is called in the backend, I'm adding this check:

if (
      this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED') &&
      !user.isEmailVerified
    ) {
      throw new AuthException(
        'Email is not verified',
        AuthExceptionCode.EMAIL_NOT_VERIFIED,
      );
    }

This works fine and shows the user an error when they try to login when their email is not verified. However, I also want to redirect them to resend email verification page if the error that got thrown had an exception code of AuthException.EmailVerified. I didn't see any existing patterns where we were conditionally doing something on the frontend based on the exception code being sent by the backend. So, this is how I'm implementing it right now although it doesn't have type safety so leaving that as a TODO for now.

const handleChallenge = useCallback(
    async (email: string, password: string, captchaToken?: string) => {
      try {
        const challengeResult = await challenge({
          variables: {
            email,
            password,
            captchaToken,
          },
        });
        if (isDefined(challengeResult.errors)) {
          throw challengeResult.errors;
        }

        if (!challengeResult.data?.challenge) {
          throw new Error('No login token');
        }

        return challengeResult.data.challenge;
      } catch (error) {
        // TODO: Get intellisense for graphql error extensions code (codegen?)
        if (
          error instanceof ApolloError &&
          error.graphQLErrors[0]?.extensions?.code === 'EMAIL_NOT_VERIFIED'
        ) {
          setSearchParams({ email });
          setSignInUpStep(SignInUpStep.EmailVerification);
          throw error;
        }
        throw error;
      }
    },
    [challenge, setSearchParams, setSignInUpStep],
  );

}

try {
await verifyEmail(emailVerificationToken);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

email verification enpoint should respond with a loginToken and then the user should get back in the traditional flow / be logged in automatically. His short-lived loginToken will be exchanged again authTokens to get access to the app

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I thought it was safer this way. I'm currently storing the raw emailVerificationToken in the database and the expiry is set to 1d by default. I'll instead store the hashed version and reset the expiry to 1h since users are able to resend the emailVerificationToken anyways.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, greptile suggested changing some file that documents the env, but I couldn't find it.

@FelixMalfait
Copy link
Member

Haven't had time to do a full review yet but did a few first comments. Congrats, really great work!


const navigate = useNavigate();

useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI we are trying to remove all useEffect in the codebase, but OK to keep this one for now imo if it's too complex, since it's just a copy of the implementation we have in Verify. We should make sure to remove both in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify the rationale behind removing useEffect across the codebase? Also what alternative pattern or strategy are we adopting to handle side effects and lifecycle events in its place?

- Replace verifyEmail mutation with getLoginTokenFromEmailVerificationToken
- Remove email parameter from verify mutation
- Add email verification check in challenge mutation
- Clean up email verification token service
- Add EMAIL_NOT_VERIFIED error handling
- Update frontend components to use new auth flow

This change simplifies the email verification flow by having the verification
endpoint directly return a login token instead of requiring a separate sign in
step after verification.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Email verification
4 participants