Adding e-mail auth (e-mail verfication, password reset) (#1087)

This commit is contained in:
Mihovil Ilakovac 2023-04-05 23:25:03 +02:00 committed by GitHub
parent 8ad582ac7b
commit 7f32a4ccb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 1570 additions and 217 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
# macOS related
.DS_Store
.vscode/

View File

@ -0,0 +1,12 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
import { initSession } from '../../helpers/user';
export async function login(data: { email: string; password: string }): Promise<void> {
try {
const response = await api.post('{= loginPath =}', data);
await initSession(response.data.token);
} catch (e: unknown) {
handleApiError(e);
}
}

View File

@ -0,0 +1,20 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
export async function requestPasswordReset(data: { email: string; }): Promise<{ success: boolean }> {
try {
const response = await api.post('{= requestPasswordResetPath =}', data);
return response.data;
} catch (e: unknown) {
handleApiError(e);
}
}
export async function resetPassword(data: { token: string; password: string; }): Promise<{ success: boolean }> {
try {
const response = await api.post('{= resetPasswordPath =}', data);
return response.data;
} catch (e: unknown) {
handleApiError(e);
}
}

View File

@ -0,0 +1,11 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
export async function signup(data: { email: string; password: string }): Promise<{ success: boolean }> {
try {
const response = await api.post('{= signupPath =}', data);
return response.data;
} catch (e: unknown) {
handleApiError(e);
}
}

View File

@ -0,0 +1,11 @@
{{={= =}=}}
import api, { handleApiError } from '../../../api';
export async function verifyEmail(data: { token: string; }): Promise<{ success: boolean; reason?: string; }> {
try {
const response = await api.post('{= verifyEmailPath =}', data);
return response.data;
} catch (e: unknown) {
handleApiError(e);
}
}

View File

@ -0,0 +1,47 @@
{{={= =}=}}
import { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { login } from '../actions/login';
import { errorMessage } from '../../../utils.js'
export const LoginForm = () => {
const history = useHistory()
const [emailFieldVal, setEmailFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleLogin = async (event) => {
event.preventDefault()
try {
await login({
email: emailFieldVal,
password: passwordFieldVal,
})
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
return (
<form onSubmit={handleLogin} className="login-form auth-form">
<h2>Email</h2>
<input
type="email"
value={emailFieldVal}
onChange={e => setEmailFieldVal(e.target.value)}
/>
<h2>Password</h2>
<input
type="password"
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
/>
<div>
<input type="submit" value="Log in"/>
</div>
</form>
)
}

View File

@ -0,0 +1,53 @@
{{={= =}=}}
import { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { signup } from '../actions/signup'
import { login } from '../actions/login'
import { errorMessage } from '../../../utils.js'
export const SignupForm = () => {
const history = useHistory()
const [emailFieldVal, setEmailFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleSignup = async (event) => {
event.preventDefault()
try {
await signup({ email: emailFieldVal, password: passwordFieldVal })
await login ({
email: emailFieldVal,
password: passwordFieldVal,
})
setEmailFieldVal('')
setPasswordFieldVal('')
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
return (
<form onSubmit={handleSignup} className='signup-form auth-form'>
<h2>Email</h2>
<input
type="email"
value={emailFieldVal}
onChange={e => setEmailFieldVal(e.target.value)}
/>
<h2>Password</h2>
<input
type="password"
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
/>
<div>
<input type="submit" value="Sign up"/>
</div>
</form>
)
}

View File

@ -0,0 +1,8 @@
export { login } from './actions/login';
export { signup } from './actions/signup';
export { requestPasswordReset } from './actions/passwordReset';
export { resetPassword } from './actions/passwordReset';
export { verifyEmail } from './actions/verifyEmail';
export { SignupForm } from './components/Signup';
export { LoginForm } from './components/Login';

View File

@ -1,5 +1,4 @@
{{={= =}=}}
import React, { useState } from 'react'
import Auth from './Auth'
const LoginForm = ({ appearance, logo, socialLayout }) => {

View File

@ -1,6 +1,4 @@
{{={= =}=}}
import React, { useState } from 'react'
import Auth from './Auth'
const SignupForm = ({ appearance, logo, socialLayout }) => {

View File

@ -0,0 +1,82 @@
{{={= =}=}}
import { Router, Request, Response, NextFunction } from "express";
import { ProviderConfig } from "../types.js";
import type { EmailFromField } from '../../../email/core/types.js';
import { getLoginRoute } from "../email/login.js";
import { getSignupRoute } from "../email/signup.js";
import { getRequestPasswordResetRoute } from "../email/requestPasswordReset.js";
import { resetPassword } from "../email/resetPassword.js";
import { verifyEmail } from "../email/verifyEmail.js";
import { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from "../email/types.js";
import { handleRejection } from "../../../utils.js";
{=# getVerificationEmailContent.isDefined =}
{=& getVerificationEmailContent.importStatement =}
const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = {= getVerificationEmailContent.importIdentifier =};
{=/ getVerificationEmailContent.isDefined =}
{=# getPasswordResetEmailContent.isDefined =}
{=& getPasswordResetEmailContent.importStatement =}
const _waspGetPasswordResetEmailContent: GetPasswordResetEmailContentFn = {= getPasswordResetEmailContent.importIdentifier =};
{=/ getPasswordResetEmailContent.isDefined =}
{=^ getVerificationEmailContent.isDefined =}
const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
subject: 'Verify your email',
text: `Click the link below to verify your email: ${verificationLink}`,
html: `
<p>Click the link below to verify your email</p>
<a href="${verificationLink}">Verify email</a>
`,
});
{=/ getVerificationEmailContent.isDefined =}
{=^ getPasswordResetEmailContent.isDefined =}
const _waspGetPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
subject: 'Reset your password',
text: `Click the link below to reset your password: ${passwordResetLink}`,
html: `
<p>Click the link below to reset your password</p>
<a href="${passwordResetLink}">Reset password</a>
`,
});
{=/ getPasswordResetEmailContent.isDefined =}
const fromField: EmailFromField = {
name: '{= fromField.name =}',
email: '{= fromField.email =}',
};
const config: ProviderConfig = {
id: "{= providerId =}",
displayName: "{= displayName =}",
createRouter() {
const router = Router();
const loginRoute = handleRejection(getLoginRoute({
allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =},
}));
router.post('/login', loginRoute);
const signupRoute = handleRejection(getSignupRoute({
fromField,
clientRoute: '{= emailVerificationClientRoute =}',
getVerificationEmailContent: _waspGetVerificationEmailContent,
}));
router.post('/signup', signupRoute);
const requestPasswordResetRoute = handleRejection(getRequestPasswordResetRoute({
fromField,
clientRoute: '{= passwordResetClientRoute =}',
getPasswordResetEmailContent: _waspGetPasswordResetEmailContent,
}));
router.post('/request-password-reset', requestPasswordResetRoute);
router.post('/reset-password', handleRejection(resetPassword));
router.post('/verify-email', handleRejection(verifyEmail));
return router;
},
}
export default config;

View File

@ -0,0 +1,36 @@
import { Request, Response } from 'express';
import { verifyPassword } from "../../../core/auth.js";
import { findUserBy, createAuthToken, ensureValidEmailAndPassword } from "../../utils.js";
export function getLoginRoute({
allowUnverifiedLogin,
}: {
allowUnverifiedLogin: boolean
}) {
return async function login(
req: Request<{ email: string; password: string; }>,
res: Response,
): Promise<Response<{ token: string } | undefined>> {
const args = req.body || {};
ensureValidEmailAndPassword(args);
args.email = args.email.toLowerCase();
const user = await findUserBy<'email'>({ email: args.email });
if (!user) {
return res.status(401).send();
}
if (!user.isEmailVerified && !allowUnverifiedLogin) {
return res.status(401).send();
}
try {
await verifyPassword(user.password, args.password);
} catch(e) {
return res.status(401).send();
}
const token = await createAuthToken(user);
return res.json({ token })
};
}

View File

@ -0,0 +1,60 @@
import { Request, Response } from 'express';
import {
createPasswordResetLink,
findUserBy,
doFakeWork,
ensureValidEmail,
sendPasswordResetEmail,
isEmailResendAllowed,
} from "../../utils.js";
import type { EmailFromField } from '../../../email/core/types.js';
import { GetPasswordResetEmailContentFn } from './types.js';
export function getRequestPasswordResetRoute({
fromField,
clientRoute,
getPasswordResetEmailContent,
}: {
fromField: EmailFromField;
clientRoute: string;
getPasswordResetEmailContent: GetPasswordResetEmailContentFn;
}) {
return async function requestPasswordReset(
req: Request<{ email: string; }>,
res: Response,
): Promise<Response<{ success: true } | { success: false; message: string }>> {
const args = req.body || {};
ensureValidEmail(args);
args.email = args.email.toLowerCase();
const user = await findUserBy<'email'>({ email: args.email });
// User not found or not verified - don't leak information
if (!user || !user.isEmailVerified) {
await doFakeWork();
return res.json({ success: true });
}
if (!isEmailResendAllowed(user, 'passwordResetSentAt')) {
return res.status(400).json({ success: false, message: "Please wait a minute before trying again." });
}
const passwordResetLink = await createPasswordResetLink(user, clientRoute);
try {
await sendPasswordResetEmail(
user.email,
{
from: fromField,
to: user.email,
...getPasswordResetEmailContent({ passwordResetLink }),
}
);
} catch (e: any) {
console.error("Failed to send password reset email:", e);
return res.status(500).json({ success: false, message: "Failed to send password reset email." });
}
res.json({ success: true });
};
}

View File

@ -0,0 +1,27 @@
import { Request, Response } from 'express';
import { ensureValidTokenAndNewPassword, findUserBy, updateUserPassword, verifyToken } from "../../utils.js";
import { tokenVerificationErrors } from "./types.js";
export async function resetPassword(
req: Request<{ token: string; password: string; }>,
res: Response,
): Promise<Response<{ success: true } | { success: false; message: string }>> {
const args = req.body || {};
ensureValidTokenAndNewPassword(args);
const { token, password } = args;
try {
const { id: userId } = await verifyToken(token);
const user = await findUserBy<'id'>({ id: userId });
if (!user) {
return res.status(400).json({ success: false, message: 'Invalid token' });
}
await updateUserPassword(userId, password);
} catch (e) {
const reason = e.name === tokenVerificationErrors.TokenExpiredError
? 'expired'
: 'invalid';
return res.status(400).json({ success: false, message: `Password reset failed, ${reason} token`});
}
res.json({ success: true });
};

View File

@ -0,0 +1,64 @@
import { Request, Response } from 'express';
import { EmailFromField } from "../../../email/core/types.js";
import {
createEmailVerificationLink,
createUser,
findUserBy,
deleteUser,
doFakeWork,
ensureValidEmailAndPassword,
sendEmailVerificationEmail,
isEmailResendAllowed,
} from "../../utils.js";
import { GetVerificationEmailContentFn } from './types.js';
export function getSignupRoute({
fromField,
clientRoute,
getVerificationEmailContent,
}: {
fromField: EmailFromField;
clientRoute: string;
getVerificationEmailContent: GetVerificationEmailContentFn;
}) {
return async function signup(
req: Request<{ email: string; password: string; }>,
res: Response,
): Promise<Response<{ success: true } | { success: false; message: string }>> {
const userFields = req.body;
ensureValidEmailAndPassword(userFields);
userFields.email = userFields.email.toLowerCase();
const existingUser = await findUserBy<'email'>({ email: userFields.email });
// User already exists and is verified - don't leak information
if (existingUser && existingUser.isEmailVerified) {
await doFakeWork();
return res.json({ success: true });
} else if (existingUser && !existingUser.isEmailVerified) {
if (!isEmailResendAllowed(existingUser, 'emailVerificationSentAt')) {
return res.status(400).json({ success: false, message: "Please wait a minute before trying again." });
}
await deleteUser(existingUser);
}
const user = await createUser(userFields);
const verificationLink = await createEmailVerificationLink(user, clientRoute);
try {
await sendEmailVerificationEmail(
userFields.email,
{
from: fromField,
to: userFields.email,
...getVerificationEmailContent({ verificationLink }),
}
);
} catch (e: any) {
console.error("Failed to send email verification email:", e);
return res.status(500).json({ success: false, message: "Failed to send email verification email." });
}
return res.json({ success: true });
};
}

View File

@ -0,0 +1,13 @@
export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent;
export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent;
type EmailContent = {
subject: string;
html: string;
text: string;
}
export const tokenVerificationErrors = {
TokenExpiredError: 'TokenExpiredError',
};

View File

@ -0,0 +1,22 @@
import { Request, Response } from 'express';
import { updateUserEmailVerification, verifyToken } from '../../utils.js';
import { tokenVerificationErrors } from './types.js';
export async function verifyEmail(
req: Request<{ token: string }>,
res: Response,
): Promise<Response<{ success: true } | { success: false, message: string }>> {
try {
const { token } = req.body;
const { id: userId } = await verifyToken(token);
await updateUserEmailVerification(userId);
} catch (e) {
const reason = e.name === tokenVerificationErrors.TokenExpiredError
? 'expired'
: 'invalid';
return res.status(400).json({ success: false, message: `Token is ${reason}` });
}
return res.json({ success: true });
};

View File

@ -1,35 +1,25 @@
{{={= =}=}}
import Prisma from '@prisma/client'
import SecurePassword from 'secure-password'
import { sign, verifyPassword } from '../../../core/auth.js'
import { verifyPassword } from '../../../core/auth.js'
import { handleRejection } from '../../../utils.js'
const prisma = new Prisma.PrismaClient()
import { findUserBy, createAuthToken } from '../../utils.js'
export default handleRejection(async (req, res) => {
const args = req.body || {}
// Try to fetch user with the given username.
const user = await prisma.{= userEntityLower =}.findUnique({ where: { username: args.username } })
const user = await findUserBy<'username'>({ username: args.username })
if (!user) {
return res.status(401).send()
}
// We got user - now check the password.
const verifyPassRes = await verifyPassword(user.password, args.password)
switch (verifyPassRes) {
case SecurePassword.VALID:
break
case SecurePassword.VALID_NEEDS_REHASH:
// TODO(matija): take neccessary steps to make the password more secure.
break
default:
return res.status(401).send()
try {
await verifyPassword(user.password, args.password)
} catch(e) {
return res.status(401).send()
}
// Username & password valid - generate token.
const token = await sign(user.id)
const token = await createAuthToken(user)
// NOTE(matija): Possible option - instead of explicitly returning token here,
// we could add to response header 'Set-Cookie {token}' directive which would then make

View File

@ -1,23 +1,11 @@
{{={= =}=}}
import prisma from '../../../dbClient.js'
import { handleRejection, isPrismaError, prismaErrorToHttpError } from '../../../utils.js'
import AuthError from '../../../core/AuthError.js'
import HttpError from '../../../core/HttpError.js'
import { handleRejection } from '../../../utils.js'
import { createUser } from '../../utils.js'
export default handleRejection(async (req, res) => {
const userFields = req.body || {}
try {
await prisma.{= userEntityLower =}.create({ data: userFields })
} catch (e) {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}
await createUser(userFields)
res.send()
return res.send()
})

View File

@ -7,11 +7,12 @@ import { v4 as uuidv4 } from 'uuid'
import prisma from '../../../dbClient.js'
import waspServerConfig from '../../../config.js'
import { sign } from '../../../core/auth.js'
import { authConfig, contextWithUserEntity } from "../../utils.js"
import { authConfig, contextWithUserEntity, createUser } from "../../utils.js"
import type { {= userEntityUpper =} } from '../../../entities';
import type { ProviderConfig, RequestWithWasp } from "../types.js"
import type { GetUserFieldsFn } from "./types.js"
import { handleRejection } from "../../../utils.js"
// For oauth providers, we have an endpoint /login to get the auth URL,
// and the /callback endpoint which is used to get the actual access_token and the user info.
@ -32,24 +33,24 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat
session: false,
failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath
}),
async function (req: RequestWithWasp, res) {
const providerProfile = req?.wasp?.providerProfile;
handleRejection(async function (req: RequestWithWasp, res) {
const providerProfile = req?.wasp?.providerProfile;
if (!providerProfile) {
throw new Error(`Missing ${provider.displayName} provider profile on request. This should not happen! Please contact Wasp.`);
} else if (!providerProfile.id) {
throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`);
}
if (!providerProfile) {
throw new Error(`Missing ${provider.displayName} provider profile on request. This should not happen! Please contact Wasp.`);
} else if (!providerProfile.id) {
throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`);
}
// Wrap call to getUserFieldsFn so we can invoke only if needed.
const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile });
// TODO: In the future we could make this configurable, possibly associating an external account
// with the currently logged in account, or by some DB lookup.
const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields);
// Wrap call to getUserFieldsFn so we can invoke only if needed.
const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile });
// TODO: In the future we could make this configurable, possibly associating an external account
// with the currently logged in account, or by some DB lookup.
const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields);
const token = await sign(user.id);
res.json({ token });
}
const token = await sign(user.id);
res.json({ token });
})
)
return router;
@ -84,5 +85,5 @@ async function findOrCreateUserByExternalAuthAssociation(
}
}
return prisma.{= userEntityLower =}.create({ data: userAndExternalAuthAssociation })
return createUser(userAndExternalAuthAssociation)
}

View File

@ -1,6 +1,19 @@
{{={= =}=}}
import { sign, verify } from '../core/auth.js'
import AuthError from '../core/AuthError.js'
import HttpError from '../core/HttpError.js'
import prisma from '../dbClient.js'
import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js'
import { type {= userEntityUpper =} } from '../entities/index.js'
import waspServerConfig from '../config.js';
import { type Prisma } from '@prisma/client';
{=# isEmailAuthEnabled =}
import { isValidEmail } from '../core/auth/validators.js'
import { emailSender } from '../email/index.js';
import { Email } from '../email/core/types.js';
{=/ isEmailAuthEnabled =}
type {= userEntityUpper =}Id = {= userEntityUpper =}['id']
export const contextWithUserEntity = {
entities: {
@ -12,3 +25,191 @@ export const authConfig = {
failureRedirectPath: "{= failureRedirectPath =}",
successRedirectPath: "{= successRedirectPath =}",
}
export async function findUserBy<K extends keyof {= userEntityUpper =}>(where: { [key in K]: {= userEntityUpper =}[K] }): Promise<{= userEntityUpper =}> {
return prisma.{= userEntityLower =}.findUnique({ where });
}
export async function createUser(data: Prisma.{= userEntityUpper =}CreateInput): Promise<{= userEntityUpper =}> {
try {
return await prisma.{= userEntityLower =}.create({ data })
} catch (e) {
rethrowError(e);
}
}
export async function deleteUser(user: {= userEntityUpper =}): Promise<{= userEntityUpper =}> {
try {
return await prisma.{= userEntityLower =}.delete({ where: { id: user.id } })
} catch (e) {
rethrowError(e);
}
}
export async function createAuthToken(user: {= userEntityUpper =}): Promise<string> {
return sign(user.id);
}
export async function verifyToken(token: string): Promise<{ id: any }> {
return verify(token);
}
// If an user exists, we don't want to leak information
// about it. Pretending that we're doing some work
// will make it harder for an attacker to determine
// if a user exists or not.
// NOTE: Attacker measuring time to response can still determine
// if a user exists or not. We'll be able to avoid it when
// we implement e-mail sending via jobs.
export async function doFakeWork() {
const timeToWork = Math.floor(Math.random() * 1000) + 1000;
return sleep(timeToWork);
}
{=# isEmailAuthEnabled =}
export async function updateUserEmailVerification(userId: {= userEntityUpper =}Id): Promise<void> {
try {
await prisma.{= userEntityLower =}.update({
where: { id: userId },
data: { isEmailVerified: true },
})
} catch (e) {
rethrowError(e);
}
}
export async function updateUserPassword(userId: {= userEntityUpper =}Id, password: string): Promise<void> {
try {
await prisma.{= userEntityLower =}.update({
where: { id: userId },
data: { password },
})
} catch (e) {
rethrowError(e);
}
}
export async function createEmailVerificationLink(user: {= userEntityUpper =}, clientRoute: string): Promise<string> {
const token = await createEmailVerificationToken(user);
return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`;
}
export async function createPasswordResetLink(user: {= userEntityUpper =}, clientRoute: string): Promise<string> {
const token = await createPasswordResetToken(user);
return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`;
}
async function createEmailVerificationToken(user: {= userEntityUpper =}): Promise<string> {
return sign(user.id, { expiresIn: '30m' });
}
async function createPasswordResetToken(user: {= userEntityUpper =}): Promise<string> {
return sign(user.id, { expiresIn: '30m' });
}
export async function sendPasswordResetEmail(
email: string,
content: Email,
): Promise<void> {
return sendEmailAndLogTimestamp(email, content, 'passwordResetSentAt');
}
export async function sendEmailVerificationEmail(
email: string,
content: Email,
): Promise<void> {
return sendEmailAndLogTimestamp(email, content, 'emailVerificationSentAt');
}
async function sendEmailAndLogTimestamp(
email: string,
content: Email,
field: 'emailVerificationSentAt' | 'passwordResetSentAt',
): Promise<void> {
// Set the timestamp first, and then send the email
// so the user can't send multiple requests while
// the email is being sent.
try {
await prisma.{= userEntityLower =}.update({
where: { email },
data: { [field]: new Date() },
})
} catch (e) {
rethrowError(e);
}
await emailSender.send(content);
}
export function isEmailResendAllowed(
user: {= userEntityUpper =},
field: 'emailVerificationSentAt' | 'passwordResetSentAt',
resendInterval: number = 1000 * 60,
): boolean {
const sentAt = user[field];
if (!sentAt) {
return true;
}
const now = new Date();
const diff = now.getTime() - sentAt.getTime();
return diff > resendInterval;
}
const EMAIL_FIELD = 'email';
const PASSWORD_FIELD = 'password';
const TOKEN_FIELD = 'token';
const emailValidators = [
{ validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email },
{ validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) },
];
const passwordValidators = [
{ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
{ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 },
{ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) },
];
const tokenValidators = [
{ validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token },
];
export function ensureValidEmailAndPassword(args: unknown): void {
ensureValidEmail(args);
ensureValidPassword(args);
}
export function ensureValidTokenAndNewPassword(args: unknown): void {
validate(args, [
...tokenValidators,
]);
ensureValidPassword(args);
}
export function ensureValidEmail(args: unknown): void {
validate(args, [
...emailValidators,
]);
}
export function ensureValidPassword(args: unknown): void {
validate(args, [
...passwordValidators,
]);
}
function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
for (const { validates, message, validator } of validators) {
if (!validator(args[validates])) {
throw new HttpError(422, `Validation failed: ${message}`, { message, field: validates })
}
}
}
{=/ isEmailAuthEnabled =}
function rethrowError(e: unknown): void {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}

View File

@ -66,11 +66,9 @@ export const hashPassword = async (password) => {
}
export const verifyPassword = async (hashedPassword, password) => {
try {
return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
} catch (error) {
console.error(error)
return false
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}

View File

@ -2,7 +2,9 @@
import { hashPassword } from '../auth.js'
import AuthError from '../AuthError.js'
{=# isUsernameOnUserEntity =}
const USERNAME_FIELD = 'username'
{=/ isUsernameOnUserEntity =}
const PASSWORD_FIELD = 'password'
// Allows flexible validation of a user entity.
@ -59,21 +61,21 @@ export const registerAuthMiddleware = (prismaClient) => {
registerPasswordHashing(prismaClient)
}
const userValidations = []
{=# isUsernameOnUserEntity =}
userValidations.push({ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username })
{=/ isUsernameOnUserEntity =}
{=# isPasswordOnUserEntity =}
userValidations.push({ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password })
userValidations.push({ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 })
userValidations.push({ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) })
{=/ isPasswordOnUserEntity =}
const validateUser = (user, args, action) => {
user = user || {}
const defaultValidations = []
{=# isUsernameOnUserEntity =}
defaultValidations.push({ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username })
{=/ isUsernameOnUserEntity =}
{=# isPasswordOnUserEntity =}
defaultValidations.push({ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password })
defaultValidations.push({ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 })
defaultValidations.push({ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) })
{=/ isPasswordOnUserEntity =}
const validations = [
...(args._waspSkipDefaultValidations ? [] : defaultValidations),
...(args._waspSkipDefaultValidations ? [] : userValidations),
...(args._waspCustomValidations || [])
]

View File

@ -0,0 +1,5 @@
const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/
export function isValidEmail(input: string): boolean {
return input.match(validEmailRegex) !== null
}

View File

@ -16,3 +16,7 @@ export { Server } from 'http'
{=# isExternalAuthEnabled =}
export { GetUserFieldsFn } from '../auth/providers/oauth/types';
{=/ isExternalAuthEnabled =}
{=# isEmailAuthEnabled =}
export { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from '../auth/providers/email/types';
{=/ isEmailAuthEnabled =}

View File

@ -53,7 +53,7 @@
"file",
"server/package.json"
],
"cd6bef87942a19586777c8a244d4a74da49fa04ebadc091143273c2fcfd2c6d1"
"e8b22a2f3d10e4a20d4f90ef79187fc641888de8f5a40aa14c12537b3cabc21a"
],
[
[
@ -214,7 +214,7 @@
"file",
"server/src/types/index.ts"
],
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -10,6 +10,7 @@
"lodash.merge": "^4.6.2",
"morgan": "~1.10.0",
"patch-package": "^6.4.7",
"rate-limiter-flexible": "^2.4.1",
"secure-password": "^4.0.0",
"uuid": "^9.0.0"
},

View File

@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
export { Application } from 'express'
export { Server } from 'http'

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"cd6bef87942a19586777c8a244d4a74da49fa04ebadc091143273c2fcfd2c6d1"
"e8b22a2f3d10e4a20d4f90ef79187fc641888de8f5a40aa14c12537b3cabc21a"
],
[
[
@ -221,7 +221,7 @@
"file",
"server/src/types/index.ts"
],
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -10,6 +10,7 @@
"lodash.merge": "^4.6.2",
"morgan": "~1.10.0",
"patch-package": "^6.4.7",
"rate-limiter-flexible": "^2.4.1",
"secure-password": "^4.0.0",
"uuid": "^9.0.0"
},

View File

@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
export { Application } from 'express'
export { Server } from 'http'

View File

@ -33,6 +33,7 @@ waspComplexTest/.wasp/out/server/src/core/AuthError.js
waspComplexTest/.wasp/out/server/src/core/HttpError.js
waspComplexTest/.wasp/out/server/src/core/auth.js
waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js
waspComplexTest/.wasp/out/server/src/core/auth/validators.ts
waspComplexTest/.wasp/out/server/src/dbClient.ts
waspComplexTest/.wasp/out/server/src/dbSeed/types.ts
waspComplexTest/.wasp/out/server/src/email/core/helpers.ts

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"f32445341b14c6e04eb4e5c4f8f7e2c9a55dcb154cbc9823b6a24eb57700b4e4"
"43785eb2123f65d8555de544f14dca978dff4f0299d32885beabe12afb8c73d7"
],
[
[
@ -144,7 +144,7 @@
"file",
"server/src/auth/providers/oauth/createRouter.ts"
],
"3743bd9f6b01ba355ae9af62637bd66f327615664552869397c1feb025273dad"
"63dbe409a2de70c55e3f4c01b5faa1da6d09ac0947ff90133c6c616c57ad75c7"
],
[
[
@ -179,7 +179,7 @@
"file",
"server/src/auth/utils.ts"
],
"f956a964d5973e8521fb5c1e514a5da9d9c5e52ed3352e4d3373eb5c084872b3"
"e5b365c0914f8da5dfeb721db11bb241b97ce5f82b59537786747bdb6521bf93"
],
[
[
@ -207,14 +207,21 @@
"file",
"server/src/core/auth.js"
],
"968abe76a1cf4627b2bce2b9504558dbb1a8e5a50690262d6d4cd9506066ee72"
"da2adb670df6bb76d9df157a44056c7f13bf16a2eb5b78fa90dbaa8d98674689"
],
[
[
"file",
"server/src/core/auth/prismaMiddleware.js"
],
"14746535104eaaefeeb0befda6d4472177e367d906c005706f740802f6a57240"
"72352443cb7f3f72e7608270d19f7e541b1b1f233fa995391f253e1585efc71c"
],
[
[
"file",
"server/src/core/auth/validators.ts"
],
"d7cfe22168d66e0d346c2aec33573c0346c9f0f5c854d3f204bed5b4c315da87"
],
[
[
@ -438,7 +445,7 @@
"file",
"server/src/types/index.ts"
],
"d14074812de8055f6dbeb67152c90c2eb94defa0a42749984069fbdcea7bc7af"
"6b9dcc39aee89af771c01c9daf84aa769968081dd9b1b22a96e98f09366b06a8"
],
[
[
@ -592,14 +599,14 @@
"file",
"web-app/src/auth/forms/Login.jsx"
],
"a0bf5bc76dc48aacd73436c4dc15881f039f7f7d3d9a5b2e9dabda87b72d24b0"
"cc899de975012417da95738016650b4d7416475a50fd730bae08c10b99c58d52"
],
[
[
"file",
"web-app/src/auth/forms/Signup.jsx"
],
"cba167b5decece31212894297a27cf2509bccb6a55fc0fba0a54322602d9d21e"
"413ba86964bbe7735ed102618d5c4efc8187eae2feb73cae72f2ddfda55a53b3"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -14,6 +14,7 @@
"passport-google-oauth20": "2.0.0",
"patch-package": "^6.4.7",
"pg-boss": "^8.4.2",
"rate-limiter-flexible": "^2.4.1",
"react-redux": "^7.1.3",
"redux": "^4.0.5",
"secure-password": "^4.0.0",

View File

@ -6,11 +6,12 @@ import { v4 as uuidv4 } from 'uuid'
import prisma from '../../../dbClient.js'
import waspServerConfig from '../../../config.js'
import { sign } from '../../../core/auth.js'
import { authConfig, contextWithUserEntity } from "../../utils.js"
import { authConfig, contextWithUserEntity, createUser } from "../../utils.js"
import type { User } from '../../../entities';
import type { ProviderConfig, RequestWithWasp } from "../types.js"
import type { GetUserFieldsFn } from "./types.js"
import { handleRejection } from "../../../utils.js"
// For oauth providers, we have an endpoint /login to get the auth URL,
// and the /callback endpoint which is used to get the actual access_token and the user info.
@ -31,24 +32,24 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat
session: false,
failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath
}),
async function (req: RequestWithWasp, res) {
const providerProfile = req?.wasp?.providerProfile;
handleRejection(async function (req: RequestWithWasp, res) {
const providerProfile = req?.wasp?.providerProfile;
if (!providerProfile) {
throw new Error(`Missing ${provider.displayName} provider profile on request. This should not happen! Please contact Wasp.`);
} else if (!providerProfile.id) {
throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`);
}
if (!providerProfile) {
throw new Error(`Missing ${provider.displayName} provider profile on request. This should not happen! Please contact Wasp.`);
} else if (!providerProfile.id) {
throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`);
}
// Wrap call to getUserFieldsFn so we can invoke only if needed.
const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile });
// TODO: In the future we could make this configurable, possibly associating an external account
// with the currently logged in account, or by some DB lookup.
const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields);
// Wrap call to getUserFieldsFn so we can invoke only if needed.
const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile });
// TODO: In the future we could make this configurable, possibly associating an external account
// with the currently logged in account, or by some DB lookup.
const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields);
const token = await sign(user.id);
res.json({ token });
}
const token = await sign(user.id);
res.json({ token });
})
)
return router;
@ -81,5 +82,5 @@ async function findOrCreateUserByExternalAuthAssociation(
}
}
return prisma.user.create({ data: userAndExternalAuthAssociation })
return createUser(userAndExternalAuthAssociation)
}

View File

@ -1,5 +1,13 @@
import { sign, verify } from '../core/auth.js'
import AuthError from '../core/AuthError.js'
import HttpError from '../core/HttpError.js'
import prisma from '../dbClient.js'
import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js'
import { type User } from '../entities/index.js'
import waspServerConfig from '../config.js';
import { type Prisma } from '@prisma/client';
type UserId = User['id']
export const contextWithUserEntity = {
entities: {
@ -11,3 +19,54 @@ export const authConfig = {
failureRedirectPath: "/login",
successRedirectPath: "/",
}
export async function findUserBy<K extends keyof User>(where: { [key in K]: User[K] }): Promise<User> {
return prisma.user.findUnique({ where });
}
export async function createUser(data: Prisma.UserCreateInput): Promise<User> {
try {
return await prisma.user.create({ data })
} catch (e) {
rethrowError(e);
}
}
export async function deleteUser(user: User): Promise<User> {
try {
return await prisma.user.delete({ where: { id: user.id } })
} catch (e) {
rethrowError(e);
}
}
export async function createAuthToken(user: User): Promise<string> {
return sign(user.id);
}
export async function verifyToken(token: string): Promise<{ id: any }> {
return verify(token);
}
// If an user exists, we don't want to leak information
// about it. Pretending that we're doing some work
// will make it harder for an attacker to determine
// if a user exists or not.
// NOTE: Attacker measuring time to response can still determine
// if a user exists or not. We'll be able to avoid it when
// we implement e-mail sending via jobs.
export async function doFakeWork() {
const timeToWork = Math.floor(Math.random() * 1000) + 1000;
return sleep(timeToWork);
}
function rethrowError(e: unknown): void {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}

View File

@ -65,11 +65,9 @@ export const hashPassword = async (password) => {
}
export const verifyPassword = async (hashedPassword, password) => {
try {
return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
} catch (error) {
console.error(error)
return false
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}

View File

@ -58,17 +58,17 @@ export const registerAuthMiddleware = (prismaClient) => {
registerPasswordHashing(prismaClient)
}
const userValidations = []
userValidations.push({ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username })
userValidations.push({ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password })
userValidations.push({ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 })
userValidations.push({ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) })
const validateUser = (user, args, action) => {
user = user || {}
const defaultValidations = []
defaultValidations.push({ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username })
defaultValidations.push({ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password })
defaultValidations.push({ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 })
defaultValidations.push({ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) })
const validations = [
...(args._waspSkipDefaultValidations ? [] : defaultValidations),
...(args._waspSkipDefaultValidations ? [] : userValidations),
...(args._waspCustomValidations || [])
]

View File

@ -0,0 +1,5 @@
const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/
export function isValidEmail(input: string): boolean {
return input.match(validEmailRegex) !== null
}

View File

@ -13,3 +13,4 @@ export { Application } from 'express'
export { Server } from 'http'
export { GetUserFieldsFn } from '../auth/providers/oauth/types';

View File

@ -1,4 +1,3 @@
import React, { useState } from 'react'
import Auth from './Auth'
const LoginForm = ({ appearance, logo, socialLayout }) => {

View File

@ -1,5 +1,3 @@
import React, { useState } from 'react'
import Auth from './Auth'
const SignupForm = ({ appearance, logo, socialLayout }) => {

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"29403f5ff8c77da8139d70fc36e51be86e7591449574f063ed134fc45fbef911"
"3efe339177fc65c14215d1b2a6e62a338a7f605b01f77d0eebb771e1aad1751a"
],
[
[
@ -235,7 +235,7 @@
"file",
"server/src/types/index.ts"
],
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"pg-boss","version":"^8.4.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"pg-boss","version":"^8.4.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -11,6 +11,7 @@
"morgan": "~1.10.0",
"patch-package": "^6.4.7",
"pg-boss": "^8.4.2",
"rate-limiter-flexible": "^2.4.1",
"secure-password": "^4.0.0",
"uuid": "^9.0.0"
},

View File

@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
export { Application } from 'express'
export { Server } from 'http'

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"d8c1f86ddb19cc609e1dbe6ddd87e33567a19e882be183fe714d34631cfe5d95"
"77483f50baac1f1980c0a2360d9c6296c84e7c52a499c1ffc982cbe19edb1c1b"
],
[
[
@ -221,7 +221,7 @@
"file",
"server/src/types/index.ts"
],
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -10,6 +10,7 @@
"lodash.merge": "^4.6.2",
"morgan": "~1.10.0",
"patch-package": "^6.4.7",
"rate-limiter-flexible": "^2.4.1",
"secure-password": "^4.0.0",
"uuid": "^9.0.0"
},

View File

@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
export { Application } from 'express'
export { Server } from 'http'

View File

@ -1,24 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"description" TEXT NOT NULL,
"isDone" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,9 +0,0 @@
-- DropIndex
DROP INDEX "User_email_key";
-- AlterTable
ALTER TABLE "User"
RENAME COLUMN "email" TO "username";
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

View File

@ -1,16 +0,0 @@
-- CreateTable
CREATE TABLE "SocialLogin" (
"id" SERIAL NOT NULL,
"provider" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId");
-- AddForeignKey
ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,44 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT,
"password" TEXT,
"isEmailVerified" BOOLEAN NOT NULL DEFAULT false,
"emailVerificationSentAt" TIMESTAMP(3),
"passwordResetSentAt" TIMESTAMP(3),
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SocialLogin" (
"id" SERIAL NOT NULL,
"provider" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"description" TEXT NOT NULL,
"isDone" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId");
-- AddForeignKey
ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -16,7 +16,7 @@ export function App({ children }) {
{user && (
<div className="flex gap-3 items-center">
<div>
Hello, <Link to="/profile">{user.username}</Link>
Hello, <Link to="/profile">{user.email}</Link>
</div>
<div>
<button className="btn btn-primary" onClick={logout}>

View File

@ -8,16 +8,21 @@ async function fetchCustomRoute() {
console.log(res.data)
}
export const ProfilePage = ({ user: { username } }: { user: User }) => {
export const ProfilePage = ({
user: { email, isEmailVerified },
}: {
user: User
}) => {
useEffect(() => {
fetchCustomRoute()
}, []);
}, [])
return (
<>
<h2>Profile page</h2>
<div>
Hello <strong>{username}</strong>!
Hello <strong>{email}</strong>! Your status is{' '}
<strong>{isEmailVerified ? 'verfied' : 'unverified'}</strong>.
</div>
<br />
<Link to="/">Go to dashboard</Link>

View File

@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Link, useHistory, useLocation } from 'react-router-dom'
import { verifyEmail } from '@wasp/auth/email'
export function EmailVerification() {
const location = useLocation()
const history = useHistory()
const verifyEmailInfo = useMutation<
{ success: boolean },
{ data: { success: boolean; reason?: string } },
{ token: string },
unknown
>({
mutationFn: verifyEmail,
})
useEffect(() => {
const token = new URLSearchParams(location.search).get('token')
if (!token) {
history.push('/login')
return
}
verifyEmailInfo.mutateAsync({ token })
}, [location])
return (
<div>
<h1>Email verification</h1>
{verifyEmailInfo.isLoading && <p>Verifying email...</p>}
{verifyEmailInfo.isError && (
<p>
Failed to verify email. Reason: {verifyEmailInfo.error.data?.reason}{' '}
token
</p>
)}
{verifyEmailInfo.isSuccess && verifyEmailInfo.data.success && (
<>
<p>Email verified successfully. </p>
<Link to="/login">Login</Link>
</>
)}
</div>
)
}

View File

@ -0,0 +1,36 @@
import { useHistory, useLocation } from 'react-router-dom'
import { resetPassword } from '@wasp/auth/email'
export function PasswordReset() {
const location = useLocation()
const history = useHistory()
const token = new URLSearchParams(location.search).get('token')
const handleSubmit = async (e: any) => {
e.preventDefault()
if (!token) {
alert('Invalid token!')
return
}
const password = e.target[0].value as string
const passwordConfirmation = e.target[1].value as string
if (!password || password !== passwordConfirmation) {
alert("Passwords don't match!")
return
}
try {
await resetPassword({ password, token })
history.push('/login')
} catch (e: any) {
alert(e.message)
}
}
return (
<form className="flex flex-col gap-3 max-w-xs" onSubmit={handleSubmit}>
<h1>Reset password</h1>
<input type="password" placeholder="Enter new password" />
<input type="password" placeholder="Confirm new password" />
<button className="btn btn-primary">Reset password</button>
</form>
)
}

View File

@ -0,0 +1,24 @@
import { requestPasswordReset } from '@wasp/auth/email'
import { errorMessage } from '@wasp/utils'
export function RequestPasswordReset() {
const handleSubmit = async (e: any) => {
e.preventDefault()
const email = e.target[0].value as string
try {
await requestPasswordReset({ email })
window.alert('Check your e-mail!')
} catch (e) {
window.alert(errorMessage(e))
}
}
return (
<div>
<form className="flex flex-col gap-3 max-w-xs" onSubmit={handleSubmit}>
<h1>Request password reset</h1>
<input type="email" placeholder="Enter your e-mail" />
<button className="btn btn-primary">Request password reset</button>
</form>
</div>
)
}

View File

@ -1,7 +1,6 @@
import React from 'react'
import { Link } from 'react-router-dom'
import SignupForm from '@wasp/auth/forms/Signup'
import { SignupForm } from '@wasp/auth/email'
import getNumTasks from '@wasp/queries/getNumTasks'
import { useQuery } from '@wasp/queries'
import { getTotalTaskCountMessage } from './helpers'

View File

@ -1,6 +1,6 @@
import { FooBar } from '@wasp/apis/types'
export const fooBar : FooBar = (req, res, context) => {
export const fooBar: FooBar = (req, res, context) => {
res.set('Access-Control-Allow-Origin', '*') // Example of modifying headers to override Wasp default CORS middleware.
res.json({ msg: `Hello, ${context.user?.username || "stranger"}!` })
res.json({ msg: `Hello, ${context.user?.email || 'stranger'}!` })
}

View File

@ -0,0 +1,26 @@
import {
GetPasswordResetEmailContentFn,
GetVerificationEmailContentFn,
} from '@wasp/types'
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({
passwordResetLink,
}) => ({
subject: 'Password reset',
text: `Click the link below to reset your password: ${passwordResetLink}`,
html: `
<p>Click the link below to reset your password</p>
<a href="${passwordResetLink}">Reset password</a>
`,
})
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({
verificationLink,
}) => ({
subject: 'Verify your email',
text: `Click the link below to verify your email: ${verificationLink}`,
html: `
<p>Click the link below to verify your email</p>
<a href="${verificationLink}">Verify email</a>
`,
})

View File

@ -1,19 +1,15 @@
import { generateAvailableUsername } from '@wasp/core/auth.js'
export function config() {
console.log('Inside user-supplied Google config')
return {
clientID: process.env['GOOGLE_CLIENT_ID'],
clientSecret: process.env['GOOGLE_CLIENT_SECRET'],
scope: ['profile'],
scope: ['profile', 'email'],
}
}
export async function getUserFields(_context, args) {
console.log('Inside user-supplied Google getUserFields')
const username = await generateAvailableUsername(
args.profile.displayName.split(' '),
{ separator: '.' }
)
return { username }
const email =
args.profile.emails.length > 0 ? args.profile.emails[0].value : null
return { email, isEmailVerified: !!email }
}

View File

@ -10,17 +10,32 @@ app todoApp {
],
auth: {
userEntity: User,
externalAuthEntity: SocialLogin,
externalAuthEntity: SocialLogin,
methods: {
usernameAndPassword: {},
google: {
configFn: import { config } from "@server/auth/google.js",
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js"
},
gitHub: {
configFn: import { config } from "@server/auth/github.js",
getUserFieldsFn: import { getUserFields } from "@server/auth/github.js"
}
// usernameAndPassword: {},
google: {
configFn: import { config } from "@server/auth/google.js",
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js"
},
// gitHub: {
// configFn: import { config } from "@server/auth/github.js",
// getUserFieldsFn: import { getUserFields } from "@server/auth/github.js"
// }
email: {
allowUnverifiedLogin: true,
fromField: {
name: "ToDO App",
email: "mihovil@ilakovac.com"
},
emailVerification: {
getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js",
clientRoute: EmailVerificationRoute,
},
passwordReset: {
getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
clientRoute: PasswordResetRoute
},
},
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/profile"
@ -39,14 +54,26 @@ app todoApp {
import { prodSeed } from "@server/dbSeeds.js"
]
},
emailSender: {
provider: SMTP,
defaultFrom: {
email: "mihovil@ilakovac.com"
},
},
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
tasks Task[]
// Email auth
email String? @unique
password String?
isEmailVerified Boolean @default(false)
emailVerificationSentAt DateTime?
passwordResetSentAt DateTime?
// Social login
externalAuthAssociations SocialLogin[]
// Business logic
tasks Task[]
psl=}
entity SocialLogin {=psl
@ -77,6 +104,21 @@ page LoginPage {
component: import Login from "@client/pages/auth/Login.tsx"
}
route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage }
page PasswordResetPage {
component: import { PasswordReset } from "@client/pages/auth/PasswordReset.tsx",
}
route EmailVerificationRoute { path: "/email-verification-", to: EmailVerificationPage }
page EmailVerificationPage {
component: import { EmailVerification } from "@client/pages/auth/EmailVerification.tsx",
}
route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage }
page RequestPasswordResetPage {
component: import { RequestPasswordReset } from "@client/pages/auth/RequestPasswordReset.tsx",
}
route HomeRoute { path: "/", to: MainPage }
page MainPage {
authRequired: true,

View File

@ -23,7 +23,6 @@ import Wasp.AppSpec.Route (Route)
makeEnumType ''EmailProvider
makeEnumType ''DbSystem
makeDeclType ''App
makeDeclType ''Page
makeDeclType ''Route
makeDeclType ''Query
@ -32,6 +31,7 @@ makeEnumType ''JobExecutor
makeDeclType ''Job
makeEnumType ''HttpMethod
makeDeclType ''Api
makeDeclType ''App
{- ORMOLU_DISABLE -}
-- | Collection of domain types that are standard for Wasp, that define what the Wasp language looks like.

View File

@ -5,17 +5,22 @@ module Wasp.AppSpec.App.Auth
( Auth (..),
AuthMethods (..),
ExternalAuthConfig (..),
EmailAuthConfig (..),
usernameAndPasswordConfig,
isUsernameAndPasswordAuthEnabled,
areBothExternalAndUsernameAndPasswordAuthEnabled,
isExternalAuthEnabled,
isGoogleAuthEnabled,
isGitHubAuthEnabled,
isEmailAuthEnabled,
)
where
import Data.Data (Data)
import Data.Maybe (isJust)
import Wasp.AppSpec.App.Auth.EmailVerification (EmailVerificationConfig)
import Wasp.AppSpec.App.Auth.PasswordReset (PasswordResetConfig)
import Wasp.AppSpec.App.EmailSender (EmailFromField)
import Wasp.AppSpec.Core.Ref (Ref)
import Wasp.AppSpec.Entity (Entity)
import Wasp.AppSpec.ExtImport (ExtImport)
@ -32,7 +37,8 @@ data Auth = Auth
data AuthMethods = AuthMethods
{ usernameAndPassword :: Maybe UsernameAndPasswordConfig,
google :: Maybe ExternalAuthConfig,
gitHub :: Maybe ExternalAuthConfig
gitHub :: Maybe ExternalAuthConfig,
email :: Maybe EmailAuthConfig
}
deriving (Show, Eq, Data)
@ -48,6 +54,14 @@ data ExternalAuthConfig = ExternalAuthConfig
}
deriving (Show, Eq, Data)
data EmailAuthConfig = EmailAuthConfig
{ fromField :: EmailFromField,
emailVerification :: EmailVerificationConfig,
passwordReset :: PasswordResetConfig,
allowUnverifiedLogin :: Maybe Bool
}
deriving (Show, Eq, Data)
usernameAndPasswordConfig :: UsernameAndPasswordConfig
usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing
@ -65,3 +79,6 @@ isGoogleAuthEnabled = isJust . google . methods
isGitHubAuthEnabled :: Auth -> Bool
isGitHubAuthEnabled = isJust . gitHub . methods
isEmailAuthEnabled :: Auth -> Bool
isEmailAuthEnabled = isJust . email . methods

View File

@ -0,0 +1,14 @@
{-# LANGUAGE DeriveDataTypeable #-}
module Wasp.AppSpec.App.Auth.EmailVerification where
import Data.Data (Data)
import Wasp.AppSpec.Core.Ref (Ref)
import Wasp.AppSpec.ExtImport (ExtImport)
import Wasp.AppSpec.Route (Route)
data EmailVerificationConfig = EmailVerificationConfig
{ getEmailContentFn :: Maybe ExtImport,
clientRoute :: Ref Route
}
deriving (Show, Eq, Data)

View File

@ -0,0 +1,14 @@
{-# LANGUAGE DeriveDataTypeable #-}
module Wasp.AppSpec.App.Auth.PasswordReset where
import Data.Data (Data)
import Wasp.AppSpec.Core.Ref (Ref)
import Wasp.AppSpec.ExtImport (ExtImport)
import Wasp.AppSpec.Route (Route)
data PasswordResetConfig = PasswordResetConfig
{ getEmailContentFn :: Maybe ExtImport,
clientRoute :: Ref Route
}
deriving (Show, Eq, Data)

View File

@ -1,8 +1,20 @@
module Wasp.AppSpec.Util (isPgBossJobExecutorUsed) where
module Wasp.AppSpec.Util
( isPgBossJobExecutorUsed,
getRoutePathFromRef,
)
where
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.Core.Ref as AS.Ref
import qualified Wasp.AppSpec.Job as Job
import qualified Wasp.AppSpec.Route as AS.Route
isPgBossJobExecutorUsed :: AppSpec -> Bool
isPgBossJobExecutorUsed spec = any (\(_, job) -> Job.executor job == Job.PgBoss) (AS.getJobs spec)
getRoutePathFromRef :: AS.AppSpec -> AS.Ref.Ref AS.Route.Route -> String
getRoutePathFromRef spec ref = path
where
route = AS.resolveRef spec ref
path = AS.Route.path . snd $ route

View File

@ -45,7 +45,10 @@ validateAppSpec spec =
concat
[ validateWasp spec,
validateAppAuthIsSetIfAnyPageRequiresAuth spec,
validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec,
validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec,
validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec,
validateEmailSenderIsDefinedIfEmailAuthIsUsed spec,
validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec,
validateDbIsPostgresIfPgBossUsed spec
]
@ -111,6 +114,18 @@ validateAppAuthIsSetIfAnyPageRequiresAuth spec =
where
anyPageRequiresAuth = any ((== Just True) . Page.authRequired) (snd <$> AS.getPages spec)
validateOnlyEmailOrUsernameAndPasswordAuthIsUsed :: AppSpec -> [ValidationError]
validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec =
case App.auth (snd $ getApp spec) of
Nothing -> []
Just auth ->
[ GenericValidationError
"Expected app.auth to use either email or username and password authentication, but not both."
| areBothAuthMethodsUsed
]
where
areBothAuthMethodsUsed = Auth.isEmailAuthEnabled auth && Auth.isUsernameAndPasswordAuthEnabled auth
validateDbIsPostgresIfPgBossUsed :: AppSpec -> [ValidationError]
validateDbIsPostgresIfPgBossUsed spec =
[ GenericValidationError
@ -133,6 +148,36 @@ validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec = cas
("password", Entity.Field.FieldTypeScalar Entity.Field.String, "String")
]
validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed :: AppSpec -> [ValidationError]
validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec = case App.auth (snd $ getApp spec) of
Nothing -> []
Just auth ->
if not $ Auth.isEmailAuthEnabled auth
then []
else
let userEntity = snd $ AS.resolveRef spec (Auth.userEntity auth)
userEntityFields = Entity.getFields userEntity
in concatMap
(validateEntityHasField "app.auth.userEntity" userEntityFields)
[ ("email", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.String), "String"),
("password", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.String), "String"),
("isEmailVerified", Entity.Field.FieldTypeScalar Entity.Field.Boolean, "Boolean"),
("emailVerificationSentAt", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.DateTime), "DateTime?"),
("passwordResetSentAt", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.DateTime), "DateTime?")
]
validateEmailSenderIsDefinedIfEmailAuthIsUsed :: AppSpec -> [ValidationError]
validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of
Nothing -> []
Just auth ->
if not $ Auth.isEmailAuthEnabled auth
then []
else case App.emailSender app of
Nothing -> [GenericValidationError "app.emailSender must be specified when using email auth."]
Just _ -> []
where
app = snd $ getApp spec
validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed :: AppSpec -> [ValidationError]
validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec = case App.auth (snd $ getApp spec) of
Nothing -> []

View File

@ -4,6 +4,7 @@ import Data.Maybe (fromJust)
import StrongPath (relfile)
import qualified Wasp.AppSpec.App.Dependency as App.Dependency
import Wasp.Generator.AuthProviders.Common (makeProviderId)
import qualified Wasp.Generator.AuthProviders.Email as E
import qualified Wasp.Generator.AuthProviders.Local as L
import qualified Wasp.Generator.AuthProviders.OAuth as OA
@ -33,3 +34,10 @@ localAuthProvider =
{ L._providerId = fromJust $ makeProviderId "local",
L._displayName = "Username and password"
}
emailAuthProvider :: E.EmailAuthProvider
emailAuthProvider =
E.EmailAuthProvider
{ E._providerId = fromJust $ makeProviderId "email",
E._displayName = "Email and password"
}

View File

@ -0,0 +1,41 @@
module Wasp.Generator.AuthProviders.Email
( providerId,
displayName,
serverLoginUrl,
serverSignupUrl,
serverRequestPasswordResetUrl,
serverResetPasswordUrl,
serverVerifyEmailUrl,
EmailAuthProvider (..),
)
where
import Wasp.Generator.AuthProviders.Common (ProviderId, fromProviderId)
data EmailAuthProvider = EmailAuthProvider
{ -- Unique identifier of the auth provider
_providerId :: ProviderId,
-- Used for pretty printing
_displayName :: String
}
providerId :: EmailAuthProvider -> String
providerId = fromProviderId . _providerId
displayName :: EmailAuthProvider -> String
displayName = _displayName
serverLoginUrl :: EmailAuthProvider -> String
serverLoginUrl provider = "/auth/" ++ providerId provider ++ "/login"
serverSignupUrl :: EmailAuthProvider -> String
serverSignupUrl provider = "/auth/" ++ providerId provider ++ "/signup"
serverRequestPasswordResetUrl :: EmailAuthProvider -> String
serverRequestPasswordResetUrl provider = "/auth/" ++ providerId provider ++ "/request-password-reset"
serverResetPasswordUrl :: EmailAuthProvider -> String
serverResetPasswordUrl provider = "/auth/" ++ providerId provider ++ "/reset-password"
serverVerifyEmailUrl :: EmailAuthProvider -> String
serverVerifyEmailUrl provider = "/auth/" ++ providerId provider ++ "/verify-email"

View File

@ -159,7 +159,8 @@ npmDepsForWasp spec =
("helmet", "^6.0.0"),
("patch-package", "^6.4.7"),
("uuid", "^9.0.0"),
("lodash.merge", "^4.6.2")
("lodash.merge", "^4.6.2"),
("rate-limiter-flexible", "^2.4.1")
]
++ depsRequiredByPassport spec
++ depsRequiredByJobs spec
@ -369,6 +370,7 @@ genExportedTypesDir spec =
[ C.mkTmplFdWithData [relfile|src/types/index.ts|] (Just tmplData)
]
where
tmplData = object ["isExternalAuthEnabled" .= isExternalAuthEnabled]
tmplData = object ["isExternalAuthEnabled" .= isExternalAuthEnabled, "isEmailAuthEnabled" .= isEmailAuthEnabled]
isExternalAuthEnabled = AS.App.Auth.isExternalAuthEnabled <$> maybeAuth
isEmailAuthEnabled = AS.App.Auth.isEmailAuthEnabled <$> maybeAuth
maybeAuth = AS.App.auth $ snd $ getApp spec

View File

@ -0,0 +1,104 @@
module Wasp.Generator.ServerGenerator.Auth.EmailAuthG
( genEmailAuth,
)
where
import Data.Aeson (object, (.=))
import Data.Maybe (fromMaybe)
import StrongPath
( Dir,
File',
Path,
Path',
Posix,
Rel,
reldirP,
relfile,
(</>),
)
import qualified StrongPath as SP
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import qualified Wasp.AppSpec.App.Auth.EmailVerification as AS.Auth.EmailVerification
import qualified Wasp.AppSpec.App.Auth.PasswordReset as AS.Auth.PasswordReset
import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender
import Wasp.AppSpec.Util (getRoutePathFromRef)
import Wasp.Generator.AuthProviders (emailAuthProvider)
import qualified Wasp.Generator.AuthProviders.Email as Email
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.ServerGenerator.Common as C
import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson)
import Wasp.Util ((<++>))
genEmailAuth :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft]
genEmailAuth spec auth = case emailAuth of
Just emailAuthConfig ->
sequence
[ genEmailAuthConfig spec emailAuthConfig,
genTypes emailAuthConfig
]
<++> genRoutes
_ -> return []
where
emailAuth = AS.Auth.email $ AS.Auth.methods auth
genEmailAuthConfig :: AS.AppSpec -> AS.Auth.EmailAuthConfig -> Generator FileDraft
genEmailAuthConfig spec emailAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
where
tmplFile = C.srcDirInServerTemplatesDir </> SP.castRel authIndexFileInSrcDir
dstFile = C.serverSrcDirInServerRootDir </> authIndexFileInSrcDir
tmplData =
object
[ "providerId" .= Email.providerId emailAuthProvider,
"displayName" .= Email.displayName emailAuthProvider,
"fromField" .= fromFieldJson,
"emailVerificationClientRoute" .= emailVerificationClientRoute,
"passwordResetClientRoute" .= passwordResetClientRoute,
"getPasswordResetEmailContent" .= getPasswordResetEmailContent,
"getVerificationEmailContent" .= getVerificationEmailContent,
"allowUnverifiedLogin" .= fromMaybe False (AS.Auth.allowUnverifiedLogin emailAuthConfig)
]
fromFieldJson =
object
[ "name" .= fromMaybe "" maybeName,
"email" .= email
]
fromField = AS.Auth.fromField emailAuthConfig
maybeName = AS.EmailSender.name fromField
email = AS.EmailSender.email fromField
emailVerificationClientRoute = getRoutePathFromRef spec $ AS.Auth.EmailVerification.clientRoute emailVerification
passwordResetClientRoute = getRoutePathFromRef spec $ AS.Auth.PasswordReset.clientRoute passwordReset
getPasswordResetEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.PasswordReset.getEmailContentFn passwordReset
getVerificationEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.EmailVerification.getEmailContentFn emailVerification
emailVerification = AS.Auth.emailVerification emailAuthConfig
passwordReset = AS.Auth.passwordReset emailAuthConfig
relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir)
relPathToServerSrcDir = [reldirP|../../../|]
authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File'
authIndexFileInSrcDir = [relfile|auth/providers/config/email.ts|]
genRoutes :: Generator [FileDraft]
genRoutes =
sequence
[ copyTmplFile [relfile|auth/providers/email/signup.ts|],
copyTmplFile [relfile|auth/providers/email/login.ts|],
copyTmplFile [relfile|auth/providers/email/resetPassword.ts|],
copyTmplFile [relfile|auth/providers/email/requestPasswordReset.ts|],
copyTmplFile [relfile|auth/providers/email/verifyEmail.ts|]
]
where
copyTmplFile = return . C.mkSrcTmplFd
genTypes :: AS.Auth.EmailAuthConfig -> Generator FileDraft
genTypes _emailAuthConfig = return $ C.mkTmplFdWithData tmplFile (Just tmplData)
where
tmplFile = C.srcDirInServerTemplatesDir </> [relfile|auth/providers/email/types.ts|]
tmplData = object []

View File

@ -19,11 +19,13 @@ import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import Wasp.AppSpec.Valid (doesUserEntityContainField, getApp)
import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider, localAuthProvider)
import Wasp.Generator.AuthProviders (emailAuthProvider, gitHubAuthProvider, googleAuthProvider, localAuthProvider)
import qualified Wasp.Generator.AuthProviders.Email as EmailProvider
import qualified Wasp.Generator.AuthProviders.Local as LocalProvider
import qualified Wasp.Generator.AuthProviders.OAuth as OAuthProvider
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth)
import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth)
import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth)
import qualified Wasp.Generator.ServerGenerator.Common as C
@ -36,6 +38,7 @@ genAuth spec = case maybeAuth of
sequence
[ genCoreAuth auth,
genAuthMiddleware spec auth,
genFileCopy [relfile|core/auth/validators.ts|],
genAuthRoutesIndex auth,
genMeRoute auth,
genUtils auth,
@ -44,6 +47,7 @@ genAuth spec = case maybeAuth of
]
<++> genLocalAuth auth
<++> genOAuthAuth spec auth
<++> genEmailAuth spec auth
Nothing -> return []
where
maybeAuth = AS.App.auth $ snd $ getApp spec
@ -82,9 +86,11 @@ genAuthMiddleware spec auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile
isPasswordOnUserEntity = doesUserEntityContainField spec "password" == Just True
isUsernameOnUserEntity = doesUserEntityContainField spec "username" == Just True
in object
[ "userEntityUpper" .= (userEntityName :: String),
[ "userEntityUpper" .= userEntityName,
"isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth,
"isPasswordOnUserEntity" .= isPasswordOnUserEntity,
"isUsernameOnUserEntity" .= isUsernameOnUserEntity
"isUsernameOnUserEntity" .= isUsernameOnUserEntity,
"isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth
]
genAuthRoutesIndex :: AS.Auth.Auth -> Generator FileDraft
@ -118,7 +124,8 @@ genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplDat
[ "userEntityUpper" .= (userEntityName :: String),
"userEntityLower" .= (Util.toLowerFirst userEntityName :: String),
"failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth,
"successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth
"successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth,
"isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth
]
utilsFileInSrcDir :: Path' (Rel C.ServerSrcDir) File'
@ -136,5 +143,6 @@ genProvidersIndex auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers
concat
[ [OAuthProvider.providerId gitHubAuthProvider | AS.Auth.isGitHubAuthEnabled auth],
[OAuthProvider.providerId googleAuthProvider | AS.Auth.isGoogleAuthEnabled auth],
[LocalProvider.providerId localAuthProvider | AS.Auth.isUsernameAndPasswordAuthEnabled auth]
[LocalProvider.providerId localAuthProvider | AS.Auth.isUsernameAndPasswordAuthEnabled auth],
[EmailProvider.providerId emailAuthProvider | AS.Auth.isEmailAuthEnabled auth]
]

View File

@ -0,0 +1,96 @@
module Wasp.Generator.WebAppGenerator.Auth.EmailAuthG
( genEmailAuth,
)
where
import Data.Aeson (object, (.=))
import StrongPath (relfile)
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import Wasp.Generator.AuthProviders (emailAuthProvider)
import Wasp.Generator.AuthProviders.Email
( serverLoginUrl,
serverRequestPasswordResetUrl,
serverResetPasswordUrl,
serverSignupUrl,
serverVerifyEmailUrl,
)
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import Wasp.Generator.WebAppGenerator.Auth.Common (getOnAuthSucceededRedirectToOrDefault)
import Wasp.Generator.WebAppGenerator.Common as C
import Wasp.Util ((<++>))
genEmailAuth :: AS.Auth.Auth -> Generator [FileDraft]
genEmailAuth auth
| AS.Auth.isEmailAuthEnabled auth =
sequence
[ genIndex
]
<++> genActions
<++> genComponents auth
| otherwise = return []
genIndex :: Generator FileDraft
genIndex = return $ C.mkSrcTmplFd [relfile|auth/email/index.ts|]
genActions :: Generator [FileDraft]
genActions =
sequence
[ genLoginAction,
genSignupAction,
genPasswordResetActions,
genVerifyEmailAction
]
genLoginAction :: Generator FileDraft
genLoginAction =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/actions/login.ts|]
(object ["loginPath" .= serverLoginUrl emailAuthProvider])
genSignupAction :: Generator FileDraft
genSignupAction =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/actions/signup.ts|]
(object ["signupPath" .= serverSignupUrl emailAuthProvider])
genPasswordResetActions :: Generator FileDraft
genPasswordResetActions =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/actions/passwordReset.ts|]
( object
[ "requestPasswordResetPath" .= serverRequestPasswordResetUrl emailAuthProvider,
"resetPasswordPath" .= serverResetPasswordUrl emailAuthProvider
]
)
genVerifyEmailAction :: Generator FileDraft
genVerifyEmailAction =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/actions/verifyEmail.ts|]
(object ["verifyEmailPath" .= serverVerifyEmailUrl emailAuthProvider])
genComponents :: AS.Auth.Auth -> Generator [FileDraft]
genComponents auth =
sequence
[ genLoginComponent auth,
genSignupComponent auth
]
genLoginComponent :: AS.Auth.Auth -> Generator FileDraft
genLoginComponent auth =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/components/Login.jsx|]
(object ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth])
genSignupComponent :: AS.Auth.Auth -> Generator FileDraft
genSignupComponent auth =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/components/Signup.jsx|]
(object ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth])

View File

@ -15,6 +15,7 @@ import qualified Wasp.Generator.AuthProviders.OAuth as OAuth
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import Wasp.Generator.WebAppGenerator.Auth.Common (getOnAuthSucceededRedirectToOrDefault)
import Wasp.Generator.WebAppGenerator.Auth.EmailAuthG (genEmailAuth)
import Wasp.Generator.WebAppGenerator.Auth.LocalAuthG (genLocalAuth)
import Wasp.Generator.WebAppGenerator.Auth.OAuthAuthG (genOAuthAuth)
import Wasp.Generator.WebAppGenerator.Common as C
@ -33,6 +34,7 @@ genAuth spec =
<++> genAuthForms auth
<++> genLocalAuth auth
<++> genOAuthAuth auth
<++> genEmailAuth auth
Nothing -> return []
where
maybeAuth = AS.App.auth $ snd $ getApp spec

View File

@ -135,7 +135,8 @@ spec_Analyzer = do
Auth.AuthMethods
{ Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig,
Auth.google = Nothing,
Auth.gitHub = Nothing
Auth.gitHub = Nothing,
Auth.email = Nothing
},
Auth.onAuthFailedRedirectTo = "/",
Auth.onAuthSucceededRedirectTo = Nothing

View File

@ -9,12 +9,16 @@ import Test.Tasty.Hspec
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import qualified Wasp.AppSpec.App.Auth.EmailVerification as AS.Auth.EmailVerification
import qualified Wasp.AppSpec.App.Auth.PasswordReset as AS.Auth.PasswordReset
import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender
import qualified Wasp.AppSpec.App.Wasp as AS.Wasp
import qualified Wasp.AppSpec.Core.Decl as AS.Decl
import qualified Wasp.AppSpec.Core.Ref as AS.Core.Ref
import qualified Wasp.AppSpec.Entity as AS.Entity
import qualified Wasp.AppSpec.ExtImport as AS.ExtImport
import qualified Wasp.AppSpec.Page as AS.Page
import qualified Wasp.AppSpec.Route as AS.Route
import qualified Wasp.AppSpec.Valid as ASV
import qualified Wasp.Psl.Ast.Model as PslM
import qualified Wasp.SemanticVersion as SV
@ -90,6 +94,16 @@ spec_AppSpecValid = do
PslM.ElementField $ makeBasicPslField "password" PslM.String
]
)
let validUserEntityForEmailAuth =
AS.Entity.makeEntity
( PslM.Body
[ PslM.ElementField $ makePslField "email" PslM.String True,
PslM.ElementField $ makePslField "password" PslM.String True,
PslM.ElementField $ makePslField "isEmailVerified" PslM.Boolean False,
PslM.ElementField $ makePslField "emailVerificationSentAt" PslM.DateTime True,
PslM.ElementField $ makePslField "passwordResetSentAt" PslM.DateTime True
]
)
let validAppAuth =
AS.Auth.Auth
{ AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName,
@ -98,7 +112,8 @@ spec_AppSpecValid = do
AS.Auth.AuthMethods
{ AS.Auth.usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig,
AS.Auth.google = Nothing,
AS.Auth.gitHub = Nothing
AS.Auth.gitHub = Nothing,
AS.Auth.email = Nothing
},
AS.Auth.onAuthFailedRedirectTo = "/",
AS.Auth.onAuthSucceededRedirectTo = Nothing
@ -132,6 +147,64 @@ spec_AppSpecValid = do
ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "password" `shouldBe` Just True
ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "missing" `shouldBe` Just False
describe "should validate that UsernameAndPassword and Email auth cannot used at the same time" $ do
let makeSpec authMethods userEntity =
basicAppSpec
{ AS.decls =
[ AS.Decl.makeDecl "TestApp" $
basicApp
{ AS.App.auth =
Just
AS.Auth.Auth
{ AS.Auth.methods = authMethods,
AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName,
AS.Auth.externalAuthEntity = Nothing,
AS.Auth.onAuthFailedRedirectTo = "/",
AS.Auth.onAuthSucceededRedirectTo = Nothing
},
AS.App.emailSender =
Just
AS.EmailSender.EmailSender
{ AS.EmailSender.provider = AS.EmailSender.Mailgun,
AS.EmailSender.defaultFrom = Nothing
}
},
AS.Decl.makeDecl userEntityName userEntity,
basicPageDecl,
basicRouteDecl
]
}
let emailAuthConfig =
AS.Auth.EmailAuthConfig
{ AS.Auth.fromField =
AS.EmailSender.EmailFromField
{ AS.EmailSender.email = "dummy@info.com",
AS.EmailSender.name = Nothing
},
AS.Auth.emailVerification =
AS.Auth.EmailVerification.EmailVerificationConfig
{ AS.Auth.EmailVerification.clientRoute = AS.Core.Ref.Ref basicRouteName,
AS.Auth.EmailVerification.getEmailContentFn = Nothing
},
AS.Auth.passwordReset =
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
ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing, email = Nothing}) validUserEntity) `shouldBe` []
it "returns no error if app.auth is set and only one of UsernameAndPassword and Email is used" $ do
ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, google = Nothing, gitHub = Nothing, email = Nothing}) validUserEntity) `shouldBe` []
ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntityForEmailAuth) `shouldBe` []
it "returns an error if app.auth is set and both UsernameAndPassword and Email are used" $ do
ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntity)
`shouldContain` [ASV.GenericValidationError "Expected app.auth to use either email or username and password authentication, but not both."]
describe "should validate that when app.auth is using UsernameAndPassword, user entity is of valid shape." $ do
let makeSpec appAuth userEntity =
basicAppSpec
@ -170,11 +243,15 @@ spec_AppSpecValid = do
"Expected an Entity referenced by app.auth.userEntity to have field 'password' of type 'String'."
]
where
makeBasicPslField name typ =
makeBasicPslField name typ = makePslField name typ False
makePslField name typ isOptional =
PslM.Field
{ PslM._name = name,
PslM._type = typ,
PslM._typeModifiers = [],
PslM._typeModifiers =
[ PslM.Optional | isOptional
],
PslM._attrs = []
}
@ -220,3 +297,13 @@ spec_AppSpecValid = do
(fromJust $ SP.parseRelFileP "pages/Main"),
AS.Page.authRequired = Nothing
}
basicPageName = "TestPage"
basicPageDecl = AS.Decl.makeDecl basicPageName basicPage
basicRoute = AS.Route.Route {AS.Route.to = AS.Core.Ref.Ref basicPageName, AS.Route.path = "/test"}
basicRouteName = "TestRoute"
basicRouteDecl = AS.Decl.makeDecl basicRouteName basicRoute

View File

@ -187,6 +187,8 @@ library
Wasp.AppSpec.Api
Wasp.AppSpec.App
Wasp.AppSpec.App.Auth
Wasp.AppSpec.App.Auth.PasswordReset
Wasp.AppSpec.App.Auth.EmailVerification
Wasp.AppSpec.App.Client
Wasp.AppSpec.App.Db
Wasp.AppSpec.App.EmailSender
@ -247,12 +249,14 @@ library
Wasp.Generator.AuthProviders.Common
Wasp.Generator.AuthProviders.OAuth
Wasp.Generator.AuthProviders.Local
Wasp.Generator.AuthProviders.Email
Wasp.Generator.ServerGenerator
Wasp.Generator.ServerGenerator.JsImport
Wasp.Generator.ServerGenerator.ApiRoutesG
Wasp.Generator.ServerGenerator.AuthG
Wasp.Generator.ServerGenerator.Auth.OAuthAuthG
Wasp.Generator.ServerGenerator.Auth.LocalAuthG
Wasp.Generator.ServerGenerator.Auth.EmailAuthG
Wasp.Generator.ServerGenerator.Db.Seed
Wasp.Generator.ServerGenerator.EmailSenderG
Wasp.Generator.ServerGenerator.EmailSender.Providers
@ -273,6 +277,7 @@ library
Wasp.Generator.WebAppGenerator.AuthG
Wasp.Generator.WebAppGenerator.Auth.OAuthAuthG
Wasp.Generator.WebAppGenerator.Auth.LocalAuthG
Wasp.Generator.WebAppGenerator.Auth.EmailAuthG
Wasp.Generator.WebAppGenerator.Auth.Common
Wasp.Generator.WebAppGenerator.Common
Wasp.Generator.WebAppGenerator.ExternalCodeGenerator