Skip to content

Commit

Permalink
166816202-feat(auth): Signup email verification
Browse files Browse the repository at this point in the history
- setup nodemailer
- update user model
- update user controller
- add mailer utils
- add activation controller to verify email token
- add tests
- add sinon for mocking mail
[Delivers #166816202]
  • Loading branch information
vincentayorinde committed Jul 2, 2019
1 parent 6a55c06 commit e2af673
Show file tree
Hide file tree
Showing 13 changed files with 200 additions and 46 deletions.
26 changes: 22 additions & 4 deletions controllers/users/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { blackListThisToken, getToken } from '../../utils';

export default {
signUp: async (req, res) => {
const {
firstName, lastName, password, email, username
} = req.body;
const { firstName, lastName, password, email, username } = req.body;
try {
const user = await db.User.create({
firstName,
Expand Down Expand Up @@ -53,12 +51,32 @@ export default {
});
}
},

signOut: async (req, res) => {
const token = req.headers['x-access-token'];
await blackListThisToken(token);
return res.status(200).send({
message: 'Thank you'
});
},
activate: async (req, res) => {
try {
const user = await db.User.findOne({
where: { emailVerificationToken: req.params.token, activated: false }
});
if (user) {
await user.update({ activated: true, emailVerificationToken: null });
return res.status(200).json({
message: 'Activation successful, You can now login',
user: user.response()
});
}
return res.status(400).json({
error: 'Invalid activation Link'
});
} catch (e) {
return res.status(400).json({
message: 'Bad request'
});
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@


module.exports = {
up: (queryInterface, Sequelize) => queryInterface
.addColumn('Users', 'emailVerificationToken', {
type: Sequelize.STRING,
allowNull: true
})
.then(() => queryInterface.addColumn('Users', 'activated', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
})),
down: queryInterface => queryInterface
.removeColumn('Users', 'emailVerificationToken')
.then(() => queryInterface.removeColumn('Users', 'activated'))
};
21 changes: 18 additions & 3 deletions db/models/User.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import bcrypt from 'bcryptjs';
import { getToken } from '../../utils';
import { getToken, randomString } from '../../utils';
import { sendMail } from '../../utils/mailer';
import { activationMessage } from '../../utils/mailer/mails';

module.exports = (sequelize, DataTypes) => {
const User = sequelize.define(
Expand All @@ -11,12 +13,25 @@ module.exports = (sequelize, DataTypes) => {
username: DataTypes.STRING,
firstName: DataTypes.STRING,
lastName: DataTypes.STRING,
password: DataTypes.STRING
password: DataTypes.STRING,
emailVerificationToken: DataTypes.STRING,
activated: {
type: DataTypes.BOOLEAN,
default: false
}
},
{
hooks: {
beforeCreate: async (user) => {
beforeCreate: async user => {
user.password = await bcrypt.hash(user.password, 10);
user.emailVerificationToken = randomString();
},
afterCreate: async user => {
await sendMail({
email: user.email,
subject: 'Activate Account',
content: activationMessage(user.email, user.emailVerificationToken)
});
}
}
}
Expand Down
5 changes: 0 additions & 5 deletions env-sample

This file was deleted.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.19.0",
"consola": "^2.9.0",
"dotenv": "^8.0.0",
"express": "^4.17.1",
"indicative": "^5.0.8",
Expand All @@ -56,9 +57,9 @@
"pg-hstore": "^2.3.3",
"sequelize": "^5.8.12",
"swagger-ui-express": "^4.0.6",
"consola": "^2.9.0",
"parse-database-url": "^0.3.0",
"sinon": "^7.3.2",
"nodemailer": "^6.2.1",
"yamljs": "^0.3.0"
}
}
9 changes: 5 additions & 4 deletions routes/v1/users.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import express from 'express';
import Validation from '../../validators/users';
import Controller from '../../controllers/users';
import User from '../../controllers/users';
import Middleware from '../../middlewares';

const router = express.Router();

router.post('/signup', Validation.signUp, Controller.signUp);
router.post('/login', Validation.logIn, Controller.logIn);
router.post('/signout', Middleware.authenticate, Middleware.isblackListedToken, Controller.signOut);
router.post('/signup', Validation.signUp, User.signUp);
router.post('/login', Validation.logIn, User.logIn);
router.post('/signout', Middleware.authenticate, Middleware.isblackListedToken, User.signOut);
router.put(`/activate/:token`, User.activate);

export default router;
3 changes: 3 additions & 0 deletions server/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default () => ({
secret: process.env.JWT_SECRET || 'secret'
});
33 changes: 18 additions & 15 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,29 @@ const app = express();
dotenv.config();

const swaggerDocument = YAML.load(`${__dirname}/../swagger.yaml`);

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
Routes(app);

app.get('/', (req, res) => res.status(200).json({
message: "welcome to Author's Haven"
}));

app.use((req, res) => res.status(404).json({
status: 404,
error: `Route ${req.url} Not found`
}));
app.get('/', (req, res) =>
res.status(200).json({
message: "welcome to Author's Haven"
})
);
app.use((req, res) =>
res.status(404).json({
status: 404,
error: `Route ${req.url} Not found`
})
);

app.use((error, req, res) => res.status(500).json({
status: 500,
error
}));
app.use((error, req, res) =>
res.status(500).json({
status: 500,
error
})
);

const dbconnection = db.sequelize;
dbconnection
Expand All @@ -42,7 +45,7 @@ dbconnection
consola.success(`server start at port ${process.env.PORT}`);
});
})
.catch((e) => {
.catch(e => {
throw e.message;
});

Expand Down
4 changes: 2 additions & 2 deletions tests/middleware/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('Middlewares', () => {
await db.BlackListedTokens.sync({ truncate: true, cascade: false });
});
describe('Testing blacklisted middleware', () => {
it('should call next when blacklisted middleware checks fine', (done) => {
it('should call next when blacklisted middleware checks fine', done => {
const user = {
name: 'test user'
};
Expand All @@ -36,7 +36,7 @@ describe('Middlewares', () => {
done();
});

it('should return jwt expired when token is expired', async (done) => {
it('should return jwt expired when token is expired', async done => {
const user = {
name: 'test user'
};
Expand Down
58 changes: 58 additions & 0 deletions tests/routes/users.spec.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import chai from 'chai';
import chaiHttp from 'chai-http';
import sinon from 'sinon';
import { transporter } from '../../utils/mailer';
import { app, db } from '../../server';
import { createUser } from '../../utils';

const { expect } = chai;
let mockNodeMailer;

chai.use(chaiHttp);
let register = {};
Expand All @@ -27,11 +30,15 @@ describe('USER AUTHENTICATION', () => {
password: '12345678',
email: 'frank@gmail.com'
};
mockNodeMailer = sinon.stub(transporter, 'sendMail').resolves({});
});

after(async () => {
await db.User.destroy({ truncate: true, cascade: false });
});
afterEach(async () => {
mockNodeMailer.restore();
});

describe('Sign up', () => {
it('should sign up user if info is valid', async () => {
Expand Down Expand Up @@ -81,6 +88,16 @@ describe('USER AUTHENTICATION', () => {
describe('Log in', () => {
it('should not login user if info is invalid', async () => {
login.password = '1234567';
// const { firstName, lastName, password, email, bio, username, image } = register;
// await db.User.create({
// firstName,
// lastName,
// password,
// email,
// bio,
// username,
// image
// });
const res = await chai
.request(app)
.post('/api/v1/users/login')
Expand All @@ -92,6 +109,16 @@ describe('USER AUTHENTICATION', () => {
});

it('should login user if info is valid', async () => {
// const { firstName, lastName, password, email, bio, username, image } = register;
// await db.User.create({
// firstName,
// lastName,
// password,
// email,
// bio,
// username,
// image
// });
const res = await chai
.request(app)
.post('/api/v1/users/login')
Expand Down Expand Up @@ -130,4 +157,35 @@ describe('USER AUTHENTICATION', () => {
expect(res.status).to.equal(200);
});
});
describe('Signup Email Activation', () => {
it('should activate verification mail', async () => {
// const newUser = await db.User.create({ ...register, email: 'user1@authorshaven.com' });
const activationString = user.emailVerificationToken;
const res = await chai
.request(app)
.put(`/api/v1/users/activate/${activationString}`)
.send();
expect(res).to.have.status(200);
expect(res.body.user).to.be.an('object');
expect(res.body.message).to.be.a('string');
expect(res.body).to.include.all.keys('user', 'message');
expect(res.body.user.firstName).to.include(register.firstName);
expect(res.body.user.lastName).to.include(register.lastName);
expect(res.body.user.username).to.include(register.username);
expect(res.body.user.email).to.include(register.email);
expect(res.body.message).to.include('Activation successful, You can now login');
});
it('should not activate user if token is wrong', async () => {
register.emailActivationToken = 'wrongtoken';
const res = await chai
.request(app)
.put(`/api/v1/users/activate/${register.emailActivationToken}`)
.send();
expect(res).to.have.status(400);
expect(res.body).to.be.an('object');
expect(res.body).to.include.all.keys('error');
expect(res.body.error).to.be.a('string');
expect(res.body.error).to.include('Invalid activation Link');
});
});
});
31 changes: 19 additions & 12 deletions utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { validations } from 'indicative';
import { Vanilla } from 'indicative/builds/formatters';
import Validator from 'indicative/builds/validator';
import db from '../db/models';
import crypto from 'crypto';

const getToken = (id, email) => jwt.sign({ id, email }, process.env.SECRET, {
expiresIn: '5h'
});
const getToken = (id, email) =>
jwt.sign({ id, email }, process.env.SECRET, {
expiresIn: '5h'
});

const isBlackListed = async (token) => {
const isBlackListed = async token => {
const blockedToken = await db.BlackListedTokens.findOne({
where: { token }
});
Expand All @@ -17,7 +19,7 @@ const isBlackListed = async (token) => {

const decodeToken = token => jwt.verify(token, process.env.SECRET);

const blackListThisToken = async (token) => {
const blackListThisToken = async token => {
const decoded = decodeToken(token);
await db.BlackListedTokens.create({
token,
Expand Down Expand Up @@ -55,10 +57,8 @@ validations.unique = async (data, field, message, args, get) => {

const validatorInstance = Validator(validations, Vanilla);

const createUser = async (user) => {
const {
firstName, lastName, username, email, password
} = user;
const createUser = async user => {
const { firstName, lastName, username, email, password } = user;

const newUser = await db.User.create({
firstName,
Expand All @@ -71,8 +71,15 @@ const createUser = async (user) => {
return newUser;
};

const randomString = () => crypto.randomBytes(11).toString('hex');
export {
getToken, blackListThisToken, decodeToken,
isBlackListed, messages, validatorInstance,
sanitizeRules, createUser
getToken,
blackListThisToken,
decodeToken,
isBlackListed,
messages,
validatorInstance,
sanitizeRules,
createUser,
randomString
};
Loading

0 comments on commit e2af673

Please sign in to comment.