diff --git a/api/db/migrations/20241003153016_add_authentication_db_auth/migration.sql b/api/db/migrations/20241003153016_add_authentication_db_auth/migration.sql
new file mode 100644
index 0000000..3d19685
--- /dev/null
+++ b/api/db/migrations/20241003153016_add_authentication_db_auth/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "UserExample" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "email" TEXT NOT NULL,
+ "name" TEXT
+);
+
+-- CreateTable
+CREATE TABLE "User" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "email" TEXT NOT NULL,
+ "hashedPassword" TEXT NOT NULL,
+ "salt" TEXT NOT NULL,
+ "resetToken" TEXT,
+ "resetTokenExpiresAt" DATETIME,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "UserExample_email_key" ON "UserExample"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
diff --git a/api/db/migrations/migration_lock.toml b/api/db/migrations/migration_lock.toml
new file mode 100644
index 0000000..e5e5c47
--- /dev/null
+++ b/api/db/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "sqlite"
\ No newline at end of file
diff --git a/api/db/schema.prisma b/api/db/schema.prisma
index 8c86666..29b3072 100644
--- a/api/db/schema.prisma
+++ b/api/db/schema.prisma
@@ -22,3 +22,16 @@ model UserExample {
email String @unique
name String?
}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ firstName String?
+ lastName String?
+ hashedPassword String?
+ salt String?
+ resetToken String?
+ resetTokenExpiresAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
diff --git a/api/package.json b/api/package.json
index 6eb8208..a3a7232 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.3.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..667c57a
--- /dev/null
+++ b/api/src/functions/auth.ts
@@ -0,0 +1,207 @@
+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,
+ 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..e212e34 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 !== 'number') {
+ 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 7e55fcd..8697eac 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
]
},
"devDependencies": {
+ "@redwoodjs/auth-dbauth-setup": "8.3.0",
"@redwoodjs/core": "8.3.0",
"@redwoodjs/project-config": "8.3.0"
},
@@ -23,5 +24,8 @@
"packageManager": "yarn@4.4.0",
"resolutions": {
"@storybook/react-dom-shim@npm:7.6.17": "https://verdaccio.tobbe.dev/@storybook/react-dom-shim/-/react-dom-shim-8.0.8.tgz"
+ },
+ "dependencies": {
+ "crypto-js": "^4.2.0"
}
}
diff --git a/web/package.json b/web/package.json
index d8209fd..2e64933 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
]
},
"dependencies": {
+ "@redwoodjs/auth-dbauth-web": "8.3.0",
"@redwoodjs/forms": "8.3.0",
"@redwoodjs/router": "8.3.0",
"@redwoodjs/web": "8.3.0",
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 340c30b..56f13b6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -5,7 +5,11 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
+import { AuthProvider, useAuth } from './auth'
+
import './index.css'
+import './scaffold.css'
+
interface AppProps {
children?: ReactNode
@@ -14,7 +18,9 @@ interface AppProps {
const App = ({ children }: AppProps) => (
- {children}
+
+ {children}
+
)
diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx
index e26b6aa..dedc6a9 100644
--- a/web/src/Routes.tsx
+++ b/web/src/Routes.tsx
@@ -9,8 +9,15 @@
import { Router, Route } from '@redwoodjs/router'
+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/pages/ForgotPasswordPage/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..4d3f34f
--- /dev/null
+++ b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useRef } from 'react'
+
+import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms'
+import { navigate, routes } from '@redwoodjs/router'
+import { Metadata } from '@redwoodjs/web'
+import { toast, Toaster } from '@redwoodjs/web/toast'
+
+import { useAuth } from 'src/auth'
+
+const ForgotPasswordPage = () => {
+ const { isAuthenticated, forgotPassword } = useAuth()
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ navigate(routes.home())
+ }
+ }, [isAuthenticated])
+
+ const usernameRef = useRef(null)
+ useEffect(() => {
+ usernameRef?.current?.focus()
+ }, [])
+
+ const onSubmit = async (data: { username: string }) => {
+ const response = await forgotPassword(data.username)
+
+ if (response.error) {
+ toast.error(response.error)
+ } else {
+ // The function `forgotPassword.handler` in api/src/functions/auth.js has
+ // been invoked, let the user know how to get the link to reset their
+ // password (sent in email, perhaps?)
+ toast.success(
+ 'A link to reset your password was sent to ' + response.email
+ )
+ navigate(routes.login())
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+