Skip to content

Commit

Permalink
Adds allowedUserFields option to define the only data that can be r…
Browse files Browse the repository at this point in the history
…eturned to client by dbAuth functions (#9374)
  • Loading branch information
cannikin authored and Tobbe committed Dec 22, 2023
1 parent 9945472 commit ad20084
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 53 deletions.
78 changes: 54 additions & 24 deletions docs/docs/auth/dbauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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',
Expand All @@ -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
> ```
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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`)
43 changes: 23 additions & 20 deletions packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ interface ForgotPasswordFlowOptions<TUser = UserType> {
* 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
Expand Down Expand Up @@ -177,6 +177,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
Expand Down Expand Up @@ -281,6 +287,8 @@ interface DbAuthSession<TIdType> {
id: TIdType
}

const DEFAULT_ALLOWED_USER_FIELDS = ['id', 'email']

export class DbAuthHandler<
TUser extends UserType,
TIdType = any,
Expand All @@ -294,6 +302,7 @@ export class DbAuthHandler<
db: PrismaClient
dbAccessor: any
dbCredentialAccessor: any
allowedUserFields: string[]
headerCsrfToken: string | undefined
hasInvalidSession: boolean
session: DbAuthSession<TIdType> | undefined
Expand Down Expand Up @@ -388,6 +397,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(
Expand Down Expand Up @@ -543,26 +554,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,
},
Expand Down Expand Up @@ -1100,11 +1098,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<string, unknown>) {
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
}
Expand Down Expand Up @@ -1452,7 +1455,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
Expand Down
Loading

0 comments on commit ad20084

Please sign in to comment.