-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 : ''}` | ||
} |
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) | ||
} | ||
} | ||
} | ||
} |
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() | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,7 @@ genAuth :: Wasp -> [FileDraft] | |
genAuth wasp = case maybeAuth of | ||
Just auth -> | ||
[ genCoreAuth auth, | ||
genAuthMiddleware auth, | ||
-- Auth routes | ||
genAuthRoutesIndex, | ||
genLoginRoute auth, | ||
|
@@ -40,6 +41,19 @@ genCoreAuth auth = C.makeTemplateFD tmplFile dstFile (Just tmplData) | |
"userEntityLower" .= Util.toLowerFirst userEntity | ||
] | ||
|
||
genAuthMiddleware :: Wasp.Auth.Auth -> FileDraft | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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|]) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
|
@@ -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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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". There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sweet!