mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-11-22 01:24:49 +03:00
Adding e-mail auth (e-mail verfication, password reset) (#1087)
This commit is contained in:
parent
8ad582ac7b
commit
7f32a4ccb9
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@
|
||||
|
||||
# macOS related
|
||||
.DS_Store
|
||||
.vscode/
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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';
|
@ -1,5 +1,4 @@
|
||||
{{={= =}=}}
|
||||
import React, { useState } from 'react'
|
||||
import Auth from './Auth'
|
||||
|
||||
const LoginForm = ({ appearance, logo, socialLayout }) => {
|
||||
|
@ -1,6 +1,4 @@
|
||||
{{={= =}=}}
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import Auth from './Auth'
|
||||
|
||||
const SignupForm = ({ appearance, logo, socialLayout }) => {
|
||||
|
@ -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;
|
@ -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 })
|
||||
};
|
||||
}
|
@ -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 });
|
||||
};
|
||||
}
|
@ -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 });
|
||||
};
|
@ -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 });
|
||||
};
|
||||
}
|
@ -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',
|
||||
};
|
@ -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 });
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 || [])
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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 =}
|
||||
|
@ -53,7 +53,7 @@
|
||||
"file",
|
||||
"server/package.json"
|
||||
],
|
||||
"cd6bef87942a19586777c8a244d4a74da49fa04ebadc091143273c2fcfd2c6d1"
|
||||
"e8b22a2f3d10e4a20d4f90ef79187fc641888de8f5a40aa14c12537b3cabc21a"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -214,7 +214,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
|
||||
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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"}]}}
|
@ -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"
|
||||
},
|
||||
|
@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
|
||||
export { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
|
||||
|
||||
|
@ -60,7 +60,7 @@
|
||||
"file",
|
||||
"server/package.json"
|
||||
],
|
||||
"cd6bef87942a19586777c8a244d4a74da49fa04ebadc091143273c2fcfd2c6d1"
|
||||
"e8b22a2f3d10e4a20d4f90ef79187fc641888de8f5a40aa14c12537b3cabc21a"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -221,7 +221,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
|
||||
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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"}]}}
|
@ -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"
|
||||
},
|
||||
|
@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
|
||||
export { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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"}]}}
|
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 || [])
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -13,3 +13,4 @@ export { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
|
||||
export { GetUserFieldsFn } from '../auth/providers/oauth/types';
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React, { useState } from 'react'
|
||||
import Auth from './Auth'
|
||||
|
||||
const LoginForm = ({ appearance, logo, socialLayout }) => {
|
||||
|
@ -1,5 +1,3 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import Auth from './Auth'
|
||||
|
||||
const SignupForm = ({ appearance, logo, socialLayout }) => {
|
||||
|
@ -60,7 +60,7 @@
|
||||
"file",
|
||||
"server/package.json"
|
||||
],
|
||||
"29403f5ff8c77da8139d70fc36e51be86e7591449574f063ed134fc45fbef911"
|
||||
"3efe339177fc65c14215d1b2a6e62a338a7f605b01f77d0eebb771e1aad1751a"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -235,7 +235,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
|
||||
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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"}]}}
|
@ -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"
|
||||
},
|
||||
|
@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
|
||||
export { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
|
||||
|
||||
|
@ -60,7 +60,7 @@
|
||||
"file",
|
||||
"server/package.json"
|
||||
],
|
||||
"d8c1f86ddb19cc609e1dbe6ddd87e33567a19e882be183fe714d34631cfe5d95"
|
||||
"77483f50baac1f1980c0a2360d9c6296c84e7c52a499c1ffc982cbe19edb1c1b"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -221,7 +221,7 @@
|
||||
"file",
|
||||
"server/src/types/index.ts"
|
||||
],
|
||||
"de81c15d086ed53e3e44fc8e38f6bcd703d77182f8ad693b6905cda679c9e5e6"
|
||||
"82570549b7c746ecc2f95f7497750a506dd297c131c41bd05547de8c844adeda"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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"}]}}
|
@ -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"
|
||||
},
|
||||
|
@ -12,3 +12,4 @@ export type ServerSetupFnContext = {
|
||||
export { Application } from 'express'
|
||||
export { Server } from 'http'
|
||||
|
||||
|
||||
|
@ -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;
|
@ -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");
|
@ -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;
|
@ -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;
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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'
|
||||
|
@ -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'}!` })
|
||||
}
|
||||
|
26
waspc/examples/todoApp/src/server/auth/email.ts
Normal file
26
waspc/examples/todoApp/src/server/auth/email.ts
Normal 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>
|
||||
`,
|
||||
})
|
@ -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 }
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
14
waspc/src/Wasp/AppSpec/App/Auth/EmailVerification.hs
Normal file
14
waspc/src/Wasp/AppSpec/App/Auth/EmailVerification.hs
Normal 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)
|
14
waspc/src/Wasp/AppSpec/App/Auth/PasswordReset.hs
Normal file
14
waspc/src/Wasp/AppSpec/App/Auth/PasswordReset.hs
Normal 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)
|
@ -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
|
||||
|
@ -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 -> []
|
||||
|
@ -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"
|
||||
}
|
||||
|
41
waspc/src/Wasp/Generator/AuthProviders/Email.hs
Normal file
41
waspc/src/Wasp/Generator/AuthProviders/Email.hs
Normal 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"
|
@ -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
|
||||
|
104
waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs
Normal file
104
waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs
Normal 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 []
|
@ -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]
|
||||
]
|
||||
|
96
waspc/src/Wasp/Generator/WebAppGenerator/Auth/EmailAuthG.hs
Normal file
96
waspc/src/Wasp/Generator/WebAppGenerator/Auth/EmailAuthG.hs
Normal 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])
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user