-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
base: main
Are you sure you want to change the base?
Add Email Verification for non-Microsoft/Google Emails #9288
Conversation
TODOs/FIXMEs:
|
- 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
…cation token functionality
@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! |
There was a problem hiding this 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
andEMAIL_VERIFICATION_TOKEN_EXPIRES_IN
for configuration
44 file(s) reviewed, 34 comment(s)
Edit PR Review Bot Settings | Greptile
packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx
Outdated
Show resolved
Hide resolved
packages/twenty-front/src/modules/auth/components/VerifyEmail.tsx
Outdated
Show resolved
Hide resolved
mutation ResendEmailVerificationToken($email: String!) { | ||
resendEmailVerificationToken(email: $email) { | ||
success | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
packages/twenty-front/src/modules/auth/components/VerifyEmail.tsx
Outdated
Show resolved
Hide resolved
...rc/engine/core-modules/email-verification/utils/cast-app-token-to-email-verification.util.ts
Outdated
Show resolved
Hide resolved
packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
Outdated
Show resolved
Hide resolved
packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
Show resolved
Hide resolved
packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
Show resolved
Hide resolved
packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
Outdated
Show resolved
Hide resolved
…ndEmailVerificationToken
…pTokenToEmailVerification()
The previous implementation incorrectly assumed that email verification should be skipped for @gmail.com and @outlook.com domains. However, verification should be required based on the authentication method used (SSO vs email/password), not the email domain.
Wow, big piece!!! Thanks a lot! |
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 After generating a migration to rename |
- 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
@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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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 /> |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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:
Line 95 in b22a598
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:
@@ -48,6 +48,8 @@ export class GraphQLHydrateRequestFromTokenMiddleware | |||
'CheckUserExists', | |||
'Challenge', | |||
'Verify', |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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,
};
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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))
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx
Outdated
Show resolved
Hide resolved
packages/twenty-front/src/modules/auth/sign-in-up/components/EmailVerificationSent.tsx
Show resolved
Hide resolved
Haven't had time to do a full review yet but did a few first comments. Congrats, really great work! |
|
||
const navigate = useNavigate(); | ||
|
||
useEffect(() => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…fication-token.input.ts
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):
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
Sign In Process
Database
emailVerified
colum onuser
to toisEmailVerified
for consistencyDuring Deployment
isEmailVerified
totrue
for all users with a Google/Microsoft email and all users that show an indication of a valid subscription (e.g. linked credit card)