Skip to content

Commit

Permalink
Feature/49 sign up verification (#100)
Browse files Browse the repository at this point in the history
* ➕ Add crypto library to auth functions for sign up verify token generation

Co-authored-by: nataliadiaz2 <natdiaz2001@gmail.com>
Co-authored-by: Sunjay Armstead <sunjay@codingzeal.com>
Co-authored-by: Jesse House <mail@jessehouse.com>
Co-authored-by: Jesse House <jesse.house@codingzeal.com>
Co-authored-by: Ryan <aarchaeopteryxx@gmail.com>
  • Loading branch information
6 people authored Oct 25, 2022
1 parent 9b57c4c commit 0db35d6
Show file tree
Hide file tree
Showing 35 changed files with 524 additions and 55 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
APP_NAME='Redwood Template App'
SESSION_SECRET='its dangerous to go alone. take this 🗡'

DATABASE_URL=postgresql://postgres:development@localhost:5432/redwood_dev
Expand All @@ -9,4 +10,9 @@ ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=password
USERS_PASSWORD=password

SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_KEY=

# LOG_LEVEL=debug
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "verifyToken" TEXT;
1 change: 1 addition & 0 deletions api/db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ model User {
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
memberships Membership[]
verifyToken String?
}

model Membership {
Expand Down
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"dependencies": {
"@redwoodjs/api": "^3.2.0",
"@redwoodjs/api-server": "^3.2.0",
"@redwoodjs/graphql-server": "^3.2.0"
"@redwoodjs/graphql-server": "^3.2.0",
"nodemailer": "^6.7.8"
},
"devDependencies": {
"@types/chance": "^1.1.3",
Expand Down
29 changes: 29 additions & 0 deletions api/src/emails/user-verification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { User } from '@prisma/client'

import { logger } from 'src/lib/logger'

const email = {
subject: () => 'Verify Email',
htmlBody: (user: User) => {
const link = `${process.env.DOMAIN}/verification?verifyToken=${user.verifyToken}`
const appName = process.env.APP_NAME

if (process.env.NODE_ENV === 'development') {
logger.debug(link)
}

return `
<div> Hi ${userNameWithFallback(user)}, </div>
<p>Please find below a link to verify your email for the ${appName}:</p>
<a href="${link}">${link}</a>
<p>If you did not request an account, please ignore this email.</p>
`
},
}

// TODO: extract to utils that can be shared with api and web
const userNameWithFallback = (user: User) => {
return user.name || user.nickname || user.email
}

export { email }
20 changes: 17 additions & 3 deletions api/src/functions/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { randomUUID } from 'node:crypto'

import { DbAuthHandler } from '@redwoodjs/api'

import { email as verificationEmail } from 'src/emails/user-verification'
import { db } from 'src/lib/db'
import { sendEmail } from 'src/lib/mailer'

export const handler = async (event, context) => {
const forgotPasswordOptions = {
Expand Down Expand Up @@ -52,6 +56,9 @@ export const handler = async (event, context) => {
if (!user.active) {
throw new Error('User not Active')
}
if (user.verifyToken) {
throw new Error('User not Verified')
}
return user
},

Expand Down Expand Up @@ -112,15 +119,22 @@ export const handler = async (event, context) => {
// If this returns anything else, it will be returned by the
// `signUp()` function in the form of: `{ message: 'String here' }`.
// eslint-disable-next-line
handler: ({ username, hashedPassword, salt, userAttributes }) => {
return db.user.create({
handler: async ({ username, hashedPassword, salt, userAttributes }) => {
const user = await db.user.create({
data: {
email: username,
hashedPassword: hashedPassword,
salt: salt,
// name: userAttributes.name
verifyToken: randomUUID(),
},
})

sendEmail({
to: user.email,
subject: verificationEmail.subject(),
html: verificationEmail.htmlBody(user),
})
return user
},

errors: {
Expand Down
2 changes: 2 additions & 0 deletions api/src/graphql/users.sdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,7 @@ export const schema = gql`
updateUser(id: String!, input: UpdateUserInput!): User!
@requireAuth(roles: ["super admin"])
removeUser(id: String!): User! @requireAuth(roles: ["super admin"])
verifyUser(token: String!): Boolean! @skipAuth
verifyReset(email: String!): String! @skipAuth
}
`
41 changes: 41 additions & 0 deletions api/src/lib/mailer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as nodemailer from 'nodemailer'

import { logger } from 'src/lib/logger'

interface Options {
to: string | string[]
subject: string
text?: string
html?: string
}

export async function sendEmail({ to, subject, text, html }: Options) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_KEY,
},
})

return transporter.sendMail(
{
from: 'support@redwoodtemplate.com',
to: Array.isArray(to) ? to : [to],
subject,
text,
html,
},
(error) => {
if (error) {
logger.error(
`Failed to send '${subject}' email, check SMTP configuration`
)
logger.debug('This error can be ignored in development')
logger.error(error)
}
}
)
}
1 change: 1 addition & 0 deletions api/src/services/users/users.scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const standard = defineScenario<Prisma.UserCreateArgs>({
one: {
data: {
email: 'String4589593',
verifyToken: 'HarryPotter',
...DEFAULT_FIELDS,
},
},
Expand Down
94 changes: 91 additions & 3 deletions api/src/services/users/users.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { db } from 'src/lib/db'

import { users, user, createUser, updateUser, removeUser } from './users'
import {
users,
user,
createUser,
updateUser,
removeUser,
verifyReset,
verifyUser,
} from './users'
import type { AssociationsScenario, StandardScenario } from './users.scenarios'

const mockSendEmail = jest.fn()

jest.mock('src/lib/mailer', () => {
return {
sendEmail: () => mockSendEmail(),
}
})

describe('users', () => {
scenario('returns all users', async (scenario: StandardScenario) => {
const result = await users()
Expand Down Expand Up @@ -30,8 +46,6 @@ describe('users', () => {
expect(result.active).toEqual(true)
expect(result.admin).toEqual(false)
expect(result.email).toEqual('String4652567')
expect(result.hashedPassword).toBeTruthy()
expect(result.salt).toBeTruthy()
expect(result.createdAt.getTime()).toBeGreaterThanOrEqual(
before.getTime()
)
Expand Down Expand Up @@ -187,4 +201,78 @@ describe('users', () => {
expect(result.active).toEqual(false)
expect(result.admin).toEqual(false)
})

describe('verify user', () => {
scenario('with verify token', async (scenario: StandardScenario) => {
const result = await verifyUser({ token: scenario.user.one.verifyToken })
const user = await db.user.findUnique({
where: { id: scenario.user.one.id },
})

expect(result).toEqual(true)
expect(user.verifyToken).toEqual(null)
})

scenario('without verify token', async (scenario: StandardScenario) => {
const result = await verifyUser({ token: scenario.user.two.verifyToken })
const user = await db.user.findUnique({
where: { id: scenario.user.two.id },
})

expect(result).toEqual(true)
expect(user.verifyToken).toEqual(null)
})

scenario(
'with invalid verify token',
async (scenario: StandardScenario) => {
const result = await verifyUser({ token: 'invalid' })
const user = await db.user.findUnique({
where: { id: scenario.user.one.id },
})

expect(result).toEqual(false)
expect(user.verifyToken).toEqual(scenario.user.one.verifyToken)
}
)
})

describe('verify reset', () => {
beforeEach(() => {
jest.clearAllMocks()
})

scenario(
'with existing email with verify token',
async (scenario: StandardScenario) => {
const email = scenario.user.one.email

expect(mockSendEmail.mock.calls.length).toBe(0)
const result = await verifyReset({ email })
expect(result).toEqual(email)
expect(mockSendEmail.mock.calls.length).toBe(1)
}
)

scenario(
'with existing email with no verify token',
async (scenario: StandardScenario) => {
const email = scenario.user.two.email

expect(mockSendEmail.mock.calls.length).toBe(0)
const result = await verifyReset({ email })
expect(result).toEqual(email)
expect(mockSendEmail.mock.calls.length).toBe(0)
}
)

scenario('with email that does not exist', async () => {
const email = 'does@not.exist'

expect(mockSendEmail.mock.calls.length).toBe(0)
const result = await verifyReset({ email })
expect(result).toEqual(email)
expect(mockSendEmail.mock.calls.length).toBe(0)
})
})
})
36 changes: 36 additions & 0 deletions api/src/services/users/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type {
UserResolvers,
} from 'types/graphql'

import { email as verificationEmail } from 'src/emails/user-verification'
import { db } from 'src/lib/db'
import { sendEmail } from 'src/lib/mailer'

const parseRoles = (roleIds) =>
roleIds?.reduce((acc, id) => {
Expand Down Expand Up @@ -79,6 +81,40 @@ export const removeUser: MutationResolvers['removeUser'] = async ({ id }) => {
return user
}

export const verifyReset: MutationResolvers['verifyReset'] = async ({
email,
}) => {
const user = await db.user.findUnique({
where: { email },
})
if (user?.verifyToken) {
sendEmail({
to: user.email,
subject: verificationEmail.subject(),
html: verificationEmail.htmlBody(user),
})
}
return email
}

export const verifyUser: MutationResolvers['verifyUser'] = async ({
token,
}) => {
if (token === null) return true
const user = await db.user.findFirst({
where: { verifyToken: token },
})
if (user) {
await db.user.update({
where: { id: user.id },
data: { verifyToken: null },
})
return true
} else {
return false
}
}

export const User: UserResolvers = {
memberships: (_obj, { root }) =>
db.membership.findMany({ where: { userId: root.id } }),
Expand Down
2 changes: 1 addition & 1 deletion redwood.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# https://redwoodjs.com/docs/app-configuration-redwood-toml

[web]
title = "Redwood Template App"
title = "${APP_NAME}"
port = "${PORT:8910}"
apiUrl = "/.redwood/functions" # you can customise graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths
includeEnvironmentVariables = [] # any ENV vars that should be available to the web side, see https://redwoodjs.com/docs/environment-variables#web
Expand Down
2 changes: 2 additions & 0 deletions web/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// More info at https://redwoodjs.com/docs/project-configuration-dev-test-build
const path = require('path')

const config = {
rootDir: '../',
preset: '@redwoodjs/testing/config/jest/web',
setupFilesAfterEnv: [path.resolve(__dirname, './jest.setup.js')],
}

module.exports = config
14 changes: 14 additions & 0 deletions web/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* eslint-env jest */
const fg = require('fast-glob')

const mocks = fg.sync('**/*.mock.{js,ts,jsx,tsx}', {
cwd: global.__RWJS_TESTROOT_DIR,
ignore: ['node_modules', '**/*Cell/*'],
absolute: true,
})

beforeAll(() => {
for (const m of mocks) {
require(m)
}
})
8 changes: 6 additions & 2 deletions web/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ const Routes = () => {
<Route path="/signup" page={SignupPage} name="signup" />
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
<Route path="/verification" page={VerificationPage} name="verification" />
<Route path="/verification-reset" page={VerificationResetPage} name="verificationReset" />
<Set wrap={MainLayout}>
<Route path="/" page={HomePage} name="home" />
<Route notfound page={NotFoundPage} />
<Route path="/forbidden" page={ForbiddenPage} name="forbidden" />
</Set>
<Set wrap={MainLayout}>
<Private unauthenticated="forbidden">
<Set wrap={ProfileLayout}>
<Route path="/profile" page={ProfileEditProfilePage} name="profile" />
Expand Down Expand Up @@ -44,8 +50,6 @@ const Routes = () => {
</Set>
</Set>
</Private>
<Route notfound page={NotFoundPage} />
<Route path="/forbidden" page={ForbiddenPage} name="forbidden" />
</Set>
</Router>
)
Expand Down
Loading

0 comments on commit 0db35d6

Please sign in to comment.