From e26567188fcd249cf06b56553791fc9510c7a955 Mon Sep 17 00:00:00 2001 From: Kizito Akhilome Date: Mon, 1 Oct 2018 10:20:29 +0100 Subject: [PATCH] feat(auth-login): implement user login functionality - create POST /auth/login endpoint - install and use jsonwebtoken - write tests for POST /auth/login - add JWT_SECRET to environmental variables [Finishes #160819929] --- .env.example | 1 + package-lock.json | 90 ++++++++++++++++++++++++++++ package.json | 1 + server/controllers/authController.js | 34 +++++++++++ server/middleware/authHandler.js | 30 ++++++++++ server/middleware/sanitizer.js | 22 +++++++ server/routes/authRouter.js | 3 + tests/routes/auth.spec.js | 58 +++++++++++++++++- tests/seed/seed.js | 25 +++++++- 9 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 server/middleware/authHandler.js diff --git a/.env.example b/.env.example index ce6706f..b1710a7 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ PORT=[server_port_number] DATABASE_URL=postgres://[username]@[server]:[port]/[database_name] TEST_DATABASE_URL=postgres://[username]@[server]:[port]/[database_name] +JWT_SECRET=[your_supersecret_secret] diff --git a/package-lock.json b/package-lock.json index 1a2859a..0564aff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1470,6 +1470,11 @@ "electron-to-chromium": "^1.3.47" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-writer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz", @@ -2035,6 +2040,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4103,6 +4116,29 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, + "jsonwebtoken": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", + "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", + "requires": { + "jws": "^3.1.5", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -4115,6 +4151,25 @@ "verror": "1.10.0" } }, + "jwa": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "requires": { + "jwa": "^1.1.5", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", @@ -4194,6 +4249,41 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "log-driver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", diff --git a/package.json b/package.json index 2f2dd94..97bc9d5 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "body-parser": "^1.18.3", "dotenv": "^6.0.0", "express": "^4.16.3", + "jsonwebtoken": "^8.3.0", "pg": "^7.4.3" }, "devDependencies": { diff --git a/server/controllers/authController.js b/server/controllers/authController.js index d63ae4c..3455b7e 100644 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -28,6 +28,40 @@ class AuthController { return res.status(400).json({ error }); } } + + static async signin(req, res, next) { + const { email, password } = req; + + try { + // Check if a user with the provided email exists + const userExists = (await pool.query('SELECT * FROM users WHERE email=$1', [email])).rowCount; + if (!userExists) { + return res.status(400).json({ + status: 'error', + message: 'no user with that email exists', + }); + } + + const userDetails = (await pool.query('SELECT * FROM users WHERE email=$1', [email])).rows[0]; + const correctPassword = await bcrpyt.compare(password, userDetails.password); + + if (!correctPassword) { + return res.status(400).json({ + status: 'error', + message: 'incorrect password', + }); + } + + // Append important payload to request object + req.userId = userDetails.id; + req.userName = userDetails.name; + req.userEmail = userDetails.email; + req.userStatus = userDetails.is_admin ? 'admin' : 'customer'; + return next(); + } catch (error) { + return res.status(400).json({ error }); + } + } } export default AuthController; diff --git a/server/middleware/authHandler.js b/server/middleware/authHandler.js new file mode 100644 index 0000000..8c938da --- /dev/null +++ b/server/middleware/authHandler.js @@ -0,0 +1,30 @@ +import jwt from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +class AuthHandler { + static async generateAuthToken(req, res) { + const { + userId, + userName, + userEmail, + userStatus, + } = req; + + const token = jwt.sign({ + userId, + userName, + userEmail, + userStatus, + }, process.env.JWT_SECRET); + + res.status(200).json({ + status: 'success', + message: 'user logged in successfully', + auth_token: token, + }); + } +} + +export default AuthHandler; diff --git a/server/middleware/sanitizer.js b/server/middleware/sanitizer.js index 894beb9..38a581a 100644 --- a/server/middleware/sanitizer.js +++ b/server/middleware/sanitizer.js @@ -38,6 +38,28 @@ class Sanitize { req.password = password.trim(); return next(); } + + static signin(req, res, next) { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ + status: 'error', + message: 'some required fields missing', + }); + } + + if (!Validator.isEmail(email) || !Validator.isValidPassword(password)) { + return res.status(400).json({ + status: 'error', + message: 'email or password not correctly formatted', + }); + } + + req.email = email.trim(); + req.password = password.trim(); + return next(); + } } export default Sanitize; diff --git a/server/routes/authRouter.js b/server/routes/authRouter.js index df724a7..a0d4308 100644 --- a/server/routes/authRouter.js +++ b/server/routes/authRouter.js @@ -1,8 +1,11 @@ import { Router } from 'express'; import AuthController from '../controllers/authController'; import Sanitize from '../middleware/sanitizer'; +import AuthHandler from '../middleware/authHandler'; const router = new Router(); + router.post('/signup', Sanitize.signup, AuthController.signup); +router.post('/login', Sanitize.signin, AuthController.signin, AuthHandler.generateAuthToken); export default router; diff --git a/tests/routes/auth.spec.js b/tests/routes/auth.spec.js index c257773..d19ab41 100644 --- a/tests/routes/auth.spec.js +++ b/tests/routes/auth.spec.js @@ -3,7 +3,7 @@ import 'chai/register-should'; import chaiHttp from 'chai-http'; import dirtyChai from 'dirty-chai'; import app from '../../server/index'; -import { seedData, populateTables } from '../seed/seed'; +import { seedData, populateTables, populateUsersTable } from '../seed/seed'; chai.use(chaiHttp); chai.use(dirtyChai); @@ -98,3 +98,59 @@ describe('POST /auth/signup', () => { }); }); }); + +describe('POST /auth/login', () => { + beforeEach(populateTables); + beforeEach(populateUsersTable); + + it('should sign an existing user in', (done) => { + chai.request(app) + .post('/api/v1/auth/login') + .send(seedData.users.validUser) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(200); + res.body.should.be.an('object').which.has.keys(['status', 'message', 'auth_token']); + done(); + }); + }); + + it('should respond with a 400 if required fields are missing', (done) => { + chai.request(app) + .post('/api/v1/auth/login') + .send(seedData.users.invalidUserNoData) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(400); + done(); + }); + }); + + it('should not sign user in if invalid email or password is provided', (done) => { + chai.request(app) + .post('/api/v1/auth/login') + .send(seedData.users.invalidUser) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(400); + res.body.should.not.have.keys(['auth_token']); + done(); + }); + }); + + it('should not sign in user with improper input format', (done) => { + chai.request(app) + .post('/api/v1/auth/login') + .send({ email: 'invalid@email', password: 'well?' }) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(400); + res.body.should.not.have.keys(['auth_token']); + done(); + }); + }); +}); diff --git a/tests/seed/seed.js b/tests/seed/seed.js index f7da46a..d6e18e6 100644 --- a/tests/seed/seed.js +++ b/tests/seed/seed.js @@ -1,3 +1,4 @@ +import bcrypt from 'bcryptjs'; import pool from '../../server/db/config'; const seedData = { @@ -14,6 +15,11 @@ const seedData = { password: 'pixel2user', confirmPassword: 'pixel2user', }, + invalidUser: { + name: 'four-O-four', + email: 'no@email.address', + password: 'invalid', + }, invalidUserNoData: {}, invalidUserNoName: { email: 'unserious@lad.com', @@ -53,4 +59,21 @@ const populateTables = async () => { await pool.query(createUsersTableQuery); }; -export { seedData, populateTables }; +const populateUsersTable = async () => { + // hash passwords + const adminHashedPassword = await bcrypt.hash(seedData.users.admin.password, 10); + const userHashedPassword = await bcrypt.hash(seedData.users.validUser.password, 10); + const insertQuery = 'INSERT INTO users(name, email, password, is_admin) VALUES($1, $2, $3, $4)'; + // Admin user + await pool.query( + insertQuery, + [seedData.users.admin.name, seedData.users.admin.email, adminHashedPassword, 't'], + ); + // Customer + await pool.query( + insertQuery, + [seedData.users.validUser.name, seedData.users.validUser.email, userHashedPassword, 'f'], + ); +}; + +export { seedData, populateTables, populateUsersTable };