Skip to content

Commit

Permalink
Adding initial email verification test
Browse files Browse the repository at this point in the history
  • Loading branch information
Ian Walter committed Jan 14, 2020
1 parent ff58008 commit 0d9ee4c
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 56 deletions.
3 changes: 3 additions & 0 deletions examples/accounts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const app = nrg.createApp({
// Allow users to login.
app.post('/registration', ...nrg.registration)

// Allow users to verify their email address.
app.post('/verify-email', ...nrg.emailVerification)

// Allow users to login.
app.post('/login', ...nrg.login)

Expand Down
2 changes: 1 addition & 1 deletion examples/accounts/seeds/01_accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const accounts = [
lastName: 'Test',
email: 'unverified_user_test@example.com',
password: encryptedPassword,
emailVerified: true
emailVerified: false
},
{
firstName: 'Disabled User',
Expand Down
26 changes: 26 additions & 0 deletions examples/accounts/seeds/02_tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const bcrypt = require('bcrypt')
const addDays = require('date-fns/addDays')
const { accounts } = require('./01_accounts')

const token = 'iJustC4n7!gnore'
const salt = bcrypt.genSaltSync(12)
const encryptedToken = bcrypt.hashSync(token, salt)
const unverifiedUser = accounts.find(a => a.firstName === 'Unverified User')
const tokens = [
{
value: encryptedToken,
type: 'email',
accountId: 4,
email: unverifiedUser.email,
expiresAt: addDays(new Date(), 1).toISOString()
}
]

module.exports = {
token,
tokens,
seed: async knex => {
await knex.raw('TRUNCATE TABLE tokens RESTART IDENTITY CASCADE')
return knex('tokens').insert(tokens)
}
}
16 changes: 10 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ const { serveStatic, serveWebpack } = require('./lib/middleware/client')

const { serveSsr } = require('./lib/middleware/ssr')

const { generateToken, insertToken } = require('./lib/middleware/token')
const {
generateToken,
insertToken,
verifyToken
} = require('./lib/middleware/token')

const {
validatePasswordStrength,
Expand Down Expand Up @@ -73,8 +77,7 @@ const {

const {
validatePasswordReset,
getAccountWithPasswordTokens,
resetPassword
getAccountWithPasswordTokens
} = require('./lib/middleware/passwordReset')

module.exports = {
Expand Down Expand Up @@ -107,6 +110,7 @@ module.exports = {
// Token:
generateToken,
insertToken,
verifyToken,

// Password:
validatePasswordStrength,
Expand All @@ -125,9 +129,10 @@ module.exports = {
validateEmailVerification,
getAccountWithEmailTokens,
verifyEmail,
emailVerfication: [
emailVerification: [
validateEmailVerification,
getAccountWithEmailTokens,
verifyToken('emailTokens'),
verifyEmail,
updateAccount,
authenticate,
Expand Down Expand Up @@ -190,11 +195,10 @@ module.exports = {
// Password Reset:
validatePasswordReset,
getAccountWithPasswordTokens,
resetPassword,
passwordReset: [
validatePasswordReset,
getAccountWithPasswordTokens,
resetPassword,
verifyToken(),
hashPassword,
updateAccount,
authenticate,
Expand Down
8 changes: 5 additions & 3 deletions lib/middleware/emailVerification.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ const startEmailVerification = [
]

async function validateEmailVerification (ctx, next) {
const validation = await ctx.validators.account.validate(ctx.request.body)
const { body } = ctx.request
const validation = await ctx.validators.emailVerification.validate(body)
ctx.log.debug(validation, 'Registration validation')
if (validation.valid()) {
ctx.data = validation.data
Expand All @@ -52,15 +53,16 @@ async function validateEmailVerification (ctx, next) {
async function getAccountWithEmailTokens (ctx, next) {
const { Account } = ctx.options.accounts.models
const email = ctx.data.email.toLowerCase()
ctx.result = Account.query()
ctx.result = await Account.query()
.joinEager('emailTokens')
.modifyEager('emailTokens', b => b.orderBy('createdAt', 'desc'))
.findOne({ 'accounts.email': email, 'accounts.enabled': true })
return next()
}

async function verifyEmail (ctx, next) {
next()
await ctx.result.$set({ emailVerified: true }).$query().patch()
return next()
}

module.exports = {
Expand Down
45 changes: 2 additions & 43 deletions lib/middleware/passwordReset.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
const bcrypt = require('bcrypt')
const addDays = require('date-fns/addDays')
const { ValidationError, BadRequestError } = require('../errors')
const { ValidationError } = require('../errors')

async function validatePasswordReset (ctx, next) {
const { body } = ctx.request
Expand All @@ -23,46 +21,7 @@ async function getAccountWithPasswordTokens (ctx, next) {
return next()
}

async function resetPassword (ctx, next) {
let token = ctx.result &&
ctx.result.passwordTokens &&
ctx.result.passwordTokens[0]

// If a matching token wasn't found, create a dummy token that can be used to
// do a dummy compare to prevent against leaking information through timing.
const { Token } = ctx.options.accounts.models
const hasToken = !!(token && token.value)
const value = ctx.options.accounts.dummyPassword
const expiresAt = addDays(new Date(), 1).toISOString()
if (!hasToken) {
token = new Token()
token.$set({ value, expiresAt })
}

// Compare the supplied token value with the returned hashed token
// value.
const tokensMatch = await bcrypt.compare(ctx.data.token, token.value)

// Determine that the supplied token is valid if the token was found, the
// token values match, and the token is not expired.
if (hasToken && tokensMatch && token.isNotExpired()) {
// Delete the password token now that the user's password has been
// changed.
token.$query().delete().catch(err => ctx.log.error(err))

// Continue to the next middleware.
return next()
}

// Return a 400 Bad Request if the token is invalid. The user cannot be told
// if this is because the token is expired because that could leak that an
// account exists in the system.
ctx.log.debug({ hasToken, token, ...ctx.data, tokensMatch }, 'Invalid token')
throw new BadRequestError('Invalid token')
}

module.exports = {
validatePasswordReset,
getAccountWithPasswordTokens,
resetPassword
getAccountWithPasswordTokens
}
47 changes: 44 additions & 3 deletions lib/middleware/token.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
const uid = require('uid-safe')
const { hash } = require('bcrypt')
const bcrypt = require('bcrypt')
const addDays = require('date-fns/addDays')
const merge = require('@ianwalter/merge')
const { BadRequestError } = require('../errors')

async function handleGenerateToken (ctx, next, options) {
ctx.result = { token: uid.sync(options.bytes || ctx.options.hash.bytes) }
const rounds = options.rounds || ctx.options.hash.rounds
ctx.result.hashedToken = await hash(ctx.result.token, rounds)
ctx.result.hashedToken = await bcrypt.hash(ctx.result.token, rounds)
return next()
}

Expand Down Expand Up @@ -101,4 +102,44 @@ function insertToken (ctx, next) {
return handleInsertToken(ctx, next, options)
}

module.exports = { generateToken, insertToken }
function verifyToken (property = 'passwordTokens') {
return async (ctx, next) => {
let token = ctx.result && ctx.result[property] && ctx.result[property][0]

// If a matching token wasn't found, create a dummy token that can be used
// to do a dummy compare to prevent against leaking information through
// timing.
const { Token } = ctx.options.accounts.models
const hasToken = !!(token && token.value)
const value = ctx.options.accounts.dummyPassword
const expiresAt = addDays(new Date(), 1).toISOString()
if (!hasToken) {
token = new Token()
token.$set({ value, expiresAt })
}

// Compare the supplied token value with the returned hashed token
// value.
const tokensMatch = await bcrypt.compare(ctx.data.token, token.value)

// Determine that the supplied token is valid if the token was found, the
// token values match, and the token is not expired.
if (hasToken && tokensMatch && token.isNotExpired()) {
// Delete the password token now that the user's password has been
// changed.
token.$query().delete().catch(err => ctx.log.error(err))

// Continue to the next middleware.
return next()
}

// Return a 400 Bad Request if the token is invalid. The user cannot be told
// if this is because the token is expired because that could leak that an
// account exists in the system.
const data = { hasToken, token, ...ctx.data, tokensMatch }
ctx.log.debug(data, 'Invalid token')
throw new BadRequestError('Invalid token')
}
}

module.exports = { generateToken, insertToken, verifyToken }
19 changes: 19 additions & 0 deletions tests/emailVerification.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const { test } = require('@ianwalter/bff')
const app = require('../examples/accounts')
const { accounts } = require('../examples/accounts/seeds/01_accounts')
const { token } = require('../examples/accounts/seeds/02_tokens')

const unverifiedUser = accounts.find(a => a.firstName === 'Unverified User')

test('Email Verification success', async ({ expect }) => {
const payload = { email: unverifiedUser.email, token }
const response = await app.test('/verify-email').post(payload)
expect(response.status).toBe(201)
expect(response.body).toMatchSnapshot()
})

test.skip('Email Verification from email', async ({ expect }) => {
})

test.skip('Email Verification from resent email', async ({ expect }) => {
})
11 changes: 11 additions & 0 deletions tests/snapshots/emailVerification.tests.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Email Verification success 1`] = `
Object {
"email": "unverified_user_test@example.com",
"emailVerified": true,
"firstName": "Unverified User",
"id": 4,
"lastName": "Test",
}
`;

0 comments on commit 0d9ee4c

Please sign in to comment.