Singular (admin) account system

This commit is contained in:
Ahmed Al-Taiar
2024-08-14 12:35:57 -04:00
parent 544cea9105
commit b61a80c9a0
37 changed files with 1796 additions and 442 deletions

View File

@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL,
"salt" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiresAt" TIMESTAMP(3),
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -13,3 +13,13 @@ generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
}

View File

@ -3,7 +3,12 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@redwoodjs/api": "7.7.3",
"@redwoodjs/graphql-server": "7.7.3"
"@redwoodjs/api": "7.7.4",
"@redwoodjs/auth-dbauth-api": "7.7.4",
"@redwoodjs/graphql-server": "7.7.4",
"nodemailer": "^6.9.14"
},
"devDependencies": {
"@types/nodemailer": "^6.4.15"
}
}

231
api/src/functions/auth.ts Normal file
View File

@ -0,0 +1,231 @@
import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
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'
export const handler = async (
event: APIGatewayProxyEvent,
context: Context
) => {
const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {
handler: async (user, resetToken) => {
const domain =
(process.env.NODE_ENV || 'development') == 'production'
? process.env.DOMAIN_PROD
: process.env.DOMAIN_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', '<br />')
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) => {
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',
},
// 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 {
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',
},
// 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()
}

View File

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

View File

@ -0,0 +1,15 @@
export const schema = gql`
type User {
id: Int!
username: String!
email: String!
hashedPassword: String!
salt: String!
resetToken: String
resetTokenExpiresAt: DateTime
}
type Query {
userCount: Int! @skipAuth
}
`

View File

@ -1,32 +1,117 @@
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 no-unused-vars, @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') {
// roles to check is a string, currentUser.roles is a string
if (typeof currentUserRoles === '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.")
}
}

39
api/src/lib/email.ts Normal file
View File

@ -0,0 +1,39 @@
import * as nodemailer from 'nodemailer'
interface Options {
to: string | string[]
subject: string
html: string
text: string
}
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GMAIL,
pass: process.env.GMAIL_SMTP_PASSWORD,
},
})
export async function sendEmail({ to, subject, text, html }: Options) {
return await transporter.sendMail({
from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`,
to: Array.isArray(to) ? to : [to],
subject,
text,
html,
})
}
export function censorEmail(email: string): string {
const [localPart, domain] = email.split('@')
if (localPart.length <= 2) return `${localPart}@${domain}`
const firstChar = localPart[0]
const lastChar = localPart[localPart.length - 1]
const middleLength = Math.min(localPart.length - 2, 7)
const middle = ''.repeat(middleLength)
return `${firstChar}${middle}${lastChar}@${domain}`
}

View File

@ -0,0 +1,36 @@
import type { QueryResolvers } from 'types/graphql'
import { db } from 'src/lib/db'
export const userCount: QueryResolvers['userCount'] = () => {
return db.user.count()
}
// export const users: QueryResolvers['users'] = () => {
// return db.user.findMany()
// }
// export const user: QueryResolvers['user'] = ({ id }) => {
// return db.user.findUnique({
// where: { id },
// })
// }
// export const createUser: MutationResolvers['createUser'] = ({ input }) => {
// return db.user.create({
// data: input,
// })
// }
// export const updateUser: MutationResolvers['updateUser'] = ({ id, input }) => {
// return db.user.update({
// data: input,
// where: { id },
// })
// }
// export const deleteUser: MutationResolvers['deleteUser'] = ({ id }) => {
// return db.user.delete({
// where: { id },
// })
// }