diff --git a/docs/docs/auth/dbauth.md b/docs/docs/auth/dbauth.md index 2da40f91d378..97a2417eb6dc 100644 --- a/docs/docs/auth/dbauth.md +++ b/docs/docs/auth/dbauth.md @@ -52,6 +52,7 @@ You can also add WebAuthn to an existing dbAuth install. [Read more about WebAut Read the post-install instructions carefully as they contain instructions for adding database fields for the hashed password and salt, as well as how to configure the auth serverless function based on the name of the table that stores your user data. Here they are, but could change in future releases (these do not include the additional WebAuthn required options, make sure you get those from the output of the `setup` command): > You will need to add a couple of fields to your User table in order to store a hashed password and salt: +> > ``` > model User { > id Int @id @default(autoincrement()) @@ -62,12 +63,16 @@ Read the post-install instructions carefully as they contain instructions for ad > resetTokenExpiresAt DateTime? // <─┘ > } > ``` +> > If you already have existing user records you will need to provide a default value or Prisma complains, so change those to: +> > ``` > hashedPassword String @default("") > salt String @default("") > ``` +> > You'll need to let Redwood know what field you're using for your users' `id` and `username` fields In this case we're using `id` and `email`, so update those in the `authFields` config in `/api/src/functions/auth.js` (this is also the place to tell Redwood if you used a different name for the `hashedPassword` or `salt` fields): +> > ``` > authFields: { > id: 'id', @@ -78,13 +83,17 @@ Read the post-install instructions carefully as they contain instructions for ad > resetTokenExpiresAt: 'resetTokenExpiresAt', > }, > ``` +> > To get the actual user that's logged in, take a look at `getCurrentUser()` in `/api/src/lib/auth.js`. We default it to something simple, but you may use different names for your model or unique ID fields, in which case you need to update those calls (instructions are in the comment above the code). > > Finally, we created a `SESSION_SECRET` environment variable for you in `.env`. This value should NOT be checked into version control and should be unique for each environment you deploy to. If you ever need to log everyone out of your app at once change this secret to a new value. To create a new secret, run: +> > ``` > yarn rw g secret > ``` +> > Need simple Login, Signup and Forgot Password pages? Of course we have a generator for those: +> > ``` > yarn rw generate dbAuth > ``` @@ -109,6 +118,22 @@ If you'd rather create your own, you might want to start from the generated page Almost all config for dbAuth lives in `api/src/functions/auth.js` in the object you give to the `DbAuthHandler` initialization. The comments above each key will explain what goes where. Here's an overview of the more important options: +### allowedUserFields + +```javascript +allowedUserFields: ["id", "email"] +``` + +Most of the auth handlers accept a `user` argument that you can reference in the body of the function. These handlers also sometimes return that `user` object. As a security measure, `allowedUserFields` defines the only properties that will be available in that object so that sensitive data isn't accidentally leaked by these handlers to the client. + +:::info + +The `signup` and `forgotPassword` handlers return to the client whatever data is returned from their handlers, which can be used to display something like the email address that a verification email was just sent to. Without `allowedUserFields` it would be very easy to include the user's `hashedPassword` and `salt` in that response (just return `user` from those handlers) and then any customer could open the Web Inspector in their browser and see those values in plain text! + +::: + +`allowedUserFields` is defaulted to `id` and `email` but you can add any property on `user` to that list. + ### login.enabled Allow users to call login. Defaults to true. Needs to be explicitly set to false to disable the flow. @@ -228,13 +253,17 @@ forgotPassword: { ### forgotPassword.handler() This handler is invoked if a user is found with the username/email that they submitted on the Forgot Password page, and that user will be passed as an argument. Inside this function is where you'll send the user a link to reset their password—via an email is most common. The link will, by default, look like: + ``` https://example.com/reset-password?resetToken=${user.resetToken} ``` + If you changed the path to the Reset Password page in your routes you'll need to change it here. If you used another name for the `resetToken` database field, you'll need to change that here as well: + ``` https://example.com/reset-password?resetKey=${user.resetKey} ``` + > Note that although the user table contains a hash of `resetToken`, only for the handler, `user.resetToken` will contain the raw `resetToken` to use for generating a password reset link. ### resetPassword.enabled @@ -278,7 +307,6 @@ By default no setting is required. This is because each db has its own rules for | SQLite | N/A | N/A | [Not Supported] Insensitive checks can only be defined at a per column level | | Microsoft SQL Server | 'case-insensitive' | N/A | turned on by default so no setting required | - ### Cookie config These options determine how the cookie that tracks whether the client is authorized is stored in the browser. The default configuration should work for most use cases. If you serve your web and api sides from different domains you'll need to make some changes: set `SameSite` to `None` and then add [CORS configuration](#cors-config). @@ -332,9 +360,11 @@ cookie: { ### Session Secret Key If you need to change the secret key that's used to encrypt the session cookie, or deploy to a new target (each deploy environment should have its own unique secret key) we've got a CLI tool for creating a new one: + ``` yarn rw g secret ``` + Note that the secret that's output is _not_ appended to your `.env` file or anything else, it's merely output to the screen. You'll need to put it in the right place after that. :::warning .env and Version Control @@ -410,13 +440,13 @@ In both cases, actual scanning and matching of devices is handled by the operati WebAuthn is supported in the following browsers (as of July 2022): -| OS | Browser | Authenticator | +| OS | Browser | Authenticator | | ------- | ------- | ------------- | -| macOS | Firefox | Yubikey Security Key NFC (USB), Yubikey 5Ci, SoloKey | -| macOS | Chrome | Touch ID, Yubikey Security Key NFC (USB), Yubikey 5Ci, SoloKey | -| iOS | All | Face ID, Touch ID, Yubikey Security Key NFC (NFC), Yubikey 5Ci | -| Android | Chrome | Fingerprint Scanner, caBLE | -| Android | Firefox | Screen PIN | +| macOS | Firefox | Yubikey Security Key NFC (USB), Yubikey 5Ci, SoloKey | +| macOS | Chrome | Touch ID, Yubikey Security Key NFC (USB), Yubikey 5Ci, SoloKey | +| iOS | All | Face ID, Touch ID, Yubikey Security Key NFC (NFC), Yubikey 5Ci | +| Android | Chrome | Fingerprint Scanner, caBLE | +| Android | Firefox | Screen PIN | ### Configuration @@ -559,24 +589,24 @@ export const handler = async (event, context) => { } ``` -* `credentialModelAccessor` specifies the name of the accessor that you call to access the model you created to store credentials. If your model name is `UserCredential` then this field would be `userCredential` as that's how Prisma's naming conventions work. -* `authFields.challenge` specifies the name of the field in the user model that will hold the WebAuthn challenge string. This string is generated automatically whenever a WebAuthn registration or authentication request starts and is one more verification that the browser request came from this user. A user can only have one WebAuthn request/response cycle going at a time, meaning that they can't open a desktop browser, get the TouchID prompt, then switch to iOS Safari to use FaceID, then return to the desktop to scan their fingerprint. The most recent WebAuthn request will clobber any previous one that's in progress. -* `webAuthn.enabled` is a boolean, denoting whether the server should respond to webAuthn requests. If you decide to stop using WebAuthn, you'll want to turn it off here as well as update the LoginPage to stop prompting. -* `webAuthn.expires` is the number of seconds that a user will be allowed to keep using their fingerprint/face scan to re-authenticate into your site. Once this value expires, the user *must* use their username/password to authenticate the next time, and then WebAuthn will be re-enabled (again, for this length of time). For security, you may want to log users out of your app after an hour of inactivity, but allow them to easily use their fingerprint/face to re-authenticate for the next two weeks (this is similar to login on macOS where your TouchID session expires after a couple of days of inactivity). In this example you would set `login.expires` to `60 * 60` and `webAuthn.expires` to `60 * 60 * 24 * 14`. -* `webAuthn.name` is the name of the app that will show in some browser's prompts to use the device -* `webAuthn.domain` is the name of domain making the request. This is just the domain part of the URL, ex: `app.server.com`, or in development mode `localhost` -* `webAuthn.origin` is the domain *including* the protocol and port that the request is coming from, ex: https://app.server.com In development mode, this would be `http://localhost:8910` -* `webAuthn.type`: the type of device that's allowed to be used (see [next section below](#webauthn-type-option)) -* `webAuthn.timeout`: how long to wait for a device to be used in milliseconds (defaults to 60 seconds) -* `webAuthn.credentialFields`: lists the expected field names that dbAuth uses internally mapped to what they're actually called in your model. This includes 5 fields total: `id`, `userId`, `publicKey`, `transports`, `counter`. +- `credentialModelAccessor` specifies the name of the accessor that you call to access the model you created to store credentials. If your model name is `UserCredential` then this field would be `userCredential` as that's how Prisma's naming conventions work. +- `authFields.challenge` specifies the name of the field in the user model that will hold the WebAuthn challenge string. This string is generated automatically whenever a WebAuthn registration or authentication request starts and is one more verification that the browser request came from this user. A user can only have one WebAuthn request/response cycle going at a time, meaning that they can't open a desktop browser, get the TouchID prompt, then switch to iOS Safari to use FaceID, then return to the desktop to scan their fingerprint. The most recent WebAuthn request will clobber any previous one that's in progress. +- `webAuthn.enabled` is a boolean, denoting whether the server should respond to webAuthn requests. If you decide to stop using WebAuthn, you'll want to turn it off here as well as update the LoginPage to stop prompting. +- `webAuthn.expires` is the number of seconds that a user will be allowed to keep using their fingerprint/face scan to re-authenticate into your site. Once this value expires, the user _must_ use their username/password to authenticate the next time, and then WebAuthn will be re-enabled (again, for this length of time). For security, you may want to log users out of your app after an hour of inactivity, but allow them to easily use their fingerprint/face to re-authenticate for the next two weeks (this is similar to login on macOS where your TouchID session expires after a couple of days of inactivity). In this example you would set `login.expires` to `60 * 60` and `webAuthn.expires` to `60 * 60 * 24 * 14`. +- `webAuthn.name` is the name of the app that will show in some browser's prompts to use the device +- `webAuthn.domain` is the name of domain making the request. This is just the domain part of the URL, ex: `app.server.com`, or in development mode `localhost` +- `webAuthn.origin` is the domain _including_ the protocol and port that the request is coming from, ex: [https://app.server.com](https://app.server.com) In development mode, this would be `http://localhost:8910` +- `webAuthn.type`: the type of device that's allowed to be used (see [next section below](#webauthn-type-option)) +- `webAuthn.timeout`: how long to wait for a device to be used in milliseconds (defaults to 60 seconds) +- `webAuthn.credentialFields`: lists the expected field names that dbAuth uses internally mapped to what they're actually called in your model. This includes 5 fields total: `id`, `userId`, `publicKey`, `transports`, `counter`. ### WebAuthn `type` Option The config option `webAuthn.type` can be set to `any`, `platform` or `cross-platform`: -* `platform` means to *only* allow embedded devices (TouchID, FaceID, Windows Hello) to be used -* `cross-platform` means to *only* allow third party devices (like a Yubikey USB fingerprint reader) -* `any` means to allow both platform and cross-platform devices +- `platform` means to _only_ allow embedded devices (TouchID, FaceID, Windows Hello) to be used +- `cross-platform` means to _only_ allow third party devices (like a Yubikey USB fingerprint reader) +- `any` means to allow both platform and cross-platform devices In some browsers this can lead to a pretty drastic UX difference. For example, here is the interface in Chrome on macOS with the included TouchID sensor on a Macbook Pro: @@ -671,7 +701,7 @@ const { isAuthenticated, client, logIn } = useAuth() `client` gives you access to four functions for working with WebAuthn: -* `client.isSupported()`: returns a Promise which resolves to a boolean—whether or not WebAuthn is supported in the current browser browser -* `client.isEnabled()`: returns a boolean for whether the user currently has a `webAuthn` cookie, which means this device has been registered already and can be used for login -* `client.register()`: returns a Promise which gets options from the server, presents the prompt to scan your fingerprint/face, and then sends the result up to the server. It will either resolve successfully with an object `{ verified: true }` or throw an error. This function is used when the user has not registered this device yet (`client.isEnabled()` returns `false`). -* `client.authenticate()`: returns a Promise which gets options from the server, presents the prompt to scan the user's fingerprint/face, and then sends the result up to the server. It will either resolve successfully with an object `{ verified: true }` or throw an error. This should be used when the user has already registered this device (`client.isEnabled()` returns `true`) +- `client.isSupported()`: returns a Promise which resolves to a boolean—whether or not WebAuthn is supported in the current browser browser +- `client.isEnabled()`: returns a boolean for whether the user currently has a `webAuthn` cookie, which means this device has been registered already and can be used for login +- `client.register()`: returns a Promise which gets options from the server, presents the prompt to scan your fingerprint/face, and then sends the result up to the server. It will either resolve successfully with an object `{ verified: true }` or throw an error. This function is used when the user has not registered this device yet (`client.isEnabled()` returns `false`). +- `client.authenticate()`: returns a Promise which gets options from the server, presents the prompt to scan the user's fingerprint/face, and then sends the result up to the server. It will either resolve successfully with an object `{ verified: true }` or throw an error. This should be used when the user has already registered this device (`client.isEnabled()` returns `true`) diff --git a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts index 7608844813e3..2769fadf2d42 100644 --- a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts +++ b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts @@ -80,7 +80,7 @@ interface ForgotPasswordFlowOptions> { * Needs to be explicitly set to false to disable the flow */ enabled?: boolean - handler: (user: TUser) => any + handler: (user: TUser, token: string) => any errors?: { usernameNotFound?: string usernameRequired?: string @@ -172,6 +172,12 @@ export interface DbAuthHandlerOptions> { * ie. if your Prisma model is named `UserCredential` this value would be `userCredential`, as in `db.userCredential` */ credentialModelAccessor?: keyof PrismaClient + /** + * The fields that are allowed to be returned from the user table when + * invoking handlers that return a user object (like forgotPassword and signup) + * Defaults to `id` and `email` if not set at all. + */ + allowedUserFields?: string[] /** * A map of what dbAuth calls a field to what your database calls it. * `id` is whatever column you use to uniquely identify a user (probably @@ -276,6 +282,8 @@ interface DbAuthSession { id: TIdType } +const DEFAULT_ALLOWED_USER_FIELDS = ['id', 'email'] + export class DbAuthHandler< TUser extends Record, TIdType = any @@ -288,6 +296,7 @@ export class DbAuthHandler< db: PrismaClient dbAccessor: any dbCredentialAccessor: any + allowedUserFields: string[] headerCsrfToken: string | undefined hasInvalidSession: boolean session: DbAuthSession | undefined @@ -382,6 +391,8 @@ export class DbAuthHandler< : null this.headerCsrfToken = this.event.headers['csrf-token'] this.hasInvalidSession = false + this.allowedUserFields = + this.options.allowedUserFields || DEFAULT_ALLOWED_USER_FIELDS const sessionExpiresAt = new Date() sessionExpiresAt.setSeconds( @@ -537,26 +548,13 @@ export class DbAuthHandler< throw new DbAuthError.GenericError() } - // Temporarily set the token on the user back to the raw token so it's - // available to the handler. - user.resetToken = token // call user-defined handler in their functions/auth.js const response = await ( this.options.forgotPassword as ForgotPasswordFlowOptions - ).handler(this._sanitizeUser(user)) - - // remove resetToken and resetTokenExpiresAt if in the body of the - // forgotPassword handler response - let responseObj = response - if (typeof response === 'object') { - responseObj = Object.assign(response, { - [this.options.authFields.resetToken]: undefined, - [this.options.authFields.resetTokenExpiresAt]: undefined, - }) - } + ).handler(this._sanitizeUser(user), token) return [ - response ? JSON.stringify(responseObj) : '', + response ? JSON.stringify(response) : '', { ...this._deleteSessionHeader, }, @@ -1094,11 +1092,16 @@ export class DbAuthHandler< ].join(';') } - // removes sensitive fields from user before sending over the wire + // removes any fields not explicitly allowed to be sent to the client before + // sending a response over the wire _sanitizeUser(user: Record) { const sanitized = JSON.parse(JSON.stringify(user)) - delete sanitized[this.options.authFields.hashedPassword] - delete sanitized[this.options.authFields.salt] + + Object.keys(sanitized).forEach((key) => { + if (!this.allowedUserFields.includes(key)) { + delete sanitized[key] + } + }) return sanitized } @@ -1446,7 +1449,7 @@ export class DbAuthHandler< SetCookieHeader & CsrfTokenHeader, { statusCode: number } ] { - const sessionData = { id: user[this.options.authFields.id] } + const sessionData = this._sanitizeUser(user) // TODO: this needs to go into graphql somewhere so that each request makes a new CSRF token and sets it in both the encrypted session and the csrf-token header const csrfToken = DbAuthHandler.CSRF_TOKEN diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js index 4d19f4847055..3f7aa24971fe 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js +++ b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js @@ -818,12 +818,13 @@ describe('dbAuth', () => { event.body = JSON.stringify({ username: user.email, }) - options.forgotPassword.handler = (handlerUser) => { + options.forgotPassword.handler = (handlerUser, token) => { expect(handlerUser.id).toEqual(user.id) + expect(token).toMatch(/^[A-Za-z0-9/+]{16}$/) } const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.forgotPassword() - expect.assertions(1) + expect.assertions(2) }) it('invokes forgotPassword.handler() with the raw resetToken', async () => { @@ -831,14 +832,15 @@ describe('dbAuth', () => { event.body = JSON.stringify({ username: user.email, }) - options.forgotPassword.handler = (handlerUser) => { - // user should have the raw resetToken NOT the hash + options.forgotPassword.handler = (handlerUser, token) => { + // tokens should be the raw resetToken NOT the hash // resetToken consists of 16 base64 characters - expect(handlerUser.resetToken).toMatch(/^\w{16}$/) + expect(handlerUser.resetToken).toBeUndefined() + expect(token).toMatch(/^[A-Za-z0-9/+]{16}$/) } const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.forgotPassword() - expect.assertions(1) + expect.assertions(2) }) it('removes the token from the forgotPassword response', async () => { @@ -1016,7 +1018,7 @@ describe('dbAuth', () => { const response = await dbAuth.login() - expect(response[0]).toEqual({ id: user.id }) + expect(response[0].id).toEqual(user.id) }) it('returns a CSRF token in the header', async () => { @@ -2753,4 +2755,33 @@ describe('dbAuth', () => { expect(response.body).toEqual('{"error":"bad"}') }) }) + + describe('_sanitizeUser', () => { + it('removes all but the default fields [id, email] on user', () => { + const dbAuth = new DbAuthHandler(event, context, options) + const user = { + id: 1, + email: 'rob@redwoodjs.com', + password: 'secret', + } + + expect(dbAuth._sanitizeUser(user).id).toEqual(user.id) + expect(dbAuth._sanitizeUser(user).email).toEqual(user.email) + expect(dbAuth._sanitizeUser(user).secret).toBeUndefined() + }) + + it('removes any fields not explictly allowed in allowedUserFields', () => { + options.allowedUserFields = ['foo'] + const dbAuth = new DbAuthHandler(event, context, options) + const user = { + id: 1, + email: 'rob@redwoodjs.com', + foo: 'bar', + } + + expect(dbAuth._sanitizeUser(user).id).toBeUndefined() + expect(dbAuth._sanitizeUser(user).email).toBeUndefined() + expect(dbAuth._sanitizeUser(user).foo).toEqual('bar') + }) + }) }) diff --git a/packages/auth-providers/dbAuth/setup/src/templates/api/functions/auth.ts.template b/packages/auth-providers/dbAuth/setup/src/templates/api/functions/auth.ts.template index 580c63891025..9c13a118b42a 100644 --- a/packages/auth-providers/dbAuth/setup/src/templates/api/functions/auth.ts.template +++ b/packages/auth-providers/dbAuth/setup/src/templates/api/functions/auth.ts.template @@ -18,11 +18,20 @@ export const handler = async ( // https://example.com/reset-password?resetToken=${user.resetToken} // // Whatever is returned from this function will be returned from - // the `forgotPassword()` function that is destructured from `useAuth()` + // the `forgotPassword()` function that is destructured from `useAuth()`. // You could use this return value to, for example, show the email // address in a toast message so the user will know it worked and where // to look for the email. - handler: (user) => { + // + // Note that this return value is sent to the client in *plain text* + // so don't include anything you wouldn't want prying eyes to see. The + // `user` here has been sanitized to only include the fields listed in + // `allowedUserFields` so it should be safe to return as-is. + handler: (user, _resetToken) => { + // TODO: Send user an email/message with a link to reset their password, + // including the `resetToken`. The URL should look something like: + // `http://localhost:8910/reset-password?resetToken=${resetToken}` + return user }, @@ -153,6 +162,12 @@ export const handler = async ( resetTokenExpiresAt: 'resetTokenExpiresAt', }, + // A list of fields on your user object that are safe to return to the + // client when invoking a handler that returns a user (like forgotPassword + // and signup). This list should be as small as possible to be sure not to + // leak any sensitive information to the client. + allowedUserFields = ['id', 'email'], + // Specifies attributes on the cookie that dbAuth sets in order to remember // who is logged in. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies cookie: {