Remove user-facing unverified email flow (#1638)

This commit is contained in:
Mihovil Ilakovac 2024-01-16 17:42:27 +01:00 committed by GitHub
parent e3c825fae1
commit b360af3065
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 48 additions and 79 deletions

View File

@ -166,12 +166,6 @@ export const LoginSignupForm = ({
onLoginSuccess() {
history.push('{= onAuthSucceededRedirectTo =}')
},
{=# isEmailVerificationRequired =}
isEmailVerificationRequired: true,
{=/ isEmailVerificationRequired =}
{=^ isEmailVerificationRequired =}
isEmailVerificationRequired: false,
{=/ isEmailVerificationRequired =}
});
{=/ isEmailAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}

View File

@ -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)

View File

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

View File

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

View File

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

View File

@ -37,7 +37,6 @@ app todoApp {
getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
clientRoute: PasswordResetRoute
},
allowUnverifiedLogin: false,
},
},
signup: {

View File

@ -1,2 +1,3 @@
GOOGLE_CLIENT_ID="mock-client-id"
GOOGLE_CLIENT_SECRET="mock-client-secret"
GOOGLE_CLIENT_SECRET="mock-client-secret"
SKIP_EMAIL_VERIFICATION_IN_DEV=true

View File

@ -22,8 +22,7 @@ app todoApp {
passwordReset: {
getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
clientRoute: PasswordResetRoute
},
allowUnverifiedLogin: true,
}
},
google: {}
},

View File

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

View File

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

View File

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

View File

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

View File

@ -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 =

View File

@ -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:
</Tabs>
<small>This is the default content of the e-mail, you can customize it to your liking.</small>
#### `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).