Singular (admin) account system
This commit is contained in:
18
api/db/migrations/20240810184713_user/migration.sql
Normal file
18
api/db/migrations/20240810184713_user/migration.sql
Normal 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");
|
3
api/db/migrations/migration_lock.toml
Normal file
3
api/db/migrations/migration_lock.toml
Normal 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"
|
@ -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?
|
||||
}
|
||||
|
@ -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
231
api/src/functions/auth.ts
Normal 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()
|
||||
}
|
@ -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,
|
||||
|
15
api/src/graphql/users.sdl.ts
Normal file
15
api/src/graphql/users.sdl.ts
Normal 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
|
||||
}
|
||||
`
|
@ -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
39
api/src/lib/email.ts
Normal 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}`
|
||||
}
|
36
api/src/services/users/users.ts
Normal file
36
api/src/services/users/users.ts
Normal 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 },
|
||||
// })
|
||||
// }
|
Reference in New Issue
Block a user