diff --git a/api/db/migrations/20220422004049_added_user_for_auth/migration.sql b/api/db/migrations/20220422004049_added_user_for_auth/migration.sql
new file mode 100644
index 0000000..a29dfad
--- /dev/null
+++ b/api/db/migrations/20220422004049_added_user_for_auth/migration.sql
@@ -0,0 +1,13 @@
+-- CreateTable
+CREATE TABLE "User" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "name" TEXT,
+ "email" TEXT NOT NULL,
+ "hashedPassword" TEXT NOT NULL,
+ "salt" TEXT NOT NULL,
+ "resetToken" TEXT,
+ "resetTokenExpiresAt" DATETIME
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
diff --git a/api/db/schema.prisma b/api/db/schema.prisma
index 7c6c24a..b449e2c 100644
--- a/api/db/schema.prisma
+++ b/api/db/schema.prisma
@@ -22,3 +22,13 @@ model Contact {
message String
createdAt DateTime @default(now())
}
+
+model User {
+ id Int @id @default(autoincrement())
+ name String?
+ email String @unique
+ hashedPassword String
+ salt String
+ resetToken String?
+ resetTokenExpiresAt DateTime?
+}
diff --git a/api/src/functions/auth.js b/api/src/functions/auth.js
new file mode 100644
index 0000000..6f4c3fc
--- /dev/null
+++ b/api/src/functions/auth.js
@@ -0,0 +1,162 @@
+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',
+
+ // 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',
+ },
+
+ // 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,
+ })
+
+ return await authHandler.invoke()
+}
diff --git a/api/src/functions/graphql.js b/api/src/functions/graphql.js
index f395c3b..44dbe71 100644
--- a/api/src/functions/graphql.js
+++ b/api/src/functions/graphql.js
@@ -4,10 +4,14 @@ import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'
+import { getCurrentUser } from 'src/lib/auth'
+
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
export const handler = createGraphQLHandler({
+ getCurrentUser,
+
loggerConfig: { logger, options: {} },
directives,
sdls,
diff --git a/api/src/graphql/posts.sdl.js b/api/src/graphql/posts.sdl.js
index 52a44a7..f2bf101 100644
--- a/api/src/graphql/posts.sdl.js
+++ b/api/src/graphql/posts.sdl.js
@@ -7,8 +7,8 @@ export const schema = gql`
}
type Query {
- posts: [Post!]! @requireAuth
- post(id: Int!): Post @requireAuth
+ posts: [Post!]! @skipAuth
+ post(id: Int!): Post @skipAuth
}
input CreatePostInput {
diff --git a/api/src/lib/auth.js b/api/src/lib/auth.js
index 276427c..5693a86 100644
--- a/api/src/lib/auth.js
+++ b/api/src/lib/auth.js
@@ -1,25 +1,107 @@
+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 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:
*
- * See https://redwoodjs.com/docs/authentication for more info.
+ * 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, email: true },
+ })
+}
+
+/**
+ * The user is authenticated if there is a currentUser in the context
+ *
+ * @returns {boolean} - If the currentUser is authenticated
*/
export const isAuthenticated = () => {
- return true
+ return !!context.currentUser
}
-export const hasRole = ({ roles }) => {
- return roles !== undefined
+/**
+ * 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`
+ */
+
+/**
+ * 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) => {
+ 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
}
-// This is used by the redwood directive
-// in ./api/src/directives/requireAuth
-
-// Roles are passed in by the requireAuth directive if you have auth setup
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
+/**
+ * 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 }) => {
- return isAuthenticated()
+ 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/web/package.json b/web/package.json
index 1d94174..97325ee 100644
--- a/web/package.json
+++ b/web/package.json
@@ -13,6 +13,7 @@
]
},
"dependencies": {
+ "@redwoodjs/auth": "1.0.0",
"@redwoodjs/forms": "1.0.0",
"@redwoodjs/router": "1.0.0",
"@redwoodjs/web": "1.0.0",
diff --git a/web/src/App.js b/web/src/App.js
index 5e7beac..427a4c3 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -1,3 +1,5 @@
+import { AuthProvider } from '@redwoodjs/auth'
+
import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web'
import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
@@ -10,9 +12,11 @@ import './index.css'
const App = () => (
-
-
-
+
+
+
+
+
)
diff --git a/web/src/Routes.js b/web/src/Routes.js
index 87dd3b7..aed82f0 100644
--- a/web/src/Routes.js
+++ b/web/src/Routes.js
@@ -7,26 +7,32 @@
// 'src/pages/HomePage/HomePage.js' -> HomePage
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
-import { Router, Route, Set } from '@redwoodjs/router'
+import { Private, Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from './layouts/BlogLayout/BlogLayout'
const Routes = () => {
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
)
}
diff --git a/web/src/layouts/BlogLayout/BlogLayout.js b/web/src/layouts/BlogLayout/BlogLayout.js
index 4532bd5..92b37fd 100644
--- a/web/src/layouts/BlogLayout/BlogLayout.js
+++ b/web/src/layouts/BlogLayout/BlogLayout.js
@@ -1,26 +1,45 @@
-import { Link, routes } from '@redwoodjs/router';
+import { useAuth } from '@redwoodjs/auth'
+import { Link, routes } from '@redwoodjs/router'
const BlogLayout = ({ children }) => {
- return <>
-
-