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 <> -
-

- Colin's Redwood Blog -

- -
-
{children}
+ const { isAuthenticated, currentUser, logOut } = useAuth() + + return ( + <> +
+
+

+ Colin's Redwood Blog +

+ {isAuthenticated ? ( +
+ Logged in as {currentUser.email}{' '} + +
+ ) : ( + Login + )} +
+ + +
+
{children}
+ + ) } export default BlogLayout diff --git a/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.js b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.js new file mode 100644 index 0000000..ffc25e6 --- /dev/null +++ b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.js @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react' +import { useAuth } from '@redwoodjs/auth' +import { navigate, routes } from '@redwoodjs/router' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' +import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms' + +const ForgotPasswordPage = () => { + const { isAuthenticated, forgotPassword } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + const usernameRef = useRef() + useEffect(() => { + usernameRef.current.focus() + }, []) + + const onSubmit = async (data) => { + 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 ( + <> + + +
+ +
+
+
+

+ Forgot Password +

+
+ +
+
+
+
+ + + + +
+ +
+ Submit +
+
+
+
+
+
+
+ + ) +} + +export default ForgotPasswordPage diff --git a/web/src/pages/LoginPage/LoginPage.js b/web/src/pages/LoginPage/LoginPage.js new file mode 100644 index 0000000..e139109 --- /dev/null +++ b/web/src/pages/LoginPage/LoginPage.js @@ -0,0 +1,129 @@ +import { Link, navigate, routes } from '@redwoodjs/router' +import { useRef } from 'react' +import { + Form, + Label, + TextField, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { useAuth } from '@redwoodjs/auth' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' +import { useEffect } from 'react' + +const LoginPage = () => { + const { isAuthenticated, logIn } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + const usernameRef = useRef() + useEffect(() => { + usernameRef.current.focus() + }, []) + + const onSubmit = async (data) => { + const response = await logIn({ ...data }) + + if (response.message) { + toast(response.message) + } else if (response.error) { + toast.error(response.error) + } else { + toast.success('Welcome back!') + } + } + + return ( + <> + + +
+ +
+
+
+

Login

+
+ +
+
+
+ + + + + + + + +
+ + Forgot Password? + +
+ + + +
+ Login +
+ +
+
+
+
+ Don't have an account?{' '} + + Sign up! + +
+
+
+ + ) +} + +export default LoginPage diff --git a/web/src/pages/ResetPasswordPage/ResetPasswordPage.js b/web/src/pages/ResetPasswordPage/ResetPasswordPage.js new file mode 100644 index 0000000..97a5a6d --- /dev/null +++ b/web/src/pages/ResetPasswordPage/ResetPasswordPage.js @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react' +import { useAuth } from '@redwoodjs/auth' +import { navigate, routes } from '@redwoodjs/router' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' +import { + Form, + Label, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' + +const ResetPasswordPage = ({ resetToken }) => { + const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } = + useAuth() + const [enabled, setEnabled] = useState(true) + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + useEffect(() => { + const validateToken = async () => { + const response = await validateResetToken(resetToken) + if (response.error) { + setEnabled(false) + toast.error(response.error) + } else { + setEnabled(true) + } + } + validateToken() + }, []) + + const passwordRef = useRef() + useEffect(() => { + passwordRef.current.focus() + }, []) + + const onSubmit = async (data) => { + const response = await resetPassword({ + resetToken, + password: data.password, + }) + + if (response.error) { + toast.error(response.error) + } else { + toast.success('Password changed!') + await reauthenticate() + navigate(routes.login()) + } + } + + return ( + <> + + +
+ +
+
+
+

+ Reset Password +

+
+ +
+
+
+
+ + + + +
+ +
+ + Submit + +
+
+
+
+
+
+
+ + ) +} + +export default ResetPasswordPage diff --git a/web/src/pages/SignupPage/SignupPage.js b/web/src/pages/SignupPage/SignupPage.js new file mode 100644 index 0000000..1ccd5af --- /dev/null +++ b/web/src/pages/SignupPage/SignupPage.js @@ -0,0 +1,124 @@ +import { Link, navigate, routes } from '@redwoodjs/router' +import { useRef } from 'react' +import { + Form, + Label, + TextField, + PasswordField, + FieldError, + Submit, +} from '@redwoodjs/forms' +import { useAuth } from '@redwoodjs/auth' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' +import { useEffect } from 'react' + +const SignupPage = () => { + const { isAuthenticated, signUp } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + // focus on email box on page load + const usernameRef = useRef() + useEffect(() => { + usernameRef.current.focus() + }, []) + + const onSubmit = async (data) => { + const response = await signUp({ ...data }) + + if (response.message) { + toast(response.message) + } else if (response.error) { + toast.error(response.error) + } else { + // user is signed in automatically + toast.success('Welcome!') + } + } + + return ( + <> + + +
+ +
+
+
+

Signup

+
+ +
+
+
+ + + + + + + + + + +
+ + Sign Up + +
+ +
+
+
+
+ Already have an account?{' '} + + Log in! + +
+
+
+ + ) +} + +export default SignupPage diff --git a/yarn.lock b/yarn.lock index 7ee8837..0dc7d71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23011,6 +23011,7 @@ __metadata: version: 0.0.0-use.local resolution: "web@workspace:web" dependencies: + "@redwoodjs/auth": 1.0.0 "@redwoodjs/forms": 1.0.0 "@redwoodjs/router": 1.0.0 "@redwoodjs/web": 1.0.0