diff --git a/docs/docs/auth/dbauth.md b/docs/docs/auth/dbauth.md index 3061b08062ea..2bc99d715d98 100644 --- a/docs/docs/auth/dbauth.md +++ b/docs/docs/auth/dbauth.md @@ -40,9 +40,15 @@ If there are any shenanigans detected (the cookie can't be decrypted properly, o A single CLI command will get you everything you need to get dbAuth working, minus the actual login/signup pages: - yarn rw setup auth dbAuth +```bash +yarn rw setup auth dbAuth +``` + +You will be prompted to ask if you want to enable **WebAuthn** support. WebAuthn is an open standard for allowing authentication from devides like TouchID, FaceID, USB fingerprint scanners, ane more. If you think you want to use WebAuthn, enter `y` at this prompt and read on configuration options. + +You can also add WebAuthn to an existing dbAuth install. [Read more about WebAuthn usage and config below](#webauthn). -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: +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: > @@ -87,7 +93,11 @@ Note that if you change the fields named `hashedPassword` and `salt`, and you ha If you don't want to create your own login, signup and forgot password pages from scratch we've got a generator for that: - yarn rw g dbAuth +```bash +yarn rw g dbAuth +``` + +Once again you will be asked if you want to create a WebAuthn-enabled version of the LoginPage. If so, enter `y` and follow the setup instructions. The default routes will make them available at `/login`, `/signup`, `/forgot-password`, and `/reset-password` but that's easy enough to change. Again, check the post-install instructions for one change you need to make to those pages: where to redirect the user to once their login/signup is successful. @@ -199,15 +209,27 @@ There are several error messages that can be displayed, including: We've got some default error messages that sound nice, but may not fit the tone of your site. You can customize these error messages in `api/src/functions/auth.js` in the `errors` prop of each of the `login`, `signup`, `forgotPassword` and `resetPassword` config objects. The generated file contains tons of comments explaining when each particular error message may be shown. +### WebAuthn Config + +See [WebAuthn Configuration](#function-config) section below. + ## Environment Variables ### Cookie Domain By default, the session cookie will not have the `Domain` property set, which a browser will default to be the [current domain only](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent). If your site is spread across multiple domains (for example, your site is at `example.com` but your api-side is deployed to `api.example.com`) you'll need to explicitly set a Domain so that the cookie is accessible to both. -To do this, create an environment variable named `DBAUTH_COOKIE_DOMAIN` set to the root domain of your site, which will allow it to be read by all subdomains as well. For example: +To do this, set the `cookie.Domain` property in your `api/src/functions/auth.js` configuration, set to the root domain of your site, which will allow it to be read by all subdomains as well. For example: - DBAUTH_COOKIE_DOMAIN=example.com +```json title=api/src/functions/auth.js +cookie: { + HttpOnly: true, + Path: '/', + SameSite: 'Strict', + Secure: process.env.NODE_ENV !== 'development' ? true : false, + Domain: 'example.com' +} +``` ### Session Secret Key @@ -217,4 +239,345 @@ If you need to change the secret key that's used to encrypt the session cookie, 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. -> The `.env` file is set to be ignored by git and not committed to version control. There is another file, `.env.defaults`, which is meant to be safe to commit and contain simple ENV vars that your dev team can share. The encryption key for the session cookie is NOT one of these shareable vars! +:::caution .env and Version Control + +The `.env` file is set to be ignored by git and not committed to version control. There is another file, `.env.defaults`, which is meant to be safe to commit and contain simple ENV vars that your dev team can share. The encryption key for the session cookie is NOT one of these shareable vars! + +::: + +## WebAuthn + +[WebAuthn](https://webauthn.guide/) is a specification written by the W3C and FIDO with participation from Google, Mozilla, Microsoft, and others. It defines a standard way to use public key cryptography instead of a password to authenticate users. + +That's a very technical way of saying: users can log in with [TouchID](https://en.wikipedia.org/wiki/Touch_ID), [FaceID](https://en.wikipedia.org/wiki/Face_ID), [Windows Hello](https://support.microsoft.com/en-us/windows/learn-about-windows-hello-and-set-it-up-dae28983-8242-bb2a-d3d1-87c9d265a5f0), [Yubikey](https://www.yubico.com/), and more. + +image + +We'll refer to whatever biometric device that's used as simply a "device" below. The WebAuthn flow includes two "phases": + +1. **Registration**: the first time a new device is added for a user (a user can have multiple devices registered) +2. **Authentication**: the device is recognized and can be used to login on subsequent visits + +### User Experience + +The `LoginPage` generated by Redwood includes two new prompts on the login page, depending on the state of the user and whether they have registered their device yet or not: + +**Registration** + +The user is prompt to login with username/password: + +image + +Then asked if they want to enable WebAuthn: + +image + +If so, they are shown the browser's prompt to scan: + +image + +If they skip, they just proceed into the site as usual. If they log out and back in, they will be prompted to enable WebAuthn again. + +**Authentication** + +When a device is already registered then it can be used to skip username/password login. The user is immediately shown the prompt to scan when they land on the login page (if the prompt doesn't show, or they mistakenly cancel it, they can click "Open Authenticator" to show the prompt again) + +image + +They can also choose to go to use username/password credentials instead of their registered device. + +### How it Works + +The back and forth between the web and api sides works like this: + +**Registration** + +1. If the user selects to enable their device, a request is made to the server for "registration options" which is a JSON object containing details about the server and user (domain, username). +2. Your app receives that data and then makes a browser API call that says to start the biometric reader with the received options +3. The user scans their fingerprint/face and the browser API returns an ID representing this device, a public key and a few other fields for validation on the server +4. The ID, public key, and additional details are sent to the server to be verified. Assuming the are, the device is saved to the database in a `UserCredential` table (you can change the name if you want). The server responds by placing a cookie on the user's browser with the device ID (a random string of letters and numbers) + +A similar process takes place when authenticating: + +**Authentication** + +1. If the cookie from the previous process is present, the web side knows that the user has a registered device so a request is made to the server to get "authentication options" +2. The server looks up user who's credential ID is in the cookie and gets a list of all of the devices they have registered in the past. This is included along with the domain and username +3. The web side receives the options from the server and a browser API call is made. The browser first checks to see if the list of devices from the server includes the current device. If so, it prompts the user to scan their fingerprint/face (if the device is not in the list, the user will directed back to username/password signup) +4. The ID, public key, user details and a signature are sent to the server and checked to make sure the signature contains the expected data encrypted with the public key. If so, the regular login cookie is set (the same as if the user had used username/password login) + +In both cases, actual scanning and matching of devices is handled by the operating system: all we care about is that we are given a credential ID and a public key back from the device. + +### Browser Support + +WebAuthn is supported in the following browsers (as of July 2022): + +| 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 | + +#### iOS WebKit Browsers + +iOS Safari (and other iOS WebKit-based browsers) currently has a limitation where only a single `async` event can occur before asking to prompt the user for a WebAuthn interaction. In React, there are lots of `async` events floating around as you browse a site, which means the chances that the WebAuthn request is the first one is pretty slim. + +This means that if the login page is not the first page you land on, trying to authenticate will raise an error. Redwood catches this error and responds with the following prompt (which we know is not ideal, but better than the default message of "No available authenticator recognized any of the available credentials"): + +image + +You could catch this error on your login page and display your own custom error message, of course. + +So the workaround is to simply reload the page (guaranteeing that the WebAuthn request will be the first event fired off on the page) then the user is prompted and can login like normal. However, this reload needs to initiated by the user, it can't happen automatically in React (trust us, we already tried). + +:::caution Will it be fixed? + +Safari (and other browsers based on WebKit on iOS) have had this limitation for quite a while (and it used to be even [more harsh](https://groups.google.com/a/fidoalliance.org/g/fido-dev/c/pIs0DIajWVs/m/xeg0WjFkAQAJ) to the point that WebAuthn was functionally unusable) so it may never be rectified. Your best bet may be to just come up with a friendly message that makes it clear this isn't the user's problem, but just a current limitation of the browser ecosystem. + +::: + +### Configuration + +WebAuthn support requires a few updates to your codebase: + +1. Adding a `UserCredential` model +2. Adding configuration options in `api/src/functions/auth.js` +3. Adding a `client` to the `` in `App.js` +4. Adding an interface during the login process that prompts the user to enable their device + +:::info +If you setup dbAuth and generated the LoginPage with WebAuthn support then all of these steps have already been done for you! As described in the post-setup instructions you just need to add the required fields to your `User` model, create a `UserCredential` model, and you're ready to go! + +If you didn't setup WebAuthn at first, but decided you now want WebAuthn, you could run the setup and generator commands again with the `--force` flag to overwrite your existing files. Any changes you made will be overwritten, but if you do a quick diff in git you should be able to port over most of your changes. +::: + +### Schema Updates + +You'll need to add two fields to your `User` model, and a new `UserCredential` model to store the devices that are used and associate them with a user: + +```javascript title=api/db/schema.prisma +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + binaryTargets = "native" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + hashedPassword String + salt String + resetToken String? + resetTokenExpiresAt DateTime? + // highlight-start + webAuthnChallenge String? @unique + credentials UserCredential[] + // highlight-end +} + +// highlight-start +model UserCredential { + id String @id + userId Int + user User @relation(fields: [userId], references: [id]) + publicKey Bytes + transports String? + counter BigInt +} +// highlight-end +``` + +Run `yarn rw prisma migrate dev` to apply the changes to your database. + +### Function Config + +Next we need to let dbAuth know about the new field and model names, as well as how you want WebAuthn to behave (see the highlighted section) + +```javascript title=api/src/functions/auth.js +import { db } from 'src/lib/db' +import { DbAuthHandler } from '@redwoodjs/api' + +export const handler = async (event, context) => { + + // assorted handler config here... + + const authHandler = new DbAuthHandler(event, context, { + db: db, + authModelAccessor: 'user', + // highlight-start + credentialModelAccessor: 'userCredential', + // highlight-end + authFields: { + id: 'id', + username: 'email', + hashedPassword: 'hashedPassword', + salt: 'salt', + resetToken: 'resetToken', + resetTokenExpiresAt: 'resetTokenExpiresAt', + // highlight-start + challenge: 'webAuthnChallenge', + // highlight-end + }, + + cookie: { + HttpOnly: true, + Path: '/', + SameSite: 'Strict', + Secure: process.env.NODE_ENV !== 'development' ? true : false, + }, + + forgotPassword: forgotPasswordOptions, + login: loginOptions, + resetPassword: resetPasswordOptions, + signup: signupOptions, + + // highlight-start + webAuthn: { + enabled: true, + expires: 60 * 60 * 14, + name: 'Webauthn Test', + domain: + process.env.NODE_ENV === 'development' ? 'localhost' : 'server.com', + origin: + process.env.NODE_ENV === 'development' + ? 'http://localhost:8910' + : 'https://server.com', + type: 'platform', + timeout: 60000, + credentialFields: { + id: 'id', + userId: 'userId', + publicKey: 'publicKey', + transports: 'transports', + counter: 'counter', + }, + }, + // highlight-end + }) + + return await authHandler.invoke() +} +``` + +* `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`. + +### 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 + +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: + +#### **any** + +image + +If you pick "Add a new Android Phone" you're presented with a QR code: + +image + +If you pick "USB Security Key" you're given the chance to scan your fingerprint in a 3rd party USB device: + +image + +And finally if you pick "This device" you're presented with the standard interface you'd get if used `platform` as your type: + +image + +You'll have to decide if this UX tradeoff is worth it for your customers, as it can be pretty confusing when first presented with all of these options when someone is just used to using TouchID or FaceID. + +#### **platform** + +The `platform` option provides the simplest UI and one that users with a TouchID or FaceID will be immediately familiar with: + +image + +Note that you can also fallback to use your user account password (on the computer itself) in addition to TouchID: + +image + +Both the password and TouchID scan will count as the same device, so users can alternate between them if they want. + +#### **cross-platform** + +This interface is the same as `any`, but without the option to pick "This device": + + + +So while the `any` option is the most flexible, it's also the most confusing to users. If you do plan on allowing any device, you may want to do a user-agent check and try to explain to users what the different options actually mean. + +The api-side is now ready to go. + +### App.js Updates + +If you generated your login/signup pages with `yarn rw g dbAuth --webAuthn` then all of these changes are in place and you can start using WebAuthn right away! Otherwise, read on. + +First you'll need to import the `WebAuthnClient` and give it to the `` component: + +```jsx title="web/src/App.js" +import { AuthProvider } from '@redwoodjs/auth' +// highlight-start +import WebAuthnClient from '@redwoodjs/auth/webAuthn' +// highlight-end +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + // highlight-start + + // highlight-end + + + + + + +) + +export default App +``` + +Now you're ready to access the functionality added by the WebAuthn client. The easiest way to do this would be to generate a new `LoginPage` with `yarn rw g dbAuth --webAuthn`, even if it's in a brand new, throwaway app, and copy the pieces you need (or just replace your existing login page with it). + +The gist of building a login flow is that you now need to stop after username/password authentication and, if the browser supports WebAuthn, give the user the chance to register their device. If they come to the login page and already have the `webAuthn` cookie then you can show the prompt to authenticate, skipping the username/password form completely. This is all handled in the LoginPage template that Redwood generates for you. + +### WebAuthn Client API + +The `client` that we gave to the `AuthProvider` can be destructured from `useAuth()`: + +```javascript +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`) diff --git a/packages/api/package.json b/packages/api/package.json index 9b6ea37d5782..120ca2999d4d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -32,6 +32,7 @@ "dependencies": { "@babel/runtime-corejs3": "7.16.7", "@prisma/client": "3.15.2", + "base64url": "3.0.1", "core-js": "3.23.5", "cross-undici-fetch": "0.4.13", "crypto-js": "4.1.1", @@ -49,6 +50,7 @@ "@babel/core": "7.16.7", "@clerk/clerk-sdk-node": "3.8.6", "@redwoodjs/auth": "2.1.0", + "@simplewebauthn/server": "5.2.1", "@types/aws-lambda": "8.10.101", "@types/crypto-js": "4.1.1", "@types/jsonwebtoken": "8.5.8", diff --git a/packages/api/src/functions/dbAuth/DbAuthHandler.ts b/packages/api/src/functions/dbAuth/DbAuthHandler.ts index 833268ff229c..3f4f00a498ae 100644 --- a/packages/api/src/functions/dbAuth/DbAuthHandler.ts +++ b/packages/api/src/functions/dbAuth/DbAuthHandler.ts @@ -1,5 +1,14 @@ import type { PrismaClient } from '@prisma/client' +import type { + GenerateRegistrationOptionsOpts, + GenerateAuthenticationOptionsOpts, + VerifyRegistrationResponseOpts, + VerifyAuthenticationResponseOpts, + VerifiedRegistrationResponse, + VerifiedAuthenticationResponse, +} from '@simplewebauthn/server' import type { APIGatewayProxyEvent, Context as LambdaContext } from 'aws-lambda' +import base64url from 'base64url' import CryptoJS from 'crypto-js' import md5 from 'md5' import { v4 as uuidv4 } from 'uuid' @@ -13,7 +22,15 @@ import { import { normalizeRequest } from '../../transforms' import * as DbAuthError from './errors' -import { decryptSession, extractCookie, getSession } from './shared' +import { + decryptSession, + extractCookie, + getSession, + webAuthnSession, +} from './shared' + +type SetCookieHeader = { 'Set-Cookie': string } +type CsrfTokenHeader = { 'csrf-token': string } interface DbAuthHandlerOptions { /** @@ -25,6 +42,11 @@ interface DbAuthHandlerOptions { * ie. if your Prisma model is named `User` this value would be `user`, as in `db.user` */ authModelAccessor: keyof PrismaClient + /** + * The name of the property you'd call on `db` to access your user credentials table. + * ie. if your Prisma model is named `UserCredential` this value would be `userCredential`, as in `db.userCredential` + */ + credentialModelAccessor?: keyof PrismaClient /** * 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 @@ -37,6 +59,7 @@ interface DbAuthHandlerOptions { salt: string resetToken: string resetTokenExpiresAt: string + challenge?: string } /** * Object containing cookie config options @@ -119,6 +142,26 @@ interface DbAuthHandlerOptions { } } + /** + * Object containing WebAuthn options + */ + webAuthn?: { + enabled: boolean + expires: number + name: string + domain: string + origin: string + timeout?: number + type: 'any' | 'platform' | 'cross-platform' + credentialFields: { + id: string + userId: string + publicKey: string + transports: string + counter: string + } + } + /** * CORS settings, same as in createGraphqlHandler */ @@ -144,6 +187,10 @@ type AuthMethodNames = | 'resetPassword' | 'signup' | 'validateResetToken' + | 'webAuthnRegOptions' + | 'webAuthnRegister' + | 'webAuthnAuthOptions' + | 'webAuthnAuthenticate' type Params = { username?: string @@ -160,12 +207,14 @@ export class DbAuthHandler { params: Params db: PrismaClient dbAccessor: any + dbCredentialAccessor: any headerCsrfToken: string | undefined hasInvalidSession: boolean session: SessionRecord | undefined sessionCsrfToken: string | undefined corsContext: CorsContext | undefined - futureExpiresDate: string + sessionExpiresDate: string + webAuthnExpiresDate: string // class constant: list of auth methods that are supported static get METHODS(): AuthMethodNames[] { @@ -177,6 +226,10 @@ export class DbAuthHandler { 'resetPassword', 'signup', 'validateResetToken', + 'webAuthnRegOptions', + 'webAuthnRegister', + 'webAuthnAuthOptions', + 'webAuthnAuthenticate', ] } @@ -190,6 +243,10 @@ export class DbAuthHandler { resetPassword: 'POST', signup: 'POST', validateResetToken: 'POST', + webAuthnRegOptions: 'GET', + webAuthnRegister: 'POST', + webAuthnAuthOptions: 'GET', + webAuthnAuthenticate: 'POST', } } @@ -203,6 +260,10 @@ export class DbAuthHandler { return uuidv4() } + static get AVAILABLE_WEBAUTHN_TRANSPORTS() { + return ['usb', 'ble', 'nfc', 'internal'] + } + // returns the Set-Cookie header to mark the cookie as expired ("deletes" the session) get _deleteSessionHeader() { return { @@ -228,11 +289,23 @@ export class DbAuthHandler { this.params = this._parseBody() this.db = this.options.db this.dbAccessor = this.db[this.options.authModelAccessor] + this.dbCredentialAccessor = this.options.credentialModelAccessor + ? this.db[this.options.credentialModelAccessor] + : null this.headerCsrfToken = this.event.headers['csrf-token'] this.hasInvalidSession = false - const futureDate = new Date() - futureDate.setSeconds(futureDate.getSeconds() + this.options.login.expires) - this.futureExpiresDate = futureDate.toUTCString() + + const sessionExpiresAt = new Date() + sessionExpiresAt.setSeconds( + sessionExpiresAt.getSeconds() + this.options.login.expires + ) + this.sessionExpiresDate = sessionExpiresAt.toUTCString() + + const webAuthnExpiresAt = new Date() + webAuthnExpiresAt.setSeconds( + webAuthnExpiresAt.getSeconds() + (this.options?.webAuthn?.expires || 0) + ) + this.webAuthnExpiresDate = webAuthnExpiresAt.toUTCString() if (options.cors) { this.corsContext = createCorsContext(options.cors) @@ -329,7 +402,6 @@ export class DbAuthHandler { where: { [this.options.authFields.username]: username }, }) } catch (e) { - console.log(e) throw new DbAuthError.GenericError() } @@ -341,7 +413,7 @@ export class DbAuthHandler { // generate a token let token = md5(uuidv4()) - const buffer = new Buffer(token) + const buffer = Buffer.from(token) token = buffer.toString('base64').replace('=', '').substring(0, 16) try { @@ -356,7 +428,6 @@ export class DbAuthHandler { }, }) } catch (e) { - console.log(e) throw new DbAuthError.GenericError() } @@ -386,7 +457,7 @@ export class DbAuthHandler { // need to return *something* for our existing Authorization header stuff // to work, so return the user's ID in case we can use it for something // in the future - return [user.id] + return [user[this.options.authFields.id]] } catch (e: any) { if (e instanceof DbAuthError.NotLoggedInError) { return this._logoutResponse() @@ -455,7 +526,6 @@ export class DbAuthHandler { }, }) } catch (e) { - console.log(e) throw new DbAuthError.GenericError() } @@ -508,6 +578,269 @@ export class DbAuthHandler { ] } + // browser submits WebAuthn credentials + async webAuthnAuthenticate() { + const { verifyAuthenticationResponse } = require('@simplewebauthn/server') + const webAuthnOptions = this.options.webAuthn + + if (!webAuthnOptions || !webAuthnOptions.enabled) { + throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') + } + + const jsonBody = JSON.parse(this.event.body as string) + const credential = await this.dbCredentialAccessor.findFirst({ + where: { id: jsonBody.rawId }, + }) + + if (!credential) { + throw new DbAuthError.WebAuthnError('Credentials not found') + } + + const user = await this.dbAccessor.findFirst({ + where: { + [this.options.authFields.id]: + credential[webAuthnOptions.credentialFields.userId], + }, + }) + + let verification: VerifiedAuthenticationResponse + try { + const opts: VerifyAuthenticationResponseOpts = { + credential: jsonBody, + expectedChallenge: user[this.options.authFields.challenge as string], + expectedOrigin: webAuthnOptions.origin, + expectedRPID: webAuthnOptions.domain, + authenticator: { + credentialID: base64url.toBuffer( + credential[webAuthnOptions.credentialFields.id] + ), + credentialPublicKey: + credential[webAuthnOptions.credentialFields.publicKey], + counter: credential[webAuthnOptions.credentialFields.counter], + transports: credential[webAuthnOptions.credentialFields.transports] + ? JSON.parse( + credential[webAuthnOptions.credentialFields.transports] + ) + : DbAuthHandler.AVAILABLE_WEBAUTHN_TRANSPORTS, + }, + requireUserVerification: true, + } + + verification = verifyAuthenticationResponse(opts) + } catch (e: any) { + throw new DbAuthError.WebAuthnError(e.message) + } finally { + // whether it worked or errored, clear the challenge in the user record + // and user can get a new one next time they try to authenticate + await this._saveChallenge(user[this.options.authFields.id], null) + } + + const { verified, authenticationInfo } = verification + + if (verified) { + // update counter in credentials + await this.dbCredentialAccessor.update({ + where: { + [webAuthnOptions.credentialFields.id]: + credential[webAuthnOptions.credentialFields.id], + }, + data: { + [webAuthnOptions.credentialFields.counter]: + authenticationInfo.newCounter, + }, + }) + } + + // get the regular `login` cookies + const [, loginHeaders] = this._loginResponse(user) + const cookies = [ + this._webAuthnCookie(jsonBody.rawId, this.webAuthnExpiresDate), + loginHeaders['Set-Cookie'], + ].flat() + + return [verified, { 'Set-Cookie': cookies }] + } + + // get options for a WebAuthn authentication + async webAuthnAuthOptions() { + const { generateAuthenticationOptions } = require('@simplewebauthn/server') + + if (this.options.webAuthn === undefined || !this.options.webAuthn.enabled) { + throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') + } + const webAuthnOptions = this.options.webAuthn + + const credentialId = webAuthnSession(this.event) + + let user + + if (credentialId) { + user = await this.dbCredentialAccessor + .findFirst({ + where: { [webAuthnOptions.credentialFields.id]: credentialId }, + }) + .user() + } else { + // webauthn session not present, fallback to getting user from regular + // session cookie + user = await this._getCurrentUser() + } + + // webauthn cookie has been tampered with or UserCredential has been deleted + // from the DB, remove their cookie so it doesn't happen again + if (!user) { + return [ + { error: 'Log in with username and password to enable WebAuthn' }, + { 'Set-Cookie': this._webAuthnCookie('', 'now') }, + { statusCode: 400 }, + ] + } + + const credentials = await this.dbCredentialAccessor.findMany({ + where: { + [webAuthnOptions.credentialFields.userId]: + user[this.options.authFields.id], + }, + }) + + const someOptions: GenerateAuthenticationOptionsOpts = { + timeout: webAuthnOptions.timeout || 60000, + allowCredentials: credentials.map((cred: Record) => ({ + id: base64url.toBuffer(cred[webAuthnOptions.credentialFields.id]), + type: 'public-key', + transports: cred[webAuthnOptions.credentialFields.transports] + ? JSON.parse(cred[webAuthnOptions.credentialFields.transports]) + : DbAuthHandler.AVAILABLE_WEBAUTHN_TRANSPORTS, + })), + userVerification: 'required', + rpID: webAuthnOptions.domain, + } + + const authOptions = generateAuthenticationOptions(someOptions) + + await this._saveChallenge( + user[this.options.authFields.id], + authOptions.challenge + ) + + return [authOptions] + } + + // get options for WebAuthn registration + async webAuthnRegOptions() { + const { generateRegistrationOptions } = require('@simplewebauthn/server') + + if (!this.options?.webAuthn?.enabled) { + throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') + } + + const webAuthnOptions = this.options.webAuthn + + const user = await this._getCurrentUser() + const options: GenerateRegistrationOptionsOpts = { + rpName: webAuthnOptions.name, + rpID: webAuthnOptions.domain, + userID: user[this.options.authFields.id], + userName: user[this.options.authFields.username], + timeout: webAuthnOptions?.timeout || 60000, + excludeCredentials: [], + authenticatorSelection: { + userVerification: 'required', + }, + // Support the two most common algorithms: ES256, and RS256 + supportedAlgorithmIDs: [-7, -257], + } + + // if a type is specified other than `any` assign it (the default behavior + // of this prop if `undefined` means to allow any authenticator) + if (webAuthnOptions.type && webAuthnOptions.type !== 'any') { + options.authenticatorSelection = Object.assign( + options.authenticatorSelection || {}, + { authenticatorAttachment: webAuthnOptions.type } + ) + } + + const regOptions = generateRegistrationOptions(options) + + await this._saveChallenge( + user[this.options.authFields.id], + regOptions.challenge + ) + + return [regOptions] + } + + // browser submits WebAuthn credentials for the first time on a new device + async webAuthnRegister() { + const { verifyRegistrationResponse } = require('@simplewebauthn/server') + + if (this.options.webAuthn === undefined || !this.options.webAuthn.enabled) { + throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') + } + + const user = await this._getCurrentUser() + const jsonBody = JSON.parse(this.event.body as string) + + let verification: VerifiedRegistrationResponse + try { + const options: VerifyRegistrationResponseOpts = { + credential: jsonBody, + expectedChallenge: user[this.options.authFields.challenge as string], + expectedOrigin: this.options.webAuthn.origin, + expectedRPID: this.options.webAuthn.domain, + requireUserVerification: true, + } + verification = await verifyRegistrationResponse(options) + } catch (e: any) { + throw new DbAuthError.WebAuthnError(e.message) + } + + const { verified, registrationInfo } = verification + let plainCredentialId + + if (verified && registrationInfo) { + const { credentialPublicKey, credentialID, counter } = registrationInfo + plainCredentialId = base64url.encode(credentialID) + + const existingDevice = await this.dbCredentialAccessor.findFirst({ + where: { + id: plainCredentialId, + userId: user[this.options.authFields.id], + }, + }) + + if (!existingDevice) { + await this.dbCredentialAccessor.create({ + data: { + [this.options.webAuthn.credentialFields.id]: plainCredentialId, + [this.options.webAuthn.credentialFields.userId]: + user[this.options.authFields.id], + [this.options.webAuthn.credentialFields.publicKey]: + credentialPublicKey, + [this.options.webAuthn.credentialFields.transports]: + jsonBody.transports ? JSON.stringify(jsonBody.transports) : null, + [this.options.webAuthn.credentialFields.counter]: counter, + }, + }) + } + } else { + throw new DbAuthError.WebAuthnError('Registration failed') + } + + // clear challenge + await this._saveChallenge(user[this.options.authFields.id], null) + + return [ + verified, + { + 'Set-Cookie': this._webAuthnCookie( + plainCredentialId, + this.webAuthnExpiresDate + ), + }, + ] + } + // validates that we have all the ENV and options we need to login/signup _validateOptions() { // must have a SESSION_SECRET so we can encrypt/decrypt the cookie @@ -539,9 +872,50 @@ export class DbAuthHandler { if (!this.options?.resetPassword?.handler) { throw new DbAuthError.NoResetPasswordHandlerError() } + + // must have webAuthn config if credentialModelAccessor present and vice versa + if ( + (this.options?.credentialModelAccessor && !this.options?.webAuthn) || + (this.options?.webAuthn && !this.options?.credentialModelAccessor) + ) { + throw new DbAuthError.NoWebAuthnConfigError() + } + + if ( + this.options?.webAuthn?.enabled && + (!this.options?.webAuthn?.name || + !this.options?.webAuthn?.domain || + !this.options?.webAuthn?.origin || + !this.options?.webAuthn?.credentialFields) + ) { + throw new DbAuthError.MissingWebAuthnConfigError() + } } - // removes sensative fields from user before sending over the wire + // Save challenge string for WebAuthn + async _saveChallenge(userId: string | number, value: string | null) { + await this.dbAccessor.update({ + where: { + [this.options.authFields.id]: userId, + }, + data: { + [this.options.authFields.challenge as string]: value, + }, + }) + } + + // returns the string for the webAuthn set-cookie header + _webAuthnCookie(id: string, expires: string) { + return [ + `webAuthn=${id}`, + ...this._cookieAttributes({ + expires, + options: { HttpOnly: false }, + }), + ].join(';') + } + + // removes sensitive fields from user before sending over the wire _sanitizeUser(user: Record) { const sanitized = JSON.parse(JSON.stringify(user)) delete sanitized[this.options.authFields.hashedPassword] @@ -569,8 +943,16 @@ export class DbAuthHandler { // // pass the argument `expires` set to "now" to get the attributes needed to expire // the session, or "future" (or left out completely) to set to `futureExpiresDate` - _cookieAttributes({ expires = 'future' }: { expires?: 'now' | 'future' }) { - const cookieOptions = this.options.cookie || {} + _cookieAttributes({ + expires = 'now', + options = {}, + }: { + expires?: 'now' | string + options?: DbAuthHandlerOptions['cookie'] + }) { + const cookieOptions = { ...this.options.cookie, ...options } || { + ...options, + } const meta = Object.keys(cookieOptions) .map((key) => { const optionValue = @@ -588,35 +970,35 @@ export class DbAuthHandler { .filter((v) => v) const expiresAt = - expires === 'now' - ? DbAuthHandler.PAST_EXPIRES_DATE - : this.futureExpiresDate + expires === 'now' ? DbAuthHandler.PAST_EXPIRES_DATE : expires meta.push(`Expires=${expiresAt}`) return meta } + // encrypts a string with the SESSION_SECRET _encrypt(data: string) { return CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET as string) } - // returns the Set-Cookie header to be returned in the request (effectively creates the session) + // returns the Set-Cookie header to be returned in the request (effectively + // creates the session) _createSessionHeader( data: SessionRecord, csrfToken: string - ): Record<'Set-Cookie', string> { + ): SetCookieHeader { const session = JSON.stringify(data) + ';' + csrfToken const encrypted = this._encrypt(session) const cookie = [ `session=${encrypted.toString()}`, - ...this._cookieAttributes({ expires: 'future' }), + ...this._cookieAttributes({ expires: this.sessionExpiresDate }), ].join(';') return { 'Set-Cookie': cookie } } - // checks the CSRF token in the header against the CSRF token in the session and - // throw an error if they are not the same (not used yet) + // checks the CSRF token in the header against the CSRF token in the session + // and throw an error if they are not the same (not used yet) _validateCsrf() { if (this.sessionCsrfToken !== this.headerCsrfToken) { throw new DbAuthError.CsrfTokenMismatchError() @@ -654,6 +1036,7 @@ export class DbAuthHandler { return user } + // removes the resetToken from the database async _clearResetToken(user: Record) { try { await this.dbAccessor.update({ @@ -666,7 +1049,6 @@ export class DbAuthHandler { }, }) } catch (e) { - console.log(e) throw new DbAuthError.GenericError() } } @@ -695,7 +1077,6 @@ export class DbAuthHandler { where: { [this.options.authFields.username]: username }, }) } catch (e) { - console.log(e) throw new DbAuthError.GenericError() } @@ -727,15 +1108,24 @@ export class DbAuthHandler { throw new DbAuthError.NotLoggedInError() } + const select = { + [this.options.authFields.id]: true, + [this.options.authFields.username]: true, + } + + if (this.options.webAuthn?.enabled && this.options.authFields.challenge) { + select[this.options.authFields.challenge] = true + } + let user + try { user = await this.dbAccessor.findUnique({ where: { [this.options.authFields.id]: this.session?.id }, - select: { [this.options.authFields.id]: true }, + select, }) - } catch (e) { - console.log(e) - throw new DbAuthError.GenericError() + } catch (e: any) { + throw new DbAuthError.GenericError(e.message) } if (!user) { @@ -819,7 +1209,14 @@ export class DbAuthHandler { } } - _loginResponse(user: Record, statusCode = 200) { + _loginResponse( + user: Record, + statusCode = 200 + ): [ + { id: string }, + SetCookieHeader & CsrfTokenHeader, + { statusCode: number } + ] { const sessionData = { id: user[this.options.authFields.id] } // TODO: this needs to go into graphql somewhere so that each request makes @@ -839,7 +1236,7 @@ export class DbAuthHandler { _logoutResponse( response?: Record - ): [string, Record<'Set-Cookie', string>] { + ): [string, SetCookieHeader] { return [ response ? JSON.stringify(response) : '', { diff --git a/packages/api/src/functions/dbAuth/__tests__/DbAuthHandler.test.js b/packages/api/src/functions/dbAuth/__tests__/DbAuthHandler.test.js index d262caaa2106..9b15abd46441 100644 --- a/packages/api/src/functions/dbAuth/__tests__/DbAuthHandler.test.js +++ b/packages/api/src/functions/dbAuth/__tests__/DbAuthHandler.test.js @@ -59,6 +59,13 @@ const TableMock = class { }) } + findMany({ where }) { + return this.records.filter((record) => { + const key = Object.keys(where)[0] + return record[key] === where[key] + }) + } + deleteMany() { const count = this.records.length this.records = [] @@ -67,7 +74,7 @@ const TableMock = class { } // create a mock `db` provider that simulates prisma creating/finding/deleting records -const db = new DbMock(['user']) +const db = new DbMock(['user', 'userCredential']) const UUID_REGEX = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/ @@ -118,6 +125,7 @@ describe('dbAuth', () => { options = { authModelAccessor: 'user', + credentialModelAccessor: 'userCredential', authFields: { id: 'id', username: 'email', @@ -125,6 +133,7 @@ describe('dbAuth', () => { salt: 'salt', resetToken: 'resetToken', resetTokenExpiresAt: 'resetTokenExpiresAt', + challenge: 'webAuthnChallenge', }, db: db, excludeUserFields: [], @@ -161,6 +170,22 @@ describe('dbAuth', () => { usernameTaken: 'Username `${username}` already in use', }, }, + webAuthn: { + enabled: true, + expires: 60 * 30, + name: 'Webauthn Test', + domain: 'localhost', + origin: 'http://localhost:8910', + type: 'platform', + timeout: 30000, + credentialFields: { + id: 'id', + userId: 'userId', + publicKey: 'publicKey', + transports: 'transports', + counter: 'counter', + }, + }, } }) @@ -169,6 +194,7 @@ describe('dbAuth', () => { await db.user.deleteMany({ where: { email: 'rob@redwoodjs.com' }, }) + await db.userCredential.deleteMany() }) describe('CSRF_TOKEN', () => { @@ -199,11 +225,30 @@ describe('dbAuth', () => { }) }) - describe('futureExpiresDate', () => { + describe('dbCredentialAccessor', () => { + it('returns the prisma db accessor for a UserCredential model', () => { + const dbAuth = new DbAuthHandler(event, context, options) + expect(dbAuth.dbCredentialAccessor).toEqual(db.userCredential) + }) + }) + + describe('sessionExpiresDate', () => { it('returns a date in the future as a UTCString', () => { const dbAuth = new DbAuthHandler(event, context, options) + const expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + options.login.expires) - expect(dbAuth.futureExpiresDate).toMatch(UTC_DATE_REGEX) + expect(dbAuth.sessionExpiresDate).toEqual(expiresAt.toUTCString()) + }) + }) + + describe('webAuthnExpiresDate', () => { + it('returns a date in the future as a UTCString', () => { + const dbAuth = new DbAuthHandler(event, context, options) + const expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + options.webAuthn.expires) + + expect(dbAuth.webAuthnExpiresDate).toEqual(expiresAt.toUTCString()) }) }) @@ -1095,12 +1140,8 @@ describe('dbAuth', () => { } const dbAuth = new DbAuthHandler(event, context, options) - try { - await dbAuth.signup() - } catch (e) { - expect(e.message).toEqual('Cannot signup') - } expect.assertions(1) + await expect(dbAuth.signup()).rejects.toThrow('Cannot signup') }) it('creates a new user and logs them in', async () => { @@ -1183,6 +1224,372 @@ describe('dbAuth', () => { }) }) + describe('webAuthnAuthenticate', () => { + it('throws an error if WebAuthn options are not defined', async () => { + event = { + headers: {}, + } + options.webAuthn = undefined + + try { + const _dbAuth = new DbAuthHandler(event, context, options) + } catch (e) { + expect(e).toBeInstanceOf(dbAuthError.NoWebAuthnConfigError) + } + expect.assertions(1) + }) + + it('throws an error if WebAuthn is disabled', async () => { + event = { + headers: {}, + } + options.webAuthn.enabled = false + const dbAuth = new DbAuthHandler(event, context, options) + + expect.assertions(1) + await expect(dbAuth.webAuthnAuthenticate()).rejects.toThrow( + dbAuthError.WebAuthnError + ) + }) + + it('throws an error if UserCredential is not found in database', async () => { + event = { + headers: { 'Content-Type': 'application/json' }, + body: '{"method":"webAuthnAuthenticate","id":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","rawId":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTHRnV3BoWUtfZU41clhjX0hkdlVMdk9xcFBXeW9SdmJtbDJQbzAwVUhhZyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODkxMCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9","signature":"MEUCIQD3NOM7Aw0HxPw6EFGf86iwf2yd3p4NncNNLcjd-86zgwIgHuh80bLNV7EcwBi4IAcH57iueLg0X2gLtO5_Y6PMCFE","userHandle":"2"},"type":"public-key","clientExtensionResults":{}}', + } + const dbAuth = new DbAuthHandler(event, context, options) + + expect.assertions(1) + await expect(dbAuth.webAuthnAuthenticate()).rejects.toThrow( + 'Credentials not found' + ) + }) + + it('throws an error if signature verification fails', async () => { + const user = await createDbUser({ + webAuthnChallenge: 'QGdAFmPB711UDnEelZm-OHkLs1UwX6yebPI_jLoSVo', + }) + await db.userCredential.create({ + data: { + id: 'CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA', + userId: user.id, + transports: null, + publicKey: 'foobar', + }, + }) + event = { + headers: { 'Content-Type': 'application/json' }, + body: '{"method":"webAuthnAuthenticate","id":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","rawId":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTHRnV3BoWUtfZU41clhjX0hkdlVMdk9xcFBXeW9SdmJtbDJQbzAwVUhhZyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODkxMCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9","signature":"MEUCIQD3NOM7Aw0HxPw6EFGf86iwf2yd3p4NncNNLcjd-86zgwIgHuh80bLNV7EcwBi4IAcH57iueLg0X2gLtO5_Y6PMCFE","userHandle":"2"},"type":"public-key","clientExtensionResults":{}}', + } + const dbAuth = new DbAuthHandler(event, context, options) + + expect.assertions(1) + await expect(dbAuth.webAuthnAuthenticate()).rejects.toThrow( + 'Unexpected authentication response challenge' + ) + }) + + it('sets challenge in database to null', async () => { + const user = await createDbUser({ + webAuthnChallenge: 'GdAFmPB711UDnEelZm-OHkLs1UwX6yebPI_jLoSVo', + }) + await db.userCredential.create({ + data: { + id: 'CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA', + userId: user.id, + transports: null, + publicKey: 'foobar', + }, + }) + event = { + headers: { 'Content-Type': 'application/json' }, + body: '{"method":"webAuthnAuthenticate","id":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","rawId":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTHRnV3BoWUtfZU41clhjX0hkdlVMdk9xcFBXeW9SdmJtbDJQbzAwVUhhZyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODkxMCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9","signature":"MEUCIQD3NOM7Aw0HxPw6EFGf86iwf2yd3p4NncNNLcjd-86zgwIgHuh80bLNV7EcwBi4IAcH57iueLg0X2gLtO5_Y6PMCFE","userHandle":"2"},"type":"public-key","clientExtensionResults":{}}', + } + const dbAuth = new DbAuthHandler(event, context, options) + + expect.assertions(1) + try { + await dbAuth.webAuthnAuthenticate() + } catch (e) { + const savedUser = await db.user.findFirst({ where: { id: user.id } }) + expect(savedUser.webAuthnChallenge).toEqual(null) + } + }) + + it('sets a webAuthn cookie if valid authentication', async () => { + const user = await createDbUser({ + webAuthnChallenge: 'LtgWphYK_eN5rXc_HdvULvOqpPWyoRvbml2Po00UHag', + }) + await db.userCredential.create({ + data: { + id: 'CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA', + userId: user.id, + publicKey: Buffer.from([ + 165, 1, 2, 3, 38, 32, 1, 33, 88, 32, 24, 136, 169, 77, 11, 126, 129, + 202, 3, 60, 234, 86, 233, 152, 222, 252, 11, 253, 11, 79, 163, 89, + 189, 145, 216, 240, 102, 92, 146, 75, 249, 207, 34, 88, 32, 187, + 235, 12, 104, 222, 236, 198, 241, 195, 234, 111, 64, 60, 86, 40, + 254, 118, 163, 27, 172, 76, 173, 16, 120, 238, 20, 235, 98, 67, 103, + 109, 240, + ]), + transports: null, + counter: 0, + }, + }) + + event = { + headers: { 'Content-Type': 'application/json' }, + body: '{"method":"webAuthnAuthenticate","id":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","rawId":"CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTHRnV3BoWUtfZU41clhjX0hkdlVMdk9xcFBXeW9SdmJtbDJQbzAwVUhhZyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODkxMCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9","signature":"MEUCIQD3NOM7Aw0HxPw6EFGf86iwf2yd3p4NncNNLcjd-86zgwIgHuh80bLNV7EcwBi4IAcH57iueLg0X2gLtO5_Y6PMCFE","userHandle":"2"},"type":"public-key","clientExtensionResults":{}}', + } + const dbAuth = new DbAuthHandler(event, context, options) + + const [body, headers] = await dbAuth.webAuthnAuthenticate() + + expect(body).toEqual(false) + expect(headers['Set-Cookie'][0]).toMatch( + 'webAuthn=CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA' + ) + }) + }) + + describe('webAuthnAuthOptions', () => { + it('throws an error if user is not logged in', async () => { + event = { + headers: {}, + } + const dbAuth = new DbAuthHandler(event, context, options) + + try { + await dbAuth.webAuthnAuthOptions() + } catch (e) { + expect(e instanceof dbAuthError.NotLoggedInError).toEqual(true) + } + expect.assertions(1) + }) + + it('throws an error if WebAuthn is disabled', async () => { + event = { + headers: {}, + } + options.webAuthn.enabled = false + const dbAuth = new DbAuthHandler(event, context, options) + + try { + await dbAuth.webAuthnAuthOptions() + } catch (e) { + expect(e instanceof dbAuthError.WebAuthnError).toEqual(true) + } + expect.assertions(1) + }) + + it('returns options needed for webAuthn registration', async () => { + const user = await createDbUser() + event = { + headers: { + cookie: encryptToCookie( + JSON.stringify({ id: user.id }) + ';' + 'token' + ), + }, + } + const dbAuth = new DbAuthHandler(event, context, options) + const response = await dbAuth.webAuthnAuthOptions() + const regOptions = response[0] + + expect(regOptions.allowCredentials).toEqual([]) + expect(regOptions.challenge).not.toBeUndefined() + expect(regOptions.rpId).toEqual(options.webAuthn.domain) + expect(regOptions.timeout).toEqual(options.webAuthn.timeout) + }) + + it('includes existing devices', async () => { + const user = await createDbUser() + const credential = await db.userCredential.create({ + data: { + id: 'qwertyuiog', + userId: user.id, + transports: null, + }, + }) + + event = { + headers: { + cookie: encryptToCookie( + JSON.stringify({ id: user.id }) + ';' + 'token' + ), + }, + } + const dbAuth = new DbAuthHandler(event, context, options) + const response = await dbAuth.webAuthnAuthOptions() + const regOptions = response[0] + + expect(regOptions.allowCredentials[0].id).toEqual(credential.id) + expect(regOptions.allowCredentials[0].transports).toEqual([ + 'usb', + 'ble', + 'nfc', + 'internal', + ]) + expect(regOptions.allowCredentials[0].type).toEqual('public-key') + }) + }) + + describe('webAuthnRegOptions', () => { + it('throws an error if user is not logged in', async () => { + event = { + headers: {}, + } + const dbAuth = new DbAuthHandler(event, context, options) + + try { + await dbAuth.webAuthnRegOptions() + } catch (e) { + expect(e instanceof dbAuthError.NotLoggedInError).toEqual(true) + } + expect.assertions(1) + }) + + it('throws an error if WebAuthn is disabled', async () => { + event = { + headers: {}, + } + options.webAuthn.enabled = false + const dbAuth = new DbAuthHandler(event, context, options) + + try { + await dbAuth.webAuthnRegOptions() + } catch (e) { + expect(e instanceof dbAuthError.WebAuthnError).toEqual(true) + } + expect.assertions(1) + }) + + it('returns options needed for webAuthn registration', async () => { + const user = await createDbUser() + event = { + headers: { + cookie: encryptToCookie( + JSON.stringify({ id: user.id }) + ';' + 'token' + ), + }, + } + const dbAuth = new DbAuthHandler(event, context, options) + const response = await dbAuth.webAuthnRegOptions() + const regOptions = response[0] + + expect(regOptions.attestation).toEqual('none') + expect(regOptions.authenticatorSelection.authenticatorAttachment).toEqual( + options.webAuthn.type + ) + expect(regOptions.excludeCredentials).toEqual([]) + expect(regOptions.rp.name).toEqual(options.webAuthn.name) + expect(regOptions.rp.id).toEqual(options.webAuthn.domain) + expect(regOptions.timeout).toEqual(options.webAuthn.timeout) + expect(regOptions.user.id).toEqual(user.id) + expect(regOptions.user.displayName).toEqual(user.email) + expect(regOptions.user.name).toEqual(user.email) + }) + + it('defaults timeout if not set', async () => { + const user = await createDbUser() + event = { + headers: { + cookie: encryptToCookie( + JSON.stringify({ id: user.id }) + ';' + 'token' + ), + }, + } + options.webAuthn.timeout = null + const dbAuth = new DbAuthHandler(event, context, options) + const response = await dbAuth.webAuthnRegOptions() + + expect(response[0].timeout).toEqual(60000) + }) + + it('saves the generated challenge to the user record', async () => { + let user = await createDbUser() + event = { + headers: { + cookie: encryptToCookie( + JSON.stringify({ id: user.id }) + ';' + 'token' + ), + }, + } + const dbAuth = new DbAuthHandler(event, context, options) + const response = await dbAuth.webAuthnRegOptions() + user = await db.user.findFirst({ where: { id: user.id } }) + + expect(user.webAuthnChallenge).toEqual(response[0].challenge) + }) + }) + + describe('webAuthnRegister', () => { + it('saves a credential record to the database', async () => { + const user = await createDbUser({ + webAuthnChallenge: 'HuGPrQqK7f53NLwMZMst_DL9Dig2BBivDYWWpawIPVM', + }) + event = { + headers: { + 'Content-Type': 'application/json', + cookie: encryptToCookie( + JSON.stringify({ id: user.id }) + ';' + 'token' + ), + }, + body: '{"method":"webAuthnRegister","id":"GqjZOuYYppObBDeVknbrcBLkaa9imS5EJJwtCV740asUz24sdAmGFg","rawId":"GqjZOuYYppObBDeVknbrcBLkaa9imS5EJJwtCV740asUz24sdAmGFg","response":{"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVisSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAKBqo2TrmGKaTmwQ3lZJ263AS5GmvYpkuRCScLQle-NGrFM9uLHQJhhalAQIDJiABIVggGIipTQt-gcoDPOpW6Zje_Av9C0-jWb2R2PBmXJJL-c8iWCC76wxo3uzG8cPqb0A8Vij-dqMbrEytEHjuFOtiQ2dt8A","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSHVHUHJRcUs3ZjUzTkx3TVpNc3RfREw5RGlnMkJCaXZEWVdXcGF3SVBWTSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODkxMCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9"},"type":"public-key","clientExtensionResults":{},"transports":["internal"]}', + } + const dbAuth = new DbAuthHandler(event, context, options) + + await dbAuth.webAuthnRegister() + + const credential = db.userCredential.findFirst({ + where: { userId: user.id }, + }) + + expect(credential.id).toEqual( + 'GqjZOuYYppObBDeVknbrcBLkaa9imS5EJJwtCV740asUz24sdAmGFg' + ) + expect(credential.transports).toEqual('["internal"]') + expect(credential.counter).toEqual(0) + }) + }) + + describe('_validateOptions', () => { + it('throws an error if credentialModelAccessor is defined but not webAuthn options', () => { + delete options.webAuthn + try { + const _instance = new DbAuthHandler({ headers: {} }, context, options) + } catch (e) { + expect(e).toBeInstanceOf(dbAuthError.NoWebAuthnConfigError) + } + expect.assertions(1) + }) + + it('throws an error if credentialModelAccessor is undefined but webAuthn options exist', () => { + delete options.credentialModelAccessor + try { + const _instance = new DbAuthHandler({ headers: {} }, context, options) + } catch (e) { + expect(e).toBeInstanceOf(dbAuthError.NoWebAuthnConfigError) + } + expect.assertions(1) + }) + }) + + describe('_webAuthnCookie', () => { + it('returns the parts needed for the webAuthn cookie, defaulted to future expire', () => { + const dbAuth = new DbAuthHandler({ headers: {} }, context, options) + + expect(dbAuth._webAuthnCookie('1234')).toMatch('webAuthn=1234;Expires=') + }) + + it('returns the parts needed for the expire the webAuthn cookie', () => { + const dbAuth = new DbAuthHandler({ headers: {} }, context, options) + + expect(dbAuth._webAuthnCookie('1234', 'now')).toMatch( + 'webAuthn=1234;Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + }) + }) + describe('_cookieAttributes', () => { it('returns an array of attributes for the session cookie', () => { const dbAuth = new DbAuthHandler( @@ -1268,7 +1675,7 @@ describe('dbAuth', () => { expect(Object.keys(headers).length).toEqual(1) expect(headers['Set-Cookie']).toMatch( - `Expires=${dbAuth.futureExpiresDate}` + `Expires=${dbAuth.sessionExpiresDate}` ) // can't really match on the session value since it will change on every render, // due to CSRF token generation but we can check that it contains a only the diff --git a/packages/api/src/functions/dbAuth/__tests__/shared.test.js b/packages/api/src/functions/dbAuth/__tests__/shared.test.js index bb7b40049df0..1519e68f6d5e 100644 --- a/packages/api/src/functions/dbAuth/__tests__/shared.test.js +++ b/packages/api/src/functions/dbAuth/__tests__/shared.test.js @@ -1,7 +1,12 @@ import CryptoJS from 'crypto-js' import * as error from '../errors' -import { getSession, decryptSession, dbAuthSession } from '../shared' +import { + getSession, + decryptSession, + dbAuthSession, + webAuthnSession, +} from '../shared' process.env.SESSION_SECRET = 'nREjs1HPS7cFia6tQHK70EWGtfhOgbqJQKsHQz3S' @@ -78,3 +83,17 @@ describe('dbAuthSession()', () => { expect(dbAuthSession(event)).toEqual(first) }) }) + +describe('webAuthnSession', () => { + it('returns null if no cookies', () => { + expect(webAuthnSession({ headers: {} })).toEqual(null) + }) + + it('returns the webAuthn cookie data', () => { + const output = webAuthnSession({ + headers: { cookie: 'session=abcd1234;webAuthn=zyxw9876' }, + }) + + expect(output).toEqual('zyxw9876') + }) +}) diff --git a/packages/api/src/functions/dbAuth/errors.ts b/packages/api/src/functions/dbAuth/errors.ts index a5b88c45b807..b45f302cca17 100644 --- a/packages/api/src/functions/dbAuth/errors.ts +++ b/packages/api/src/functions/dbAuth/errors.ts @@ -42,6 +42,24 @@ export class NoResetPasswordHandlerError extends Error { } } +export class NoWebAuthnConfigError extends Error { + constructor() { + super( + 'To use Webauthn you need both `webauthn` and `credentialModelAccessor` config options, see https://redwoodjs.com/docs/auth/dbAuth#webauthn' + ) + this.name = 'NoWebAuthnConfigError' + } +} + +export class MissingWebAuthnConfigError extends Error { + constructor() { + super( + 'You are missing one or more WebAuthn config options, see https://redwoodjs.com/docs/auth/dbAuth#webauthn' + ) + this.name = 'MissingWebAuthnConfigError' + } +} + export class UnknownAuthMethodError extends Error { constructor(name: string) { super(`Unknown auth method '${name}'`) @@ -195,3 +213,19 @@ export class GenericError extends Error { this.name = 'GenericError' } } + +export class WebAuthnError extends Error { + constructor(message = 'WebAuthn Error') { + super(message) + this.name = 'WebAuthnError' + } +} + +export class NoWebAuthnSessionError extends WebAuthnError { + constructor( + message = 'Log in with username and password to enable WebAuthn' + ) { + super(message) + this.name = 'NoWebAuthnSessionError' + } +} diff --git a/packages/api/src/functions/dbAuth/shared.ts b/packages/api/src/functions/dbAuth/shared.ts index fe035504f7f5..667b3569c31c 100644 --- a/packages/api/src/functions/dbAuth/shared.ts +++ b/packages/api/src/functions/dbAuth/shared.ts @@ -71,3 +71,19 @@ export const dbAuthSession = (event: APIGatewayProxyEvent) => { return null } } + +export const webAuthnSession = (event: APIGatewayProxyEvent) => { + if (!event.headers.cookie) { + return null + } + + const webAuthnCookie = event.headers.cookie.split(';').find((cook) => { + return cook.split('=')[0].trim() === 'webAuthn' + }) + + if (!webAuthnCookie || webAuthnCookie === 'webAuthn=') { + return null + } + + return webAuthnCookie.split('=')[1].trim() +} diff --git a/packages/auth/package.json b/packages/auth/package.json index 6a690181f27a..be4eda4bc2f9 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -10,7 +10,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ - "dist" + "dist", + "webAuthn" ], "scripts": { "build": "yarn build:js && yarn build:types", @@ -37,6 +38,8 @@ "@nhost/hasura-auth-js": "1.3.4", "@nhost/nhost-js": "1.4.6", "@okta/okta-auth-js": "6.7.2", + "@simplewebauthn/browser": "5.2.1", + "@simplewebauthn/typescript-types": "5.2.1", "@supabase/supabase-js": "1.35.4", "@types/netlify-identity-widget": "1.9.3", "@types/react": "17.0.47", diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 0bff41bdc3d1..8976d03ca770 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -14,6 +14,7 @@ import type { SupportedAuthClients, SupportedUserMetadata, } from './authClients' +import type { WebAuthnClientType } from './webAuthn' export interface CurrentUser { roles?: Array | string @@ -97,7 +98,7 @@ const AuthUpdateListener = ({ type AuthProviderProps = | { client: SupportedAuthClients - type: Omit + type: Omit config?: never skipFetchCurrentUser?: boolean children?: ReactNode | undefined @@ -110,7 +111,7 @@ type AuthProviderProps = children?: ReactNode | undefined } | { - client?: never + client?: WebAuthnClientType type: 'dbAuth' config?: SupportedAuthConfig skipFetchCurrentUser?: boolean diff --git a/packages/auth/src/authClients/dbAuth.ts b/packages/auth/src/authClients/dbAuth.ts index 2961b3bc43f1..479185611d3a 100644 --- a/packages/auth/src/authClients/dbAuth.ts +++ b/packages/auth/src/authClients/dbAuth.ts @@ -1,3 +1,5 @@ +import type { WebAuthnClientType } from '../webAuthn' + import { AuthClient } from './index' export interface LoginAttributes { @@ -12,7 +14,7 @@ export interface ResetPasswordAttributes { export type SignupAttributes = Record & LoginAttributes -export type DbAuth = () => null +export type DbAuth = undefined | WebAuthnClientType export type DbAuthConfig = { fetchConfig: { @@ -26,7 +28,7 @@ let lastTokenCheckAt = new Date('1970-01-01T00:00:00') let cachedToken: string | null export const dbAuth = ( - _client: DbAuth, + client: any, config: DbAuthConfig = { fetchConfig: { credentials: 'same-origin' } } ): AuthClient => { const { credentials } = config.fetchConfig @@ -140,7 +142,7 @@ export const dbAuth = ( return { type: 'dbAuth', - client: () => null, + client, login, logout, signup, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index f90ea80c2b9e..5b99fbafdb66 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,4 +1,3 @@ export { SupportedAuthTypes } from './authClients' - export { AuthProvider, AuthContextInterface, CurrentUser } from './AuthProvider' export { useAuth } from './useAuth' diff --git a/packages/auth/src/webAuthn/index.ts b/packages/auth/src/webAuthn/index.ts new file mode 100644 index 000000000000..0236b26a8c6a --- /dev/null +++ b/packages/auth/src/webAuthn/index.ts @@ -0,0 +1,197 @@ +import { + platformAuthenticatorIsAvailable, + startRegistration, + startAuthentication, +} from '@simplewebauthn/browser' + +class WebAuthnRegistrationError extends Error { + constructor(message: string) { + super(message) + this.name = 'WebAuthnRegistrationError' + } +} + +class WebAuthnAuthenticationError extends Error { + constructor(message: string) { + super(message) + this.name = 'WebAuthnAuthenticationError' + } +} + +class WebAuthnAlreadyRegisteredError extends WebAuthnRegistrationError { + constructor() { + super('This device is already registered') + this.name = 'WebAuthnAlreadyRegisteredError' + } +} + +class WebAuthnDeviceNotFoundError extends WebAuthnAuthenticationError { + constructor() { + super('WebAuthn device not found') + this.name = 'WebAuthnDeviceNotFoundError' + } +} + +class WebAuthnNoAuthenticatorError extends WebAuthnAuthenticationError { + constructor() { + super( + "This device was not recognized. Use username/password login, or if you're using iOS you can try reloading this page" + ) + this.name = 'WebAuthnNoAuthenticatorError' + } +} + +const isSupported = async () => { + return await platformAuthenticatorIsAvailable() +} + +const isEnabled = () => !!document.cookie.match(/webAuthn/) + +const authenticationOptions = async () => { + let response + + try { + response = await fetch( + `${global.RWJS_API_DBAUTH_URL}?method=webAuthnAuthOptions`, + { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + } + ) + } catch (e: any) { + console.error(e.message) + throw new WebAuthnAuthenticationError( + `Could not start authentication: ${e.message}` + ) + } + + const options = await response.json() + + if (response.status !== 200) { + console.info(options) + if (options.error?.match(/username and password/)) { + console.info('regex match') + throw new WebAuthnDeviceNotFoundError() + } else { + console.info('no match') + throw new WebAuthnAuthenticationError( + `Could not start authentication: ${options.error}` + ) + } + } + + return options +} + +const authenticate = async () => { + const options = await authenticationOptions() + + try { + const browserResponse = await startAuthentication(options) + + const authResponse = await fetch(global.RWJS_API_DBAUTH_URL, { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method: 'webAuthnAuthenticate', + ...browserResponse, + }), + }) + + if (authResponse.status !== 200) { + throw new WebAuthnAuthenticationError( + `Could not complete authentication: ${ + (await authResponse.json()).error + }` + ) + } else { + return true + } + } catch (e: any) { + if ( + e.message.match( + /No available authenticator recognized any of the allowed credentials/ + ) + ) { + throw new WebAuthnNoAuthenticatorError() + } else { + throw new WebAuthnAuthenticationError( + `Error while authenticating: ${e.message}` + ) + } + } +} + +const registrationOptions = async () => { + let optionsResponse + + try { + optionsResponse = await fetch( + `${global.RWJS_API_DBAUTH_URL}?method=webAuthnRegOptions`, + { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + } + ) + } catch (e: any) { + console.error(e) + throw new WebAuthnRegistrationError( + `Could not start registration: ${e.message}` + ) + } + + const options = await optionsResponse.json() + + if (optionsResponse.status !== 200) { + throw new WebAuthnRegistrationError( + `Could not start registration: ${options.error}` + ) + } + + return options +} + +const register = async () => { + const options = await registrationOptions() + let regResponse + + try { + regResponse = await startRegistration(options) + } catch (e: any) { + if (e.name === 'InvalidStateError') { + throw new WebAuthnAlreadyRegisteredError() + } else { + throw new WebAuthnRegistrationError( + `Error while registering: ${e.message}` + ) + } + } + + let verifyResponse + + try { + verifyResponse = await fetch(global.RWJS_API_DBAUTH_URL, { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'webAuthnRegister', ...regResponse }), + }) + } catch (e: any) { + throw new WebAuthnRegistrationError(`Error while registering: ${e.message}`) + } + + if (verifyResponse.status !== 200) { + throw new WebAuthnRegistrationError( + `Could not complete registration: ${options.error}` + ) + } else { + return true + } +} + +const WebAuthnClient = { isSupported, isEnabled, authenticate, register } + +export default WebAuthnClient + +export type WebAuthnClientType = typeof WebAuthnClient diff --git a/packages/auth/webAuthn/index.js b/packages/auth/webAuthn/index.js new file mode 100644 index 000000000000..3fb1caeaf8a5 --- /dev/null +++ b/packages/auth/webAuthn/index.js @@ -0,0 +1,2 @@ +/* eslint-env es6, commonjs */ +module.exports = require('../dist/webAuthn') diff --git a/packages/auth/webAuthn/package.json b/packages/auth/webAuthn/package.json new file mode 100644 index 000000000000..8d8e4168fd4d --- /dev/null +++ b/packages/auth/webAuthn/package.json @@ -0,0 +1,4 @@ +{ + "main": "./index.js", + "types": "../dist/webAuthn/index.d.ts" +} diff --git a/packages/cli/src/commands/generate/dbAuth/dbAuth.js b/packages/cli/src/commands/generate/dbAuth/dbAuth.js index ff4b8adce7be..1bc683cbf191 100644 --- a/packages/cli/src/commands/generate/dbAuth/dbAuth.js +++ b/packages/cli/src/commands/generate/dbAuth/dbAuth.js @@ -2,6 +2,7 @@ import fs from 'fs' import path from 'path' import Listr from 'listr' +import prompts from 'prompts' import terminalLink from 'terminal-link' import { @@ -23,6 +24,43 @@ const ROUTES = [ ``, ] +const POST_INSTALL = + `One more thing...\n\n` + + ` ${c.warning("Pages created! But you're not done yet:")}\n\n` + + ` You'll need to tell your pages where to redirect after a user has logged in,\n` + + ` signed up, or reset their password. Look in LoginPage, SignupPage,\n` + + ` ForgotPasswordPage and ResetPasswordPage for these lines: \n\n` + + ` if (isAuthenticated) {\n` + + ` navigate(routes.home())\n` + + ` }\n\n` + + ` and change the route to where you want them to go if the user is already\n` + + ` logged in. Also take a look in the onSubmit() functions in ForgotPasswordPage\n` + + ` and ResetPasswordPage to change where the user redirects to after submitting\n` + + ` those forms.\n\n` + + ` Oh, and if you haven't already, add the necessary dbAuth functions and\n` + + ` app setup by running:\n\n` + + ` yarn rw setup auth dbAuth\n\n` + + ` Happy authenticating!\n` + +const WEBAUTHN_POST_INSTALL = + `One more thing...\n\n` + + ` ${c.warning("Pages created! But you're not done yet:")}\n\n` + + " You'll need to tell your pages where to redirect after a user has logged in,\n" + + ' signed up, or reset their password. In LoginPage, look for the `REDIRECT`\n' + + ` constant and change the route if it's something other than home().\n` + + ` In SignupPage, ForgotPasswordPage and ResetPasswordPage look for these lines:\n\n` + + ` if (isAuthenticated) {\n` + + ` navigate(routes.home())\n` + + ` }\n\n` + + ` and change the route to where you want them to go if the user is already\n` + + ` logged in. Also take a look in the onSubmit() functions in ForgotPasswordPage\n` + + ` and ResetPasswordPage to change where the user redirects to after submitting\n` + + ` those forms.\n\n` + + ` Oh, and if you haven't already, add the necessary dbAuth functions and\n` + + ` app setup by running:\n\n` + + ` yarn rw setup auth dbAuth\n\n` + + ` Happy authenticating!\n` + export const command = 'dbAuth' export const description = 'Generate Login, Signup and Forgot Password pages for dbAuth' @@ -68,6 +106,7 @@ export const files = ({ skipLogin, skipReset, skipSignup, + webAuthn, }) => { const files = [] @@ -92,7 +131,9 @@ export const files = ({ extension: typescript ? '.tsx' : '.js', webPathSection: 'pages', generator: 'dbAuth', - templatePath: 'login.tsx.template', + templatePath: webAuthn + ? 'login.webAuthn.tsx.template' + : 'login.tsx.template', }) ) } @@ -164,6 +205,7 @@ const tasks = ({ skipLogin, skipReset, skipSignup, + webAuthn, }) => { return new Listr( [ @@ -178,6 +220,7 @@ const tasks = ({ skipLogin, skipReset, skipSignup, + webAuthn, }), { overwriteExisting: force, @@ -198,23 +241,7 @@ const tasks = ({ { title: 'One more thing...', task: (ctx, task) => { - task.title = - `One more thing...\n\n` + - ` ${c.warning("Pages created! But you're not done yet:")}\n\n` + - ` You'll need to tell your pages where to redirect after a user has logged in,\n` + - ` signed up, or reset their password. Look in LoginPage, SignupPage,\n` + - ` ForgotPasswordPage and ResetPasswordPage for these lines: \n\n` + - ` if (isAuthenticated) {\n` + - ` navigate(routes.home())\n` + - ` }\n\n` + - ` and change the route to where you want them to go if the user is already\n` + - ` logged in. Also take a look in the onSubmit() functions in ForgotPasswordPage\n` + - ` and ResetPasswordPage to change where the user redirects to after submitting\n` + - ` those forms.\n\n` + - ` Oh, and if you haven't already, add the necessary dbAuth functions and\n` + - ` app setup by running:\n\n` + - ` yarn rw setup auth dbAuth\n\n` + - ` Happy authenticating!\n` + task.title = webAuthn ? WEBAUTHN_POST_INSTALL : POST_INSTALL }, }, ], @@ -223,7 +250,15 @@ const tasks = ({ } export const handler = async (options) => { - const t = tasks(options) + const response = await prompts({ + type: 'confirm', + name: 'answer', + message: `Enable WebAuthn support (TouchID/FaceID) on LoginPage? See https://redwoodjs.com/docs/auth/dbAuth#webAuthn`, + initial: false, + }) + const webAuthn = response.answer + + const t = tasks({ ...options, webAuthn }) try { await t.run() diff --git a/packages/cli/src/commands/generate/dbAuth/templates/login.webAuthn.tsx.template b/packages/cli/src/commands/generate/dbAuth/templates/login.webAuthn.tsx.template new file mode 100644 index 000000000000..069c93e23a3c --- /dev/null +++ b/packages/cli/src/commands/generate/dbAuth/templates/login.webAuthn.tsx.template @@ -0,0 +1,264 @@ +import { Link, navigate, routes } from '@redwoodjs/router' +import { useRef, useState } from 'react' +import { useAuth } from '@redwoodjs/auth' +import { + Form, + Label, + TextField, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' +import { useEffect } from 'react' + +const WELCOME_MESSAGE = 'Welcome back!' +const REDIRECT = routes.home() + +const LoginPage = ({ type }) => { + const { + isAuthenticated, + client: webAuthn, + loading, + logIn, + reauthenticate, + } = useAuth() + const [shouldShowWebAuthn, setShouldShowWebAuthn] = useState(false) + const [showWebAuthn, setShowWebAuthn] = useState( + webAuthn.isEnabled() && type !== 'password' + ) + + // should redirect right after login or wait to show the webAuthn prompts? + useEffect(() => { + if (isAuthenticated && (!shouldShowWebAuthn || webAuthn.isEnabled())) { + navigate(REDIRECT) + } + }, [isAuthenticated, shouldShowWebAuthn]) + + // if WebAuthn is enabled, show the prompt as soon as the page loads + useEffect(() => { + if (!loading && !isAuthenticated && showWebAuthn) { + onAuthenticate() + } + }, [loading, isAuthenticated]) + + // focus on the username field as soon as the page loads + const usernameRef = useRef() + useEffect(() => { + usernameRef.current && usernameRef.current.focus() + }, []) + + const onSubmit = async (data) => { + const webAuthnSupported = await webAuthn.isSupported() + + if (webAuthnSupported) { + setShouldShowWebAuthn(true) + } + const response = await logIn({ ...data }) + + if (response.message) { + // auth details good, but user not logged in + toast(response.message) + } else if (response.error) { + // error while authenticating + toast.error(response.error) + } else { + // user logged in + if (webAuthnSupported) { + setShowWebAuthn(true) + } else { + toast.success(WELCOME_MESSAGE) + } + } + } + + const onAuthenticate = async () => { + try { + await webAuthn.authenticate() + await reauthenticate() + toast.success(WELCOME_MESSAGE) + navigate(REDIRECT) + } catch (e) { + if (e.name === 'WebAuthnDeviceNotFoundError') { + toast.error( + 'Device not found, log in with username/password to continue' + ) + setShowWebAuthn(false) + } else { + toast.error(e.message) + } + } + } + + const onRegister = async () => { + try { + await webAuthn.register() + toast.success(WELCOME_MESSAGE) + navigate(REDIRECT) + } catch (e) { + toast.error(e.message) + } + } + + const onSkip = () => { + toast.success(WELCOME_MESSAGE) + setShouldShowWebAuthn(false) + } + + const AuthWebAuthnPrompt = () => { + return ( +
+

WebAuthn Login Enabled

+

Log in with your fingerprint, face or PIN

+
+ +
+
+ ) + } + + const RegisterWebAuthnPrompt = () => ( +
+

No more passwords!

+

+ Depending on your device you can log in with your fingerprint, face or + PIN next time. +

+
+ + +
+
+ ) + + const PasswordForm = () => ( +
+ + + + + + + + +
+ + Forgot Password? + +
+ + + +
+ Login +
+ + ) + + const formToRender = () => { + if (showWebAuthn) { + if (webAuthn.isEnabled()) { + return + } else { + return + } + } else { + return + } + } + + const linkToRender = () => { + if (showWebAuthn) { + if (webAuthn.isEnabled()) { + return ( +
+ or login with {' '} + + username and password + +
+ ) + } + } else { + return ( +
+ Don't have an account?{' '} + + Sign up! + +
+ ) + } + } + + if (loading) { + return null + } + + return ( + <> + + +
+ +
+
+
+

Login

+
+ +
+
{formToRender()}
+
+ + {linkToRender()} +
+
+
+ + ) +} + +export default LoginPage diff --git a/packages/cli/src/commands/setup/auth/auth.js b/packages/cli/src/commands/setup/auth/auth.js index 2b86ee8438bf..138c29bd28e3 100644 --- a/packages/cli/src/commands/setup/auth/auth.js +++ b/packages/cli/src/commands/setup/auth/auth.js @@ -201,7 +201,7 @@ const checkAuthProviderExists = async () => { } // the files to create to support auth -export const files = (provider) => { +export const files = ({ provider, webAuthn }) => { const templates = getTemplates() let files = {} @@ -209,14 +209,20 @@ export const files = (provider) => { for (const [templateProvider, templateFiles] of Object.entries(templates)) { if (provider === templateProvider) { templateFiles.forEach((templateFile) => { - const outputPath = - OUTPUT_PATHS[path.basename(templateFile).split('.')[1]] - const content = fs.readFileSync(templateFile).toString() - files = Object.assign(files, { - [outputPath]: getProject().isTypeScriptProject - ? content - : transformTSToJS(outputPath, content), - }) + const shouldUseTemplate = + (webAuthn && templateFile.match(/\.webAuthn\./)) || + (!webAuthn && !templateFile.match(/\.webAuthn\./)) + + if (shouldUseTemplate) { + const outputPath = + OUTPUT_PATHS[path.basename(templateFile).split('.')[1]] + const content = fs.readFileSync(templateFile).toString() + files = Object.assign(files, { + [outputPath]: getProject().isTypeScriptProject + ? content + : transformTSToJS(outputPath, content), + }) + } }) } } @@ -230,6 +236,7 @@ export const files = (provider) => { : transformTSToJS(templates.base[0], content), } } + return files } @@ -314,13 +321,16 @@ export const builder = (yargs) => { ) } -export const handler = async ({ provider, force, rwVersion }) => { - const providerData = await import(`./providers/${provider}`) +export const handler = async (yargs) => { + const { provider, rwVersion } = yargs + let force = yargs.force + let webAuthn = false + let providerData // check if api/src/lib/auth.js already exists and if so, ask the user to overwrite if (force === false) { - if (fs.existsSync(Object.keys(files(provider))[0])) { - const response = await prompts({ + if (fs.existsSync(Object.keys(files(provider, yargs))[0])) { + const forceResponse = await prompts({ type: 'confirm', name: 'answer', message: `Overwrite existing ${getPaths().api.lib.replace( @@ -329,17 +339,35 @@ export const handler = async ({ provider, force, rwVersion }) => { )}/auth.[jt]s?`, initial: false, }) - force = response.answer + force = forceResponse.answer } } + // only dbAuth supports WebAuthn right now, but in theory it could work with + // any provider + if (provider === 'dbAuth') { + const webAuthnResponse = await prompts({ + type: 'confirm', + name: 'answer', + message: `Enable WebAuthn support (TouchID/FaceID)? See https://redwoodjs.com/docs/auth/dbAuth#webAuthn`, + initial: false, + }) + webAuthn = webAuthnResponse.answer + } + + if (webAuthn) { + providerData = await import(`./providers/${provider}.webAuthn`) + } else { + providerData = await import(`./providers/${provider}`) + } + const tasks = new Listr( [ { title: 'Generating auth lib...', task: (_ctx, task) => { if (apiSrcDoesExist()) { - return writeFilesTask(files(provider), { + return writeFilesTask(files({ ...yargs, webAuthn }), { overwriteExisting: force, }) } else { diff --git a/packages/cli/src/commands/setup/auth/providers/dbAuth.js b/packages/cli/src/commands/setup/auth/providers/dbAuth.js index 1011cb3c085e..eeaef15a64e8 100644 --- a/packages/cli/src/commands/setup/auth/providers/dbAuth.js +++ b/packages/cli/src/commands/setup/auth/providers/dbAuth.js @@ -19,8 +19,11 @@ export const config = { export const webPackages = [] export const apiPackages = [] -const functionsPath = getPaths().api.functions.replace(getPaths().base, '') -const libPath = getPaths().api.lib.replace(getPaths().base, '') +export const libPath = getPaths().api.lib.replace(getPaths().base, '') +export const functionsPath = getPaths().api.functions.replace( + getPaths().base, + '' +) export const task = { title: 'Adding SESSION_SECRET...', diff --git a/packages/cli/src/commands/setup/auth/providers/dbAuth.webAuthn.js b/packages/cli/src/commands/setup/auth/providers/dbAuth.webAuthn.js new file mode 100644 index 000000000000..0c1bd8fa1b9e --- /dev/null +++ b/packages/cli/src/commands/setup/auth/providers/dbAuth.webAuthn.js @@ -0,0 +1,98 @@ +import path from 'path' + +import { getPaths } from '@redwoodjs/internal' + +import c from '../../../../lib/colors' + +import { functionsPath, libPath } from './dbAuth' + +// copy some identical values from dbAuth provider +export { task } from './dbAuth' + +// the lines that need to be added to App.{js,tsx} +export const config = { + imports: [`import WebAuthnClient from '@redwoodjs/auth/webAuthn'`], + authProvider: { + type: 'dbAuth', + client: 'WebAuthnClient', + }, +} + +// required packages to install +export const webPackages = ['@simplewebauthn/browser'] +export const apiPackages = ['@simplewebauthn/server'] + +// any notes to print out when the job is done +export const notes = [ + `${c.warning('Done! But you have a little more work to do:')}\n`, + 'You will need to add a couple of fields to your User table in order', + 'to store a hashed password, salt, reset token, and to connect it to', + 'a new UserCredential model to keep track of any devices used with', + 'WebAuthn authentication:', + '', + ' model User {', + ' id Int @id @default(autoincrement())', + ' email String @unique', + ' hashedPassword String', + ' salt String', + ' resetToken String?', + ' resetTokenExpiresAt DateTime?', + ' webAuthnChallenge String? @unique', + ' credentials UserCredential[]', + ' }', + '', + ' model UserCredential {', + ' id String @id', + ' userId Int', + ' user User @relation(fields: [userId], references: [id])', + ' publicKey Bytes', + ' transports String?', + ' counter BigInt', + ' }', + '', + 'If you already have existing user records you will need to provide', + 'a default value for `hashedPassword` and `salt` or Prisma complains, so', + 'change those to: ', + '', + ' hashedPassword String @default("")', + ' salt String @default("")', + '', + 'If you expose any of your user data via GraphQL be sure to exclude', + '`hashedPassword` and `salt` (or whatever you named them) from the', + 'SDL file that defines the fields for your user.', + '', + "You'll need to let Redwood know what fields 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', + `\`${functionsPath}/auth.js\` (this is also the place to tell Redwood if`, + 'you used a different name for the `hashedPassword`, `salt`,', + '`resetToken` or `resetTokenExpiresAt`, fields:`', + '', + ' authFields: {', + " id: 'id',", + " username: 'email',", + " hashedPassword: 'hashedPassword',", + " salt: 'salt',", + " resetToken: 'resetToken',", + " resetTokenExpiresAt: 'resetTokenExpiresAt',", + " challenge: 'webAuthnChallenge'", + ' },', + '', + "To get the actual user that's logged in, take a look at `getCurrentUser()`", + `in \`${libPath}/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', + `${path.join(getPaths().base, '.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 and deploy. To create a new secret, run:', + '', + ' yarn rw generate secret', + '', + 'Need simple Login, Signup, Forgot Password pages and WebAuthn prompts?', + "We've got a generator for those as well:", + '', + ' yarn rw generate dbAuth', +] diff --git a/packages/cli/src/commands/setup/auth/templates/dbAuth.auth.webAuthn.ts.template b/packages/cli/src/commands/setup/auth/templates/dbAuth.auth.webAuthn.ts.template new file mode 100644 index 000000000000..63de33f1deb3 --- /dev/null +++ b/packages/cli/src/commands/setup/auth/templates/dbAuth.auth.webAuthn.ts.template @@ -0,0 +1,108 @@ +import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' +import { db } from './db' + +/** + * The session object sent in as the first argument to getCurrentUser() will + * have a single key `id` containing the unique ID of the logged in user + * (whatever field you set as `authFields.id` in your auth function config). + * You'll need to update the call to `db` below if you use a different model + * name or unique field name, for example: + * + * return await db.profile.findUnique({ where: { email: session.id } }) + * ───┬─── ──┬── + * model accessor ─┘ unique id field name ─┘ + * + * !! BEWARE !! Anything returned from this function will be available to the + * client--it becomes the content of `currentUser` on the web side (as well as + * `context.currentUser` on the api side). You should carefully add additional + * fields to the `select` object below once you've decided they are safe to be + * seen if someone were to open the Web Inspector in their browser. + */ +export const getCurrentUser = async (session) => { + return await db.user.findUnique({ + where: { id: session.id }, + select: { id: true }, + }) +} + +/** + * The user is authenticated if there is a currentUser in the context + * + * @returns {boolean} - If the currentUser is authenticated + */ +export const isAuthenticated = (): boolean => { + return !!context.currentUser +} + +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ +type AllowedRoles = string | string[] | undefined + +/** + * Checks if the currentUser is authenticated (and assigned one of the given roles) + * + * @param roles: AllowedRoles - Checks if the currentUser is assigned one of these roles + * + * @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles, + * or when no roles are provided to check against. Otherwise returns false. + */ +export const hasRole = (roles: AllowedRoles): boolean => { + if (!isAuthenticated()) { + return false + } + + const currentUserRoles = context.currentUser?.roles + + if (typeof roles === 'string') { + if (typeof currentUserRoles === 'string') { + // roles to check is a string, currentUser.roles is a string + return currentUserRoles === roles + } else if (Array.isArray(currentUserRoles)) { + // roles to check is a string, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => roles === allowedRole) + } + } + + if (Array.isArray(roles)) { + if (Array.isArray(currentUserRoles)) { + // roles to check is an array, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => + roles.includes(allowedRole) + ) + } else if (typeof context.currentUser.roles === 'string') { + // roles to check is an array, currentUser.roles is a string + return roles.some( + (allowedRole) => context.currentUser?.roles === allowedRole + ) + } + } + + // roles not found + return false +} + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param roles: AllowedRoles - When checking role membership, these roles grant access. + * + * @returns - If the currentUser is authenticated (and assigned one of the given roles) + * + * @throws {AuthenticationError} - If the currentUser is not authenticated + * @throws {ForbiddenError} If the currentUser is not allowed due to role permissions + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const requireAuth = ({ roles }: { roles: AllowedRoles }) => { + if (!isAuthenticated()) { + throw new AuthenticationError("You don't have permission to do that.") + } + + if (roles && !hasRole(roles)) { + throw new ForbiddenError("You don't have access to do that.") + } +} diff --git a/packages/cli/src/commands/setup/auth/templates/dbAuth.function.webAuthn.ts.template b/packages/cli/src/commands/setup/auth/templates/dbAuth.function.webAuthn.ts.template new file mode 100644 index 000000000000..4636c6313f96 --- /dev/null +++ b/packages/cli/src/commands/setup/auth/templates/dbAuth.function.webAuthn.ts.template @@ -0,0 +1,195 @@ +import { db } from 'src/lib/db' +import { DbAuthHandler } from '@redwoodjs/api' + +export const handler = async (event, context) => { + const forgotPasswordOptions = { + // handler() is invoked after verifying that a user was found with the given + // username. This is where you can send the user an email with a link to + // reset their password. With the default dbAuth routes and field names, the + // URL to reset the password will be: + // + // 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()` + // 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) => { + return user + }, + + // How long the resetToken is valid for, in seconds (default is 24 hours) + expires: 60 * 60 * 24, + + errors: { + // for security reasons you may want to be vague here rather than expose + // the fact that the email address wasn't found (prevents fishing for + // valid email addresses) + usernameNotFound: 'Username not found', + // if the user somehow gets around client validation + usernameRequired: 'Username is required', + }, + } + + const loginOptions = { + // handler() is called after finding the user that matches the + // username/password provided at login, but before actually considering them + // logged in. The `user` argument will be the user in the database that + // matched the username/password. + // + // If you want to allow this user to log in simply return the user. + // + // If you want to prevent someone logging in for another reason (maybe they + // didn't validate their email yet), throw an error and it will be returned + // by the `logIn()` function from `useAuth()` in the form of: + // `{ message: 'Error message' }` + handler: (user) => { + return user + }, + + errors: { + usernameOrPasswordMissing: 'Both username and password are required', + usernameNotFound: 'Username ${username} not found', + // For security reasons you may want to make this the same as the + // usernameNotFound error so that a malicious user can't use the error + // to narrow down if it's the username or password that's incorrect + incorrectPassword: 'Incorrect password for ${username}', + }, + + // How long a user will remain logged in, in seconds + expires: 60 * 60 * 24 * 365 * 10, + } + + const resetPasswordOptions = { + // handler() is invoked after the password has been successfully updated in + // the database. Returning anything truthy will automatically logs the user + // in. Return `false` otherwise, and in the Reset Password page redirect the + // user to the login page. + handler: (user) => { + return user + }, + + // If `false` then the new password MUST be different than the current one + allowReusedPassword: true, + + errors: { + // the resetToken is valid, but expired + resetTokenExpired: 'resetToken is expired', + // no user was found with the given resetToken + resetTokenInvalid: 'resetToken is invalid', + // the resetToken was not present in the URL + resetTokenRequired: 'resetToken is required', + // new password is the same as the old password (apparently they did not forget it) + reusedPassword: 'Must choose a new password', + }, + } + + const signupOptions = { + // Whatever you want to happen to your data on new user signup. Redwood will + // check for duplicate usernames before calling this handler. At a minimum + // you need to save the `username`, `hashedPassword` and `salt` to your + // user table. `userAttributes` contains any additional object members that + // were included in the object given to the `signUp()` function you got + // from `useAuth()`. + // + // If you want the user to be immediately logged in, return the user that + // was created. + // + // If this handler throws an error, it will be returned by the `signUp()` + // function in the form of: `{ error: 'Error message' }`. + // + // If this returns anything else, it will be returned by the + // `signUp()` function in the form of: `{ message: 'String here' }`. + handler: ({ username, hashedPassword, salt, userAttributes }) => { + return db.user.create({ + data: { + email: username, + hashedPassword: hashedPassword, + salt: salt, + // name: userAttributes.name + }, + }) + }, + + errors: { + // `field` will be either "username" or "password" + fieldMissing: '${field} is required', + usernameTaken: 'Username `${username}` already in use', + }, + } + + const authHandler = new DbAuthHandler(event, context, { + // Provide prisma db client + db: db, + + // The name of the property you'd call on `db` to access your user table. + // ie. if your Prisma model is named `User` this value would be `user`, as in `db.user` + authModelAccessor: 'user', + + // The name of the property you'd call on `db` to access your user credentials table. + // ie. if your Prisma model is named `UserCredential` this value would be `userCredential`, as in `db.userCredential` + credentialModelAccessor: 'userCredential', + + // 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 + // something like `id` or `userId` or even `email`) + authFields: { + id: 'id', + username: 'email', + hashedPassword: 'hashedPassword', + salt: 'salt', + resetToken: 'resetToken', + resetTokenExpiresAt: 'resetTokenExpiresAt', + challenge: 'webAuthnChallenge', + }, + + // 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: { + HttpOnly: true, + Path: '/', + SameSite: 'Strict', + Secure: process.env.NODE_ENV !== 'development' ? true : false, + + // If you need to allow other domains (besides the api side) access to + // the dbAuth session cookie: + // Domain: 'example.com', + }, + + forgotPassword: forgotPasswordOptions, + login: loginOptions, + resetPassword: resetPasswordOptions, + signup: signupOptions, + + // See https://redwoodjs.com/docs/authentication/dbauth#webauthn for options + webAuthn: { + enabled: true, + // How long to allow re-auth via WebAuthn in seconds (default is 10 years). + // The `login.expires` time denotes how many seconds before a user will be + // logged out, and this value is how long they'll be to continue to use a + // fingerprint/face scan to log in again. When this one expires they + // *must* re-enter username and password to authenticate (WebAuthn will + // then be re-enabled for this amount of time). + expires: 60 * 60 * 24 * 365 * 10, + name: 'Redwood Application', + domain: + process.env.NODE_ENV === 'development' ? 'localhost' : 'server.com', + origin: + process.env.NODE_ENV === 'development' + ? 'http://localhost:8910' + : 'https://server.com', + type: 'platform', + timeout: 60000, + credentialFields: { + id: 'id', + userId: 'userId', + publicKey: 'publicKey', + transports: 'transports', + counter: 'counter', + }, + }, + }) + + return await authHandler.invoke() +} diff --git a/yarn.lock b/yarn.lock index 1ab4eb2adfd3..c71b24c75caf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5726,7 +5726,18 @@ __metadata: languageName: node linkType: hard -"@peculiar/asn1-schema@npm:^2.1.6": +"@peculiar/asn1-android@npm:^2.1.7": + version: 2.1.9 + resolution: "@peculiar/asn1-android@npm:2.1.9" + dependencies: + "@peculiar/asn1-schema": ^2.1.9 + asn1js: ^3.0.4 + tslib: ^2.4.0 + checksum: ace36645b8647e7d00fc203413a32411dd33e921bf6b24f3b1c4b0ffa3535f2c661018fc16f67b5d0c99e672d7daceb09c97f7deec8bc49228dcb5b486cd430f + languageName: node + linkType: hard + +"@peculiar/asn1-schema@npm:^2.1.6, @peculiar/asn1-schema@npm:^2.1.7, @peculiar/asn1-schema@npm:^2.1.9": version: 2.2.0 resolution: "@peculiar/asn1-schema@npm:2.2.0" dependencies: @@ -5737,6 +5748,19 @@ __metadata: languageName: node linkType: hard +"@peculiar/asn1-x509@npm:^2.1.7": + version: 2.1.9 + resolution: "@peculiar/asn1-x509@npm:2.1.9" + dependencies: + "@peculiar/asn1-schema": ^2.1.9 + asn1js: ^3.0.4 + ipaddr.js: ^2.0.1 + pvtsutils: ^1.3.2 + tslib: ^2.4.0 + checksum: 7d0d804ead82686b5fc2826951649f02299cc3a9516901c014d815a253fa7b97400a07355b6e17b3a0e9a94d60c39609a3bd49ed64f2a6b6b1d1d27054fbe91c + languageName: node + linkType: hard + "@peculiar/json-schema@npm:^1.1.12": version: 1.1.12 resolution: "@peculiar/json-schema@npm:1.1.12" @@ -6142,6 +6166,7 @@ __metadata: "@clerk/clerk-sdk-node": 3.8.6 "@prisma/client": 3.15.2 "@redwoodjs/auth": 2.1.0 + "@simplewebauthn/server": 5.2.1 "@types/aws-lambda": 8.10.101 "@types/crypto-js": 4.1.1 "@types/jsonwebtoken": 8.5.8 @@ -6150,6 +6175,7 @@ __metadata: "@types/split2": 3.2.1 "@types/uuid": 8.3.4 aws-lambda: 1.0.7 + base64url: 3.0.1 core-js: 3.23.5 cross-undici-fetch: 0.4.13 crypto-js: 4.1.1 @@ -6202,6 +6228,8 @@ __metadata: "@nhost/hasura-auth-js": 1.3.4 "@nhost/nhost-js": 1.4.6 "@okta/okta-auth-js": 6.7.2 + "@simplewebauthn/browser": 5.2.1 + "@simplewebauthn/typescript-types": 5.2.1 "@supabase/supabase-js": 1.35.4 "@types/netlify-identity-widget": 1.9.3 "@types/react": 17.0.47 @@ -6766,6 +6794,40 @@ __metadata: languageName: node linkType: hard +"@simplewebauthn/browser@npm:5.2.1": + version: 5.2.1 + resolution: "@simplewebauthn/browser@npm:5.2.1" + checksum: 7279ecec4ee93256b510c18e2a8636296bf5114638804d402b371308aa6ffe43545239fe0e79c02880961ec978f375fdc585b763a2052237e047fdc28fd5c7c3 + languageName: node + linkType: hard + +"@simplewebauthn/server@npm:5.2.1": + version: 5.2.1 + resolution: "@simplewebauthn/server@npm:5.2.1" + dependencies: + "@peculiar/asn1-android": ^2.1.7 + "@peculiar/asn1-schema": ^2.1.7 + "@peculiar/asn1-x509": ^2.1.7 + "@simplewebauthn/typescript-types": ^5.2.1 + base64url: ^3.0.1 + cbor: ^5.1.0 + debug: ^4.3.2 + elliptic: ^6.5.3 + jsrsasign: ^10.4.0 + jwk-to-pem: ^2.0.4 + node-fetch: ^2.6.0 + node-rsa: ^1.1.1 + checksum: 4071cb6912908c6f14dbc80b57adeb604cb0b37566abb991faff647ee9c2ba6fa3d46e6e2ea7e4ca9ca4054f8dac0f7f622e9b403ff84bfe29b0a5e4cb0fcb89 + languageName: node + linkType: hard + +"@simplewebauthn/typescript-types@npm:5.2.1, @simplewebauthn/typescript-types@npm:^5.2.1": + version: 5.2.1 + resolution: "@simplewebauthn/typescript-types@npm:5.2.1" + checksum: aa14895068ee2f9b66b7f1d25d2bb477413e4b1d5b4792b6822ccda767de779f9b6fd2b7d1543c39ff68786ea3b13e0598e785202579ac4675835124d63fc8bb + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.23.3": version: 0.23.4 resolution: "@sinclair/typebox@npm:0.23.4" @@ -10665,7 +10727,7 @@ __metadata: languageName: node linkType: hard -"asn1.js@npm:^5.2.0": +"asn1.js@npm:^5.2.0, asn1.js@npm:^5.3.0": version: 5.4.1 resolution: "asn1.js@npm:5.4.1" dependencies: @@ -10677,7 +10739,7 @@ __metadata: languageName: node linkType: hard -"asn1@npm:~0.2.3": +"asn1@npm:^0.2.4, asn1@npm:~0.2.3": version: 0.2.6 resolution: "asn1@npm:0.2.6" dependencies: @@ -10686,7 +10748,7 @@ __metadata: languageName: node linkType: hard -"asn1js@npm:^3.0.1, asn1js@npm:^3.0.5": +"asn1js@npm:^3.0.1, asn1js@npm:^3.0.4, asn1js@npm:^3.0.5": version: 3.0.5 resolution: "asn1js@npm:3.0.5" dependencies: @@ -11353,6 +11415,13 @@ __metadata: languageName: node linkType: hard +"base64url@npm:3.0.1, base64url@npm:^3.0.1": + version: 3.0.1 + resolution: "base64url@npm:3.0.1" + checksum: 5ca9d6064e9440a2a45749558dddd2549ca439a305793d4f14a900b7256b5f4438ef1b7a494e1addc66ced5d20f5c010716d353ed267e4b769e6c78074991241 + languageName: node + linkType: hard + "base@npm:^0.11.1": version: 0.11.2 resolution: "base@npm:0.11.2" @@ -11428,7 +11497,7 @@ __metadata: languageName: node linkType: hard -"bignumber.js@npm:^9.0.0": +"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.0.1": version: 9.0.2 resolution: "bignumber.js@npm:9.0.2" checksum: b5c598ede49c3e391e53de6f992ee53960c45c96bb26e3933bd252890e77e3c703b88897a2148703f90f693d538396f8bed7c118a84a32fd54e24932bd16c04f @@ -12243,6 +12312,16 @@ __metadata: languageName: node linkType: hard +"cbor@npm:^5.1.0": + version: 5.2.0 + resolution: "cbor@npm:5.2.0" + dependencies: + bignumber.js: ^9.0.1 + nofilter: ^1.0.4 + checksum: d39e14a05930648c6446b107aee3653e1b1ce8195dd121cb65790d9091202d8d98af0e4c17787f38bbc33fadc969ca99c94b40c144b84ce1e406a7f411c3ccf4 + languageName: node + linkType: hard + "ccount@npm:^1.0.0": version: 1.1.0 resolution: "ccount@npm:1.1.0" @@ -20975,6 +21054,13 @@ __metadata: languageName: node linkType: hard +"jsrsasign@npm:^10.4.0": + version: 10.5.24 + resolution: "jsrsasign@npm:10.5.24" + checksum: c4fa9bcae111e12895a6d6af188448ee825fb265dde3a26c607136aac72c6fc0c8d034dcc2e03695e9711eee778a3c2fc4b0a5d8af019b54b2c088bc55330b7d + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.1": version: 3.3.2 resolution: "jsx-ast-utils@npm:3.3.2" @@ -21040,6 +21126,17 @@ __metadata: languageName: node linkType: hard +"jwk-to-pem@npm:^2.0.4": + version: 2.0.5 + resolution: "jwk-to-pem@npm:2.0.5" + dependencies: + asn1.js: ^5.3.0 + elliptic: ^6.5.4 + safe-buffer: ^5.0.1 + checksum: 307cacfbf4f38b4c4c77bcf3e578ef0d1d9536a9323e4f5be7e55e777d9df4fb5e89abbbfaf7c080b9f9da1241217f10391e4114c7a72cac66411d374427eeb9 + languageName: node + linkType: hard + "jwks-rsa@npm:2.0.5, jwks-rsa@npm:^2.0.2, jwks-rsa@npm:^2.0.4": version: 2.0.5 resolution: "jwks-rsa@npm:2.0.5" @@ -23350,6 +23447,15 @@ __metadata: languageName: node linkType: hard +"node-rsa@npm:^1.1.1": + version: 1.1.1 + resolution: "node-rsa@npm:1.1.1" + dependencies: + asn1: ^0.2.4 + checksum: af3b6534844dfeaa8290a7bc20d5e4d1a134f05d456725e56d75b7661d4d63cd63914ce335ee8889adc2458112b996d4ba8a83ba105bde744a26e61d9160f639 + languageName: node + linkType: hard + "nodemon@npm:2.0.19": version: 2.0.19 resolution: "nodemon@npm:2.0.19" @@ -23370,6 +23476,13 @@ __metadata: languageName: node linkType: hard +"nofilter@npm:^1.0.4": + version: 1.0.4 + resolution: "nofilter@npm:1.0.4" + checksum: fcee4ed627e18c8d66dda5afca79607e569e6997c08bd12dfb8a1578fe2674a1de7761b36ae207d6a10c545f27e236aae18f0c2b33e0ef8971507bda419793b7 + languageName: node + linkType: hard + "nopt@npm:^5.0.0": version: 5.0.0 resolution: "nopt@npm:5.0.0"