From b360af3065f5e3098ce087d58ac127abdeaf9bab Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Tue, 16 Jan 2024 17:42:27 +0100 Subject: [PATCH] Remove user-facing unverified email flow (#1638) --- .../forms/internal/common/LoginSignupForm.tsx | 6 ---- .../src/auth/forms/internal/email/useEmail.ts | 9 +----- .../server/src/auth/providers/config/email.ts | 11 ++++--- .../server/src/auth/providers/email/login.ts | 8 ++--- .../server/src/auth/providers/email/signup.ts | 29 +++++++------------ waspc/examples/todoApp/todoApp.wasp | 1 - .../examples/todoApp/sample.env.server | 3 +- .../examples/todoApp/todoApp.wasp | 3 +- waspc/headless-test/tests/simple.spec.ts | 8 +++-- waspc/src/Wasp/AppSpec/App/Auth.hs | 9 +----- .../ServerGenerator/Auth/EmailAuthG.hs | 4 ++- .../WebAppGenerator/Auth/AuthFormsG.hs | 3 +- waspc/test/AppSpec/ValidTest.hs | 6 ++-- web/docs/auth/email.md | 27 +++++++---------- 14 files changed, 48 insertions(+), 79 deletions(-) diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx index 9ec80aa6f..8fd6348c5 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx @@ -166,12 +166,6 @@ export const LoginSignupForm = ({ onLoginSuccess() { history.push('{= onAuthSucceededRedirectTo =}') }, - {=# isEmailVerificationRequired =} - isEmailVerificationRequired: true, - {=/ isEmailVerificationRequired =} - {=^ isEmailVerificationRequired =} - isEmailVerificationRequired: false, - {=/ isEmailVerificationRequired =} }); {=/ isEmailAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =} diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts index f5f4e371c..4d8b792ba 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts @@ -4,7 +4,6 @@ import { login } from '../../../email/actions/login' export function useEmail({ onError, showEmailVerificationPending, - isEmailVerificationRequired, onLoginSuccess, isLogin, }: { @@ -12,7 +11,6 @@ export function useEmail({ showEmailVerificationPending: () => void onLoginSuccess: () => void isLogin: boolean - isEmailVerificationRequired: boolean }) { async function handleSubmit(data) { try { @@ -21,12 +19,7 @@ export function useEmail({ onLoginSuccess() } else { await signup(data) - if (isEmailVerificationRequired) { - showEmailVerificationPending() - } else { - await login(data) - onLoginSuccess() - } + showEmailVerificationPending() } } catch (err: unknown) { onError(err as Error) diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts index ef327d20b..7169bef49 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts @@ -53,16 +53,19 @@ const config: ProviderConfig = { createRouter() { const router = Router(); - const loginRoute = handleRejection(getLoginRoute({ - allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =}, - })); + const loginRoute = handleRejection(getLoginRoute()); router.post('/login', loginRoute); const signupRoute = handleRejection(getSignupRoute({ fromField, clientRoute: '{= emailVerificationClientRoute =}', getVerificationEmailContent: _waspGetVerificationEmailContent, - allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =}, + {=# isDevelopment =} + isEmailAutoVerified: process.env.SKIP_EMAIL_VERIFICATION_IN_DEV === 'true', + {=/ isDevelopment =} + {=^ isDevelopment =} + isEmailAutoVerified: false, + {=/ isDevelopment =} })); router.post('/signup', signupRoute); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts index c2288ab60..ee987ca19 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts @@ -10,11 +10,7 @@ import { import { createSession } from '../../session.js' import { ensureValidEmail, ensurePasswordIsPresent } from '../../validation.js' -export function getLoginRoute({ - allowUnverifiedLogin, -}: { - allowUnverifiedLogin: boolean -}) { +export function getLoginRoute() { return async function login( req: Request<{ email: string; password: string; }>, res: Response, @@ -29,7 +25,7 @@ export function getLoginRoute({ throwInvalidCredentialsError() } const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData) - if (!providerData.isEmailVerified && !allowUnverifiedLogin) { + if (!providerData.isEmailVerified) { throwInvalidCredentialsError() } try { diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index ed3563ea2..b29861a86 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -24,12 +24,12 @@ export function getSignupRoute({ fromField, clientRoute, getVerificationEmailContent, - allowUnverifiedLogin, + isEmailAutoVerified, }: { fromField: EmailFromField; clientRoute: string; getVerificationEmailContent: GetVerificationEmailContentFn; - allowUnverifiedLogin: boolean; + isEmailAutoVerified: boolean; }) { return async function signup( req: Request<{ email: string; password: string; }>, @@ -65,20 +65,7 @@ export function getSignupRoute({ * else's email address and therefore permanently making that email * address unavailable for later account creation (by real owner). */ - if (existingAuthIdentity) { - if (allowUnverifiedLogin) { - /** - * This is the case where we allow unverified login. - * - * If we pretended that the user was created successfully that would bring - * us little value: the attacker would not be able to login and figure out - * if the user exists or not, anyway. - * - * So, we throw an error that says that the user already exists. - */ - throw new HttpError(422, "User with that email already exists.") - } - + if (existingAuthIdentity) { const providerData = deserializeAndSanitizeProviderData<'email'>(existingAuthIdentity.providerData); // TOOD: faking work makes sense if the time spent on faking the work matches the time @@ -107,7 +94,7 @@ export function getSignupRoute({ const newUserProviderData = await sanitizeAndSerializeProviderData<'email'>({ hashedPassword: fields.password, - isEmailVerified: false, + isEmailVerified: isEmailAutoVerified ? true : false, emailVerificationSentAt: null, passwordResetSentAt: null, }); @@ -124,6 +111,12 @@ export function getSignupRoute({ rethrowPossibleAuthError(e); } + // Wasp allows for auto-verification of emails in development mode to + // make writing e2e tests easier. + if (isEmailAutoVerified) { + return res.json({ success: true }); + } + const verificationLink = await createEmailVerificationLink(fields.email, clientRoute); try { await sendEmailVerificationEmail( @@ -137,7 +130,7 @@ export function getSignupRoute({ } catch (e: unknown) { console.error("Failed to send email verification email:", e); throw new HttpError(500, "Failed to send email verification email."); - } + } return res.json({ success: true }); }; diff --git a/waspc/examples/todoApp/todoApp.wasp b/waspc/examples/todoApp/todoApp.wasp index 58bd86923..066750afb 100644 --- a/waspc/examples/todoApp/todoApp.wasp +++ b/waspc/examples/todoApp/todoApp.wasp @@ -37,7 +37,6 @@ app todoApp { getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", clientRoute: PasswordResetRoute }, - allowUnverifiedLogin: false, }, }, signup: { diff --git a/waspc/headless-test/examples/todoApp/sample.env.server b/waspc/headless-test/examples/todoApp/sample.env.server index b59086973..fd6dccba4 100644 --- a/waspc/headless-test/examples/todoApp/sample.env.server +++ b/waspc/headless-test/examples/todoApp/sample.env.server @@ -1,2 +1,3 @@ GOOGLE_CLIENT_ID="mock-client-id" -GOOGLE_CLIENT_SECRET="mock-client-secret" \ No newline at end of file +GOOGLE_CLIENT_SECRET="mock-client-secret" +SKIP_EMAIL_VERIFICATION_IN_DEV=true \ No newline at end of file diff --git a/waspc/headless-test/examples/todoApp/todoApp.wasp b/waspc/headless-test/examples/todoApp/todoApp.wasp index eabd93d2d..f496314af 100644 --- a/waspc/headless-test/examples/todoApp/todoApp.wasp +++ b/waspc/headless-test/examples/todoApp/todoApp.wasp @@ -22,8 +22,7 @@ app todoApp { passwordReset: { getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", clientRoute: PasswordResetRoute - }, - allowUnverifiedLogin: true, + } }, google: {} }, diff --git a/waspc/headless-test/tests/simple.spec.ts b/waspc/headless-test/tests/simple.spec.ts index 7706f56a1..5fedcfc18 100644 --- a/waspc/headless-test/tests/simple.spec.ts +++ b/waspc/headless-test/tests/simple.spec.ts @@ -17,7 +17,9 @@ test.describe("signup and login", () => { await page.waitForSelector("text=Create a new account"); - await expect(page.locator("a[href='http://localhost:3001/auth/google/login']")).toBeVisible(); + await expect( + page.locator("a[href='http://localhost:3001/auth/google/login']") + ).toBeVisible(); }); test("can sign up", async ({ page }) => { @@ -29,7 +31,9 @@ test.describe("signup and login", () => { await page.locator("input[type='password']").fill(password); await page.locator("button").click(); - await expect(page).toHaveURL("/profile"); + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.` + ); }); test("can log in and create a task", async ({ page }) => { diff --git a/waspc/src/Wasp/AppSpec/App/Auth.hs b/waspc/src/Wasp/AppSpec/App/Auth.hs index f1f7e1df8..304499f61 100644 --- a/waspc/src/Wasp/AppSpec/App/Auth.hs +++ b/waspc/src/Wasp/AppSpec/App/Auth.hs @@ -13,7 +13,6 @@ module Wasp.AppSpec.App.Auth isGoogleAuthEnabled, isGitHubAuthEnabled, isEmailAuthEnabled, - isEmailVerificationRequired, ) where @@ -59,8 +58,7 @@ data ExternalAuthConfig = ExternalAuthConfig data EmailAuthConfig = EmailAuthConfig { fromField :: EmailFromField, emailVerification :: EmailVerificationConfig, - passwordReset :: PasswordResetConfig, - allowUnverifiedLogin :: Maybe Bool + passwordReset :: PasswordResetConfig } deriving (Show, Eq, Data) @@ -86,8 +84,3 @@ isGitHubAuthEnabled = isJust . gitHub . methods isEmailAuthEnabled :: Auth -> Bool isEmailAuthEnabled = isJust . email . methods - -isEmailVerificationRequired :: Auth -> Bool -isEmailVerificationRequired auth = case email . methods $ auth of - Nothing -> False - Just emailAuthConfig -> allowUnverifiedLogin emailAuthConfig /= Just True diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs index e37f9b4ef..081112153 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs @@ -61,7 +61,7 @@ genEmailAuthConfig spec emailAuthConfig = return $ C.mkTmplFdWithDstAndData tmpl "passwordResetClientRoute" .= passwordResetClientRoute, "getPasswordResetEmailContent" .= getPasswordResetEmailContent, "getVerificationEmailContent" .= getVerificationEmailContent, - "allowUnverifiedLogin" .= fromMaybe False (AS.Auth.allowUnverifiedLogin emailAuthConfig) + "isDevelopment" .= isDevelopment ] fromFieldJson = @@ -74,6 +74,8 @@ genEmailAuthConfig spec emailAuthConfig = return $ C.mkTmplFdWithDstAndData tmpl maybeName = AS.EmailSender.name fromField email = AS.EmailSender.email fromField + isDevelopment = not $ AS.isBuild spec + emailVerificationClientRoute = getRoutePathFromRef spec $ AS.Auth.EmailVerification.clientRoute emailVerification passwordResetClientRoute = getRoutePathFromRef spec $ AS.Auth.PasswordReset.clientRoute passwordReset getPasswordResetEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.PasswordReset.getEmailContentFn passwordReset diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs index b82ca2e54..a20844026 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs @@ -118,8 +118,7 @@ genLoginSignupForm auth = -- Username and password "isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth, -- Email - "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth, - "isEmailVerificationRequired" .= AS.Auth.isEmailVerificationRequired auth + "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth ] areBothSocialAndPasswordBasedAuthEnabled = AS.Auth.isExternalAuthEnabled auth && isAnyPasswordBasedAuthEnabled isAnyPasswordBasedAuthEnabled = AS.Auth.isUsernameAndPasswordAuthEnabled auth || AS.Auth.isEmailAuthEnabled auth diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index c09b09e78..84c584965 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -180,8 +180,7 @@ spec_AppSpecValid = do AS.Auth.PasswordReset.PasswordResetConfig { AS.Auth.PasswordReset.clientRoute = AS.Core.Ref.Ref basicRouteName, AS.Auth.PasswordReset.getEmailContentFn = Nothing - }, - AS.Auth.allowUnverifiedLogin = Nothing + } } it "returns no error if app.auth is not set" $ do @@ -238,8 +237,7 @@ spec_AppSpecValid = do AS.Auth.PasswordReset.PasswordResetConfig { AS.Auth.PasswordReset.clientRoute = AS.Core.Ref.Ref basicRouteName, AS.Auth.PasswordReset.getEmailContentFn = Nothing - }, - AS.Auth.allowUnverifiedLogin = Nothing + } } let makeSpec emailSender isBuild = diff --git a/web/docs/auth/email.md b/web/docs/auth/email.md index 0cb5ad222..161ee33e0 100644 --- a/web/docs/auth/email.md +++ b/web/docs/auth/email.md @@ -70,7 +70,6 @@ app myApp { passwordReset: { clientRoute: PasswordResetRoute, }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/login", @@ -105,7 +104,6 @@ app myApp { passwordReset: { clientRoute: PasswordResetRoute, }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/login", @@ -457,10 +455,6 @@ Running `wasp db migrate-dev` and then `wasp start` should give you a working ap ![Auth UI](/img/authui/login.png) -If logging in with an unverified email is _allowed_, the user will be able to login with an unverified email address. If logging in with an unverified email is _not allowed_, the user will be shown an error message. - -Read more about the `allowUnverifiedLogin` option [here](#allowunverifiedlogin-bool-specifies-whether-the-user-can-login-without-verifying-their-e-mail-address). - ### Signup ![Auth UI](/img/authui/signup.png) @@ -484,6 +478,17 @@ Some of the behavior you get out of the box: ## Email Verification Flow +:::info Automatic email verification in development + +In development mode, you can skip the email verification step by setting the `SKIP_EMAIL_VERIFICATION_IN_DEV` environment variable to `true` in your `.env.server` file: + +```env title=".env.server" +SKIP_EMAIL_VERIFICATION_IN_DEV=true +``` + +This is useful when you are developing your app and don't want to go through the email verification flow every time you sign up. It can be also useful when you are writing automated tests for your app. +::: + By default, Wasp requires the e-mail to be verified before allowing the user to log in. This is done by sending a verification email to the user's email address and requiring the user to click on a link in the email to verify their email address. Our setup looks like this: @@ -902,7 +907,6 @@ app myApp { clientRoute: PasswordResetRoute, getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/someRoute" @@ -934,7 +938,6 @@ app myApp { clientRoute: PasswordResetRoute, getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/someRoute" @@ -1104,11 +1107,3 @@ It has the following fields: This is the default content of the e-mail, you can customize it to your liking. - -#### `allowUnverifiedLogin: bool`: specifies whether the user can login without verifying their e-mail address - -It defaults to `false`. If `allowUnverifiedLogin` is set to `true`, the user can login without verifying their e-mail address, otherwise users will receive a `401` error when trying to login without verifying their e-mail address. - -Sometimes you want to allow unverified users to login to provide them a different onboarding experience. Some of the pages can be viewed without verifying the e-mail address, but some of them can't. You can use the `isEmailVerified` field on the user entity to check if the user has verified their e-mail address. - -If you have any questions, feel free to ask them on [our Discord server](https://discord.gg/rzdnErX). \ No newline at end of file