Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): Added flexible validation checks for User entity email and password #376

Merged
merged 1 commit into from
Dec 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'

import login from '../login.js'
import { errorMessage } from '../../utils.js'

const LoginForm = () => {
const history = useHistory()
Expand All @@ -18,7 +19,7 @@ const LoginForm = () => {
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert('Error:' + err.message)
window.alert(errorMessage(err))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useHistory } from 'react-router-dom'

import signup from '../signup.js'
import login from '../login.js'
import { errorMessage } from '../../utils.js'

const SignupForm = () => {
const history = useHistory()
Expand All @@ -24,7 +25,7 @@ const SignupForm = () => {
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert('Error:' + err.message)
window.alert(errorMessage(err))
}
}

Expand Down
3 changes: 3 additions & 0 deletions waspc/data/Generator/templates/react-app/src/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const errorMessage = (e) => {
return `Error: ${e.message} ${e.data?.message ? '- Details: ' + e.data.message : ''}`
}
17 changes: 17 additions & 0 deletions waspc/data/Generator/templates/server/src/core/AuthError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class AuthError extends Error {
constructor (message, data, ...params) {
super(message, ...params)

if (Error.captureStackTrace) {
Error.captureStackTrace(this, AuthError)
}

this.name = this.constructor.name

if (data) {
this.data = data
}
}
}

export default AuthError
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{{={= =}=}}
import { hashPassword } from '../auth.js'
import AuthError from '../AuthError.js'

const EMAIL_FIELD = 'email'
const PASSWORD_FIELD = 'password'

// Allows flexible validation of a user entity.
// Users can skip default validations by passing _waspSkipDefaultValidations = true
// Users can also add custom validations by passing an array of _waspCustomValidations
// with the same format as our default validations.
// Throws an AuthError on the first validation that fails.
const registerUserEntityValidation = (prismaClient) => {
prismaClient.$use(async (params, next) => {
if (params.model === '{= userEntityUpper =}') {
if (['create', 'update', 'updateMany'].includes(params.action)) {
validateUser(params.args.data, params.args, params.action)
} else if (params.action === 'upsert') {
validateUser(params.args.create.data, params.args, 'create')
validateUser(params.args.update.data, params.args, 'update')
}

// Remove from downstream Prisma processing to avoid "Unknown arg" error
delete params.args._waspSkipDefaultValidations
delete params.args._waspCustomValidations
}

return next(params)
})
}

// Make sure password is always hashed before storing to the database.
const registerPasswordHashing = (prismaClient) => {
prismaClient.$use(async (params, next) => {
if (params.model === '{= userEntityUpper =}') {
if (['create', 'update', 'updateMany'].includes(params.action)) {
if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) {
params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD])
}
} else if (params.action === 'upsert') {
if (params.args.create.data.hasOwnProperty(PASSWORD_FIELD)) {
params.args.create.data[PASSWORD_FIELD] =
await hashPassword(params.args.create.data[PASSWORD_FIELD])
}
if (params.args.update.data.hasOwnProperty(PASSWORD_FIELD)) {
params.args.update.data[PASSWORD_FIELD] =
await hashPassword(params.args.update.data[PASSWORD_FIELD])
}
}
}

return next(params)
})
}

export const registerAuthMiddleware = (prismaClient) => {
// NOTE: registerUserEntityValidation must come before registerPasswordHashing.
registerUserEntityValidation(prismaClient)
registerPasswordHashing(prismaClient)
}

const validateUser = (user, args, action) => {
user = user || {}

const defaultValidations = [
{ validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email },
{ 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 validations = [
...(args._waspSkipDefaultValidations ? [] : defaultValidations),
...(args._waspCustomValidations || [])
]

// On 'create' validations run always, otherwise (on updates)
// they run only when the field they are validating is present.
for (const v of validations) {
if (action === 'create' || user.hasOwnProperty(v.validates)) {
if (!v.validator(user[v.validates])) {
throw new AuthError(v.message)
}
}
}
}
33 changes: 6 additions & 27 deletions waspc/data/Generator/templates/server/src/dbClient.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,21 @@
{{={= =}=}}
import Prisma from '@prisma/client'
{=# isAuthEnabled =}
import { hashPassword } from './core/auth.js'
{=/ isAuthEnabled =}

{=# isAuthEnabled =}
const PASSWORD_FIELD = 'password'

import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js'

{=/ isAuthEnabled =}

const createDbClient = () => {
const prismaClient = new Prisma.PrismaClient()

{=# isAuthEnabled =}
prismaClient.$use(async (params, next) => {
// Make sure password is always hashed before storing to the database.
if (params.model === '{= userEntityUpper =}') {
if (['create', 'update', 'updateMany'].includes(params.action)) {
if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) {
params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD])
}
} else if (params.action === 'upsert') {
if (params.args.create.data.hasOwnProperty(PASSWORD_FIELD)) {
params.args.create.data[PASSWORD_FIELD] =
await hashPassword(params.args.create.data[PASSWORD_FIELD])
}
if (params.args.update.data.hasOwnProperty(PASSWORD_FIELD)) {
params.args.update.data[PASSWORD_FIELD] =
await hashPassword(params.args.update.data[PASSWORD_FIELD])
}
}
}

const result = next(params)

return result
})

registerAuthMiddleware(prismaClient)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet!


{=/ isAuthEnabled =}

return prismaClient
}

Expand Down
16 changes: 14 additions & 2 deletions waspc/data/Generator/templates/server/src/routes/auth/signup.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
{{={= =}=}}
import prisma from '../../dbClient.js'
import { handleRejection } from '../../utils.js'
import { handleRejection, isPrismaError, prismaErrorToHttpError } from '../../utils.js'
import AuthError from '../../core/AuthError.js'
import HttpError from '../../core/HttpError.js'

export default handleRejection(async (req, res) => {
const userFields = req.body || {}

await prisma.{= userEntityLower =}.create({ data: userFields })
try {
await prisma.{= userEntityLower =}.create({ data: userFields })
} catch (e) {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}

res.send()
})
30 changes: 30 additions & 0 deletions waspc/data/Generator/templates/server/src/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Prisma from '@prisma/client'
import HttpError from './core/HttpError.js'

/**
* Decorator for async express middleware that handles promise rejections.
Expand All @@ -13,3 +15,31 @@ export const handleRejection = (middleware) => async (req, res, next) => {
next(error)
}
}

export const isPrismaError = (e) => {
return e instanceof Prisma.PrismaClientKnownRequestError ||
e instanceof Prisma.PrismaClientUnknownRequestError ||
e instanceof Prisma.PrismaClientRustPanicError ||
e instanceof Prisma.PrismaClientInitializationError ||
e instanceof Prisma.PrismaClientValidationError
}

export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
target: e.meta.target
})
} else {
// TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
// and decide which are input errors (422) and which are not (500)
// See: https://github.com/wasp-lang/wasp/issues/384
return new HttpError(500)
}
} else if (e instanceof Prisma.PrismaClientValidationError) {
return new HttpError(422, 'Save failed')
} else {
return new HttpError(500)
}
}
1 change: 1 addition & 0 deletions waspc/src/Wasp/Generator/ServerGenerator.hs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ genSrcDir wasp =
[ [C.copySrcTmplAsIs $ C.asTmplSrcFile [relfile|app.js|]],
[C.copySrcTmplAsIs $ C.asTmplSrcFile [relfile|server.js|]],
[C.copySrcTmplAsIs $ C.asTmplSrcFile [relfile|utils.js|]],
[C.copySrcTmplAsIs $ C.asTmplSrcFile [relfile|core/AuthError.js|]],
[C.copySrcTmplAsIs $ C.asTmplSrcFile [relfile|core/HttpError.js|]],
[genDbClient wasp],
[genConfigFile wasp],
Expand Down
14 changes: 14 additions & 0 deletions waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ genAuth :: Wasp -> [FileDraft]
genAuth wasp = case maybeAuth of
Just auth ->
[ genCoreAuth auth,
genAuthMiddleware auth,
-- Auth routes
genAuthRoutesIndex,
genLoginRoute auth,
Expand All @@ -40,6 +41,19 @@ genCoreAuth auth = C.makeTemplateFD tmplFile dstFile (Just tmplData)
"userEntityLower" .= Util.toLowerFirst userEntity
]

genAuthMiddleware :: Wasp.Auth.Auth -> FileDraft
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha nice job :D! A bit boilerplatish, this whole file, but nothing we should worry about right now, it is not worth the effort at the moment.

genAuthMiddleware auth = C.makeTemplateFD tmplFile dstFile (Just tmplData)
where
authMiddlewareRelToSrc = [relfile|core/auth/prismaMiddleware.js|]
tmplFile = C.asTmplFile $ [reldir|src|] </> authMiddlewareRelToSrc
dstFile = C.serverSrcDirInServerRootDir </> C.asServerSrcFile authMiddlewareRelToSrc

tmplData =
let userEntity = Wasp.Auth._userEntity auth
in object
[ "userEntityUpper" .= userEntity
]

genAuthRoutesIndex :: FileDraft
genAuthRoutesIndex = C.copySrcTmplAsIs (C.asTmplSrcFile [relfile|routes/auth/index.js|])

Expand Down
3 changes: 2 additions & 1 deletion waspc/src/Wasp/Generator/WebAppGenerator.hs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ generateSrcDir wasp =
[relfile|index.css|],
[relfile|serviceWorker.js|],
[relfile|config.js|],
[relfile|queryCache.js|]
[relfile|queryCache.js|],
[relfile|utils.js|]
]
++ genOperations wasp
++ AuthG.genAuth wasp
Expand Down
27 changes: 26 additions & 1 deletion web/docs/language/basic-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ Check out this [section of our Todo app tutorial](/docs/tutorials/todo-app/auth#
`EmailAndPassword` authentication method makes it possible to signup/login into the app by using email address and a password.
This method requires that `userEntity` specified in `auth` element contains `email: string` and `password: string` fields.

We provide basic validations out of the box, which you can customize as shown below. Default validations are:
- `email`: non-empty
- `password`: non-empty, at least 8 characters, and contains a number

#### High-level API

The quickest way to get started is by using the following API generated by Wasp:
Expand Down Expand Up @@ -365,7 +369,28 @@ export const signUp = async (args, context) => {
```

:::info
You don't need to worry about hashing the password yourself! Even when you are using Prisma's client directly and calling `create()` with a plain-text password, Wasp put middleware in place that takes care of hashing it before storing it to the database.
You don't need to worry about hashing the password yourself! Even when you are using Prisma's client directly and calling `create()` with a plain-text password, Wasp put middleware in place that takes care of hashing it before storing it to the database. An additional middleware also performs field validation.
:::

##### Customizing user entity validations

To disable/enable default validations, or add your own, you can do:
```js
const newUser = context.entities.User.create({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be helpful to provide a more "realistic" example here: e.g. we leave _waspSkipDefaultValidations to be false (and comment it can be omitted or set explicitly to true) and for custom validations we use something like what you did in a feature proposal, e.g. "password must contain at least one digit". Just a thought - it took me a bit to realize that we turned off default validations and then re-written them as custom ones.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah sorry, I see now that "one digit" is also part of default validations. Maybe something like "uppercase letter".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thinking on the variety and more realistic. I will do should contain an uppercase letter, thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been addressed here: 9a545b7 Thanks!

data: { email: 'some@email.com', password: 'this will be hashed!' },
_waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true
_waspCustomValidations: [
{
validates: 'password',
message: 'password must contain an uppercase letter',
validator: password => /[A-Z]/.test(password)
},
]
})
```

:::info
Validations always run on `create()`, but only when the field mentioned in `validates` is present for `update()`. The validation process stops on the first `validator` to return false. If enabled, default validations run first and validate basic properties of both the `'email'` or `'password'` fields.
:::

#### Specification
Expand Down