import type { APIGatewayProxyEvent, Context } from 'aws-lambda' import { isProduction } from '@redwoodjs/api/logger' import { DbAuthHandler, PasswordValidationError, } 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' import { censorEmail, sendEmail } from 'src/lib/email' function getCommonCookieDomain(domain: string, apiDomain: string): string { const splitDomain1 = domain.split('.').reverse() const splitDomain2 = apiDomain.split('.').reverse() const commonParts: string[] = [] for (let i = 0; i < Math.min(splitDomain1.length, splitDomain2.length); i++) { if (splitDomain1[i] === splitDomain2[i]) commonParts.push(splitDomain1[i]) else break } if (commonParts.length < 2) throw new Error('Domains do not share the same TLD') return commonParts.reverse().join('.') } export const handler = async ( event: APIGatewayProxyEvent, context: Context ) => { const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = { handler: async (user, resetToken) => { const domain = isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV const text = `If this wasn't you, please disregard this email. Hello, You are receiving this email because a password reset was requested. Enter the following URL to begin resetting your password: ${domain}/reset-password?resetToken=${resetToken} ` const html = text.replaceAll('\n', '
') try { await sendEmail({ to: user.email, subject: 'Password Reset Request', text, html, }) return { email: censorEmail(user.email), } } catch (error) { return { error: `Error: ${error.code} ${error.responseCode}`, } } }, // 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: 'Account not found', // if the user somehow gets around client validation usernameRequired: 'Email 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) => 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', }, // 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) => false, // 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 { email: 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: async ({ username, hashedPassword, salt, userAttributes }) => { // Only one admin account should exist if ((await db.user.count()) >= 1) return { error: 'Admin account already exists' } if ( new RegExp( /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/ ).test(userAttributes.email) ) return db.user.create({ data: { username: username, email: userAttributes.email, hashedPassword: hashedPassword, salt: salt, }, }) else return { error: 'Invalid email' } }, // 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) => { if (password.length < 8) throw new PasswordValidationError( 'Password must be at least 8 characters' ) else 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: 'username', hashedPassword: 'hashedPassword', salt: 'salt', resetToken: 'resetToken', resetTokenExpiresAt: 'resetTokenExpiresAt', }, cors: { origin: isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV, credentials: isProduction, methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'], }, // 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: isProduction ? 'None' : 'Strict', Secure: isProduction, Domain: isProduction ? getCommonCookieDomain(process.env.DOMAIN, process.env.API_DOMAIN) : 'localhost', }, name: cookieName, }, forgotPassword: forgotPasswordOptions, login: loginOptions, resetPassword: resetPasswordOptions, signup: signupOptions, }) return await authHandler.invoke() }