diff --git a/api/db/migrations/20241015183559_dbauth/migration.sql b/api/db/migrations/20241015183559_dbauth/migration.sql
new file mode 100644
index 0000000..953b24a
--- /dev/null
+++ b/api/db/migrations/20241015183559_dbauth/migration.sql
@@ -0,0 +1,35 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `contactAddressId` on the `User` table. All the data in the column will be lost.
+ - Added the required column `hashedPassword` to the `User` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `salt` to the `User` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- CreateTable
+CREATE TABLE "UserToContactAddress" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "userId" TEXT NOT NULL,
+ "contactAddressId" TEXT NOT NULL,
+ CONSTRAINT "UserToContactAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT "UserToContactAddress_contactAddressId_fkey" FOREIGN KEY ("contactAddressId") REFERENCES "ContactAddress" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_User" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "userId" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "hashedPassword" TEXT NOT NULL,
+ "salt" TEXT NOT NULL,
+ "resetToken" TEXT,
+ "resetTokenExpiresAt" DATETIME
+);
+INSERT INTO "new_User" ("email", "id", "userId") SELECT "email", "id", "userId" FROM "User";
+DROP TABLE "User";
+ALTER TABLE "new_User" RENAME TO "User";
+CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/api/db/schema.prisma b/api/db/schema.prisma
index 0cdeb80..7ceea93 100644
--- a/api/db/schema.prisma
+++ b/api/db/schema.prisma
@@ -30,25 +30,36 @@ model Account {
}
model ContactAddress {
- id String @id @default(cuid())
- Name String // name of person who is using the pendant
- Address1 String
- Address2 String
- Address3 String
- Town String
- PostalCode String
- Account Account[]
- User User[]
+ id String @id @default(cuid())
+ Name String // name of person who is using the pendant
+ Address1 String
+ Address2 String
+ Address3 String
+ Town String
+ PostalCode String
+ Account Account[]
+ UserToContactAddress UserToContactAddress[]
}
model User {
- id String @id @default(cuid())
- userId String @unique
- email String
+ id String @id @default(cuid())
+ userId String @unique
+ email String
+ hashedPassword String
+ salt String
+ resetToken String?
+ resetTokenExpiresAt DateTime?
+ accounts Account[]
+ roles Role[]
+ UserToContactAddress UserToContactAddress[]
+}
+
+model UserToContactAddress {
+ id Int @id
+ user User @relation(fields: [userId], references: [id])
address ContactAddress @relation(fields: [contactAddressId], references: [id])
+ userId String
contactAddressId String
- accounts Account[]
- roles Role[]
}
model Role {
diff --git a/api/package.json b/api/package.json
index 6eb8208..3a81087 100644
--- a/api/package.json
+++ b/api/package.json
@@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"@redwoodjs/api": "8.3.0",
+ "@redwoodjs/auth-dbauth-api": "8.4.0",
"@redwoodjs/graphql-server": "8.3.0"
}
}
diff --git a/api/src/functions/auth.ts b/api/src/functions/auth.ts
new file mode 100644
index 0000000..1991507
--- /dev/null
+++ b/api/src/functions/auth.ts
@@ -0,0 +1,208 @@
+import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
+
+import { DbAuthHandler } from '@redwoodjs/auth-dbauth-api'
+import type { DbAuthHandlerOptions, UserType } from '@redwoodjs/auth-dbauth-api'
+
+import { cookieName } from 'src/lib/auth'
+import { db } from 'src/lib/db'
+
+export const handler = async (
+ event: APIGatewayProxyEvent,
+ context: Context
+) => {
+ const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {
+ // 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.
+ //
+ // Note that this return value is sent to the client in *plain text*
+ // so don't include anything you wouldn't want prying eyes to see. The
+ // `user` here has been sanitized to only include the fields listed in
+ // `allowedUserFields` so it should be safe to return as-is.
+ handler: (user, _resetToken) => {
+ // TODO: Send user an email/message with a link to reset their password,
+ // including the `resetToken`. The URL should look something like:
+ // `http://localhost:8910/reset-password?resetToken=${resetToken}`
+
+ return user
+ },
+
+ // 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: DbAuthHandlerOptions['login'] = {
+ // 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: DbAuthHandlerOptions['resetPassword'] = {
+ // handler() is invoked after the password has been successfully updated in
+ // the database. Returning anything truthy will automatically log the user
+ // in. Return `false` otherwise, and in the Reset Password page redirect the
+ // user to the login page.
+ handler: (_user) => {
+ return true
+ },
+
+ // If `false` then the new password MUST be different from 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',
+ },
+ }
+
+ interface UserAttributes {
+ name: string
+ }
+
+ const signupOptions: DbAuthHandlerOptions<
+ UserType,
+ UserAttributes
+ >['signup'] = {
+ // 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: _userAttributes,
+ }) => {
+ return db.user.create({
+ data: {
+ email: username,
+ userId: username,
+ hashedPassword: hashedPassword,
+ salt: salt,
+ // name: userAttributes.name
+ },
+ })
+ },
+
+ // Include any format checks for password here. Return `true` if the
+ // password is valid, otherwise throw a `PasswordValidationError`.
+ // Import the error along with `DbAuthHandler` from `@redwoodjs/api` above.
+ passwordValidation: (_password) => {
+ return true
+ },
+
+ 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.
+ // i.e. if your Prisma model is named `User` this value would be `user`, as in `db.user`
+ authModelAccessor: 'user',
+
+ // 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',
+ },
+
+ // A list of fields on your user object that are safe to return to the
+ // client when invoking a handler that returns a user (like forgotPassword
+ // and signup). This list should be as small as possible to be sure not to
+ // leak any sensitive information to the client.
+ allowedUserFields: ['id', 'email'],
+
+ // Specifies attributes on the cookie that dbAuth sets in order to remember
+ // who is logged in. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies
+ cookie: {
+ attributes: {
+ HttpOnly: true,
+ Path: '/',
+ SameSite: 'Strict',
+ Secure: process.env.NODE_ENV !== 'development',
+
+ // If you need to allow other domains (besides the api side) access to
+ // the dbAuth session cookie:
+ // Domain: 'example.com',
+ },
+ name: cookieName,
+ },
+
+ forgotPassword: forgotPasswordOptions,
+ login: loginOptions,
+ resetPassword: resetPasswordOptions,
+ signup: signupOptions,
+ })
+
+ return await authHandler.invoke()
+}
diff --git a/api/src/functions/graphql.ts b/api/src/functions/graphql.ts
index f395c3b..e9c53e2 100644
--- a/api/src/functions/graphql.ts
+++ b/api/src/functions/graphql.ts
@@ -1,13 +1,19 @@
+import { createAuthDecoder } from '@redwoodjs/auth-dbauth-api'
import { createGraphQLHandler } from '@redwoodjs/graphql-server'
import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'
+import { cookieName, getCurrentUser } from 'src/lib/auth'
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
+const authDecoder = createAuthDecoder(cookieName)
+
export const handler = createGraphQLHandler({
+ authDecoder,
+ getCurrentUser,
loggerConfig: { logger, options: {} },
directives,
sdls,
diff --git a/api/src/lib/auth.ts b/api/src/lib/auth.ts
index ef6d8c9..1e9ce5d 100644
--- a/api/src/lib/auth.ts
+++ b/api/src/lib/auth.ts
@@ -1,32 +1,121 @@
+import type { Decoded } from '@redwoodjs/api'
+import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
+
+import { db } from './db'
+
/**
- * Once you are ready to add authentication to your application
- * you'll build out requireAuth() with real functionality. For
- * now we just return `true` so that the calls in services
- * have something to check against, simulating a logged
- * in user that is allowed to access that service.
+ * The name of the cookie that dbAuth sets
*
- * See https://redwoodjs.com/docs/authentication for more info.
+ * %port% will be replaced with the port the api server is running on.
+ * If you have multiple RW apps running on the same host, you'll need to
+ * make sure they all use unique cookie names
*/
-export const isAuthenticated = () => {
- return true
+export const cookieName = 'session_%port%'
+
+/**
+ * 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: Decoded) => {
+ if (!session || typeof session.id !== 'string') {
+ throw new Error('Invalid session')
+ }
+
+ return await db.user.findUnique({
+ where: { id: session.id },
+ select: { id: true },
+ })
}
-export const hasRole = ({ roles }) => {
- return roles !== undefined
+/**
+ * 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
}
-// This is used by the redwood directive
-// in ./api/src/directives/requireAuth
+/**
+ * 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
-// Roles are passed in by the requireAuth directive if you have auth setup
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const requireAuth = ({ roles }) => {
- return isAuthenticated()
+/**
+ * Checks if the currentUser is authenticated (and assigned one of the given roles)
+ *
+ * @param roles: {@link 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 currentUserRoles === 'string') {
+ // roles to check is an array, currentUser.roles is a string
+ return roles.some((allowedRole) => currentUserRoles === allowedRole)
+ }
+ }
+
+ // roles not found
+ return false
}
-export const getCurrentUser = async () => {
- throw new Error(
- 'Auth is not set up yet. See https://redwoodjs.com/docs/authentication ' +
- 'to get started'
- )
+/**
+ * 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: {@link AllowedRoles} - When checking role membership, these roles grant access.
+ *
+ * @returns - If the currentUser is authenticated (and assigned one of the given roles)
+ *
+ * @throws {@link AuthenticationError} - If the currentUser is not authenticated
+ * @throws {@link 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/package.json b/package.json
index 1acf46a..e0554c6 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
]
},
"devDependencies": {
+ "@redwoodjs/auth-dbauth-setup": "8.4.0",
"@redwoodjs/core": "8.4.0",
"@redwoodjs/project-config": "8.4.0",
"prettier-plugin-tailwindcss": "^0.6.8"
diff --git a/web/package.json b/web/package.json
index fe2f6bb..96e9724 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
]
},
"dependencies": {
+ "@redwoodjs/auth-dbauth-web": "8.4.0",
"@redwoodjs/forms": "8.4.0",
"@redwoodjs/router": "8.4.0",
"@redwoodjs/web": "8.4.0",
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 6d20074..56f13b6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -5,6 +5,8 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
+import { AuthProvider, useAuth } from './auth'
+
import './index.css'
import './scaffold.css'
@@ -16,7 +18,9 @@ interface AppProps {
const App = ({ children }: AppProps) => (
- {children}
+
+ {children}
+
)
diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx
index 2a59435..7b6aefd 100644
--- a/web/src/Routes.tsx
+++ b/web/src/Routes.tsx
@@ -9,13 +9,18 @@
import { Router, Route, Set } from '@redwoodjs/router'
+import ClientLayout from 'src/layouts/ClientLayout/ClientLayout'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
-import ClientLayout from 'src/layouts/ClientLayout/ClientLayout'
+import { useAuth } from './auth'
const Routes = () => {
return (
-
+
+
+
+
+
diff --git a/web/src/auth.ts b/web/src/auth.ts
new file mode 100644
index 0000000..143e75b
--- /dev/null
+++ b/web/src/auth.ts
@@ -0,0 +1,5 @@
+import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web'
+
+const dbAuthClient = createDbAuthClient()
+
+export const { AuthProvider, useAuth } = createAuth(dbAuthClient)
diff --git a/web/src/layouts/ClientLayout/ClientLayout.tsx b/web/src/layouts/ClientLayout/ClientLayout.tsx
index 66080d2..e3d0e62 100644
--- a/web/src/layouts/ClientLayout/ClientLayout.tsx
+++ b/web/src/layouts/ClientLayout/ClientLayout.tsx
@@ -1,4 +1,6 @@
import { Link, routes } from '@redwoodjs/router'
+
+import { useAuth } from 'src/auth'
import ThemeChanger from 'src/components/ThemeChanger/ThemeChanger'
type ClientLayoutProps = {
@@ -6,20 +8,35 @@ type ClientLayoutProps = {
}
const ClientLayout = ({ children }: ClientLayoutProps) => {
+ const { logOut } = useAuth()
+
return (
<>