From 9ca3046cc486d92ab23b475d6ca4b129fc4643e6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 8 Feb 2022 14:48:37 -0800 Subject: [PATCH] Adds dbAuth --- api/src/functions/auth.js | 149 ++++++++++++++++++ api/src/lib/auth.js | 51 +++--- web/src/App.js | 8 +- web/src/Routes.js | 10 +- .../ForgotPasswordPage/ForgotPasswordPage.js | 89 +++++++++++ web/src/pages/LoginPage/LoginPage.js | 129 +++++++++++++++ .../ResetPasswordPage/ResetPasswordPage.js | 119 ++++++++++++++ web/src/pages/SignupPage/SignupPage.js | 124 +++++++++++++++ 8 files changed, 639 insertions(+), 40 deletions(-) create mode 100644 api/src/functions/auth.js create mode 100644 web/src/pages/ForgotPasswordPage/ForgotPasswordPage.js create mode 100644 web/src/pages/LoginPage/LoginPage.js create mode 100644 web/src/pages/ResetPasswordPage/ResetPasswordPage.js create mode 100644 web/src/pages/SignupPage/SignupPage.js diff --git a/api/src/functions/auth.js b/api/src/functions/auth.js new file mode 100644 index 0000000..684dc6e --- /dev/null +++ b/api/src/functions/auth.js @@ -0,0 +1,149 @@ +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', + }, + + forgotPassword: forgotPasswordOptions, + login: loginOptions, + resetPassword: resetPasswordOptions, + signup: signupOptions, + }) + + return await authHandler.invoke() +} diff --git a/api/src/lib/auth.js b/api/src/lib/auth.js index 2b35523..2b20754 100644 --- a/api/src/lib/auth.js +++ b/api/src/lib/auth.js @@ -1,36 +1,28 @@ -import { parseJWT } from '@redwoodjs/api' import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' -import { logger } from 'src/lib/logger' +import { db } from './db' /** - * getCurrentUser returns the user information together with - * an optional collection of roles used by requireAuth() to check - * if the user is authenticated or has role-based access + * 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: * - * @param decoded - The decoded access token containing user info and JWT claims like `sub`. Note could be null. - * @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type - * @param { APIGatewayEvent event, Context context } - An object which contains information from the invoker - * such as headers and cookies, and the context information about the invocation such as IP Address + * return await db.profile.findUnique({ where: { email: session.id } }) + * ───┬─── ──┬── + * model accessor ─┘ unique id field name ─┘ * - * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + * !! 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 ( - decoded, - { _token, _type }, - { _event, _context } -) => { - if (!decoded) { - logger.warn('Missing decoded user') - return null - } - - const { roles } = parseJWT({ decoded }) - - if (roles) { - return { ...decoded, roles } - } - - return { ...decoded } +export const getCurrentUser = async (session) => { + return await db.user.findUnique({ + where: { id: session.id }, + select: { id: true }, + }) } /** @@ -60,13 +52,14 @@ export const hasRole = ({ roles }) => { return false } + // If your User model includes roles, uncomment the role checks on currentUser if (roles) { if (Array.isArray(roles)) { - return context.currentUser.roles?.some((r) => roles.includes(r)) + // return context.currentUser.roles?.some((r) => roles.includes(r)) } if (typeof roles === 'string') { - return context.currentUser.roles?.includes(roles) + // return context.currentUser.roles?.includes(roles) } // roles not found diff --git a/web/src/App.js b/web/src/App.js index a7f176e..daa6719 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,21 +1,19 @@ import { AuthProvider } from '@redwoodjs/auth' -import netlifyIdentity from 'netlify-identity-widget' + +import { AuthProvider } from '@redwoodjs/auth' import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' import FatalErrorPage from 'src/pages/FatalErrorPage' -import { isBrowser } from '@redwoodjs/prerender/browserUtils' import Routes from 'src/Routes' import './scaffold.css' import './index.css' -isBrowser && netlifyIdentity.init() - const App = () => ( - + diff --git a/web/src/Routes.js b/web/src/Routes.js index 2891de5..5230c29 100644 --- a/web/src/Routes.js +++ b/web/src/Routes.js @@ -14,12 +14,10 @@ import BlogLayout from 'src/layouts/BlogLayout' const Routes = () => { return ( - - - - - - + + + + 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