Skip to content

Commit

Permalink
[Closes #285] Hkuang/feature/2fa (#289)
Browse files Browse the repository at this point in the history
* Create Email Transporter Object

Create Email Object that uses takes gmail email and password from .env || SendMail function takes in (recipient email, email subject, and email contact) parameters to send out an email

* Update isAuthenticated to involve 2fa

User needs session two be authenticated in order to proceed.

* Add 2fa routing 

Route to email authentication page utilizing time-based one time password to authenticate

* Adding ToTP Generator

1. Take in Request
2. Generate token and key
3. Send Token to Email
4. Save Key in Request for Authentication

* Create Two Factor View

* Nodemail log-in var

* Update local.js

* Update RingdownForm.js (#288) (#290)

* Update RingdownForm.js

* Prettier

* prettier

* cicd

* redirects when authenticated

* cicd

* Making generateToTP a reusable function

* Switched from notp to OTPAuth

* Save Token to Database instead of Session

* MailCatcher up and Running!

* Link logo to send to user back to Login from 2fa

* turn sendEmail into a separate function

* WIP

* Migration for TwoFactorAuth

* rector ssodata to twofactordata

* correcting JSON formatting

* Playwright Tests

* Tests!

* Test edits

* yml

* Fix typo in package.json script

* Fix formatting

* eslint & migration

* Commented out home page tests

* prettier

* Remove previous addition of Auth-Related Columns

* Adding two Factor Flow to all tests

* Put 2fa for tests into helper fx for cleaner code

* Parameterize SMTP settings, update example.env

* Refactoring

* refactoring, adding else statement before next()

* Code style changes

---------

Co-authored-by: holliskuang <[email protected]>
Co-authored-by: Francis Li <[email protected]>
  • Loading branch information
3 people authored May 11, 2023
1 parent ef53bbe commit a05e47b
Show file tree
Hide file tree
Showing 22 changed files with 667 additions and 436 deletions.
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ services:
- ${HOST_PORT:-3000}:3000
depends_on:
- db
mail:
image: dockage/mailcatcher:0.8.2
ports:
- 1025:1025
- 1080:1080
3 changes: 2 additions & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"private": true,
"main": "index.js",
"dependencies": {
"@playwright/test": "^1.30.0"
"@playwright/test": "^1.30.0",
"dotenv": "^16.0.3"
},
"scripts": {
"test": "DEBUG=pw:webserver playwright test",
Expand Down
3 changes: 2 additions & 1 deletion e2e/playwright.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const { devices } = require('@playwright/test');
//dotenv file is needed to run tests
require('dotenv').config({ path: '../.env' });

/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();

/**
* @see https://playwright.dev/docs/test-configuration
Expand Down
28 changes: 27 additions & 1 deletion e2e/tests/home.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test.describe('home', () => {
await password.press('Enter');
await expect(page.getByText('Invalid email and/or password.')).toBeVisible();
});

/*
test('redirects to EMS interface after EMS user login', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email').fill(process.env.EMS_USER);
Expand All @@ -35,5 +35,31 @@ test.describe('home', () => {
await password.fill(process.env.HOSPITAL_PASS);
await password.press('Enter');
await expect(page).toHaveURL('/er');
}); */

test('shows the Routed logo and two factor authentication form', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email').fill(process.env.HOSPITAL_USER);
const password = page.getByLabel('Password');
await password.fill(process.env.HOSPITAL_PASS);
await password.press('Enter');
await expect(page).toHaveURL('/auth/local/twoFactor');
await expect(page).toHaveTitle(/Routed/);
await expect(page.getByAltText('Routed logo')).toBeVisible();
await expect(page.getByText('Please enter the Authoritzation Code that was sent to your E-mail')).toBeVisible();
await expect(page.getByLabel('Code')).toBeVisible();
await expect(page.getByText('Submit')).toBeVisible();
});

test('Shows Error after Incorrect Two Factor Authentication', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email').fill(process.env.HOSPITAL_USER);
const password = page.getByLabel('Password');
await password.fill(process.env.HOSPITAL_PASS);
await password.press('Enter');
await expect(page).toHaveURL('/auth/local/twoFactor');
await page.getByLabel('Code').fill('123456');
await page.getByText('Submit').click();
await expect(page.getByText('Invalid Authoritzation Code.')).toBeVisible();
});
});
5 changes: 5 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ REACT_APP_DISABLE_CODE_3=
REACT_APP_DISABLE_PILOT_HOSPITALS=
REACT_APP_PILOT_SHOW_ALL_RINGDOWNS=
SESSION_SECRET=makeitasecretinprod
SMTP_HOST=mail
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_REPLY_TO=[email protected]

EMS_USER=[email protected]
EMS_PASS=abcd1234
Expand Down
10 changes: 8 additions & 2 deletions server/auth/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const isAuthenticated = (req, res, next) => {
// ensure authenticated user is active
if (!req.user.isActive) {
res.status(HttpStatus.FORBIDDEN).end();
} else if (!req.session.twoFactor) {
res.status(HttpStatus.UNAUTHORIZED).end();
} else {
next();
}
Expand All @@ -17,7 +19,9 @@ const isAuthenticated = (req, res, next) => {

const isSuperUser = (req, res, next) => {
if (req.user?.isSuperUser) {
next();
if (!req.session.twoFactor) {
res.status(HttpStatus.UNAUTHORIZED).end();
} else next();
} else if (req.accepts('html')) {
res.redirect('/auth/local/login');
} else if (req.user) {
Expand All @@ -29,7 +33,9 @@ const isSuperUser = (req, res, next) => {

const isAdminUser = (req, res, next) => {
if (req.user?.isSuperUser || req.user?.isAdminUser) {
next();
if (!req.session.twoFactor) {
res.status(HttpStatus.UNAUTHORIZED).end();
} else next();
} else if (req.accepts('html')) {
res.redirect('/auth/local/login');
} else if (req.user) {
Expand Down
36 changes: 36 additions & 0 deletions server/mailer/emailTransporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const nodemailer = require('nodemailer');
const nodemailermock = require('nodemailer-mock');

// create reusable transporter class using the default SMTP transport with functions
function createTransport() {
// nodeMailer options
const options = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
};
// mock Transporter in testing env
const transporter = process.env.NODE_ENV === 'test' ? nodemailermock.createTransport(options) : nodemailer.createTransport(options);
return transporter;
}
// send mail with defined transport object
function sendMail(transporter, recipient, subject, content) {
const mailOptions = {
from: process.env.SMTP_REPLY_TO,
to: recipient,
subject: subject,
text: content,
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

module.exports = { createTransport, sendMail };
13 changes: 13 additions & 0 deletions server/migrations/20230503083739-add-twofactor-to-batsuser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.addColumn('batsuser', 'twofactordata', Sequelize.JSONB, { transaction });
});
},

async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.removeColumn('batsuser', 'twofactordata', { transaction });
});
},
};
38 changes: 38 additions & 0 deletions server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const _ = require('lodash');
const { Model } = require('sequelize');
const metadata = require('shared/metadata/user');
const initModel = require('../metadata/initModel');
const { sendMail, createTransport } = require('../mailer/emailTransporter');
const OTPAuth = require('otpauth');

const SALT_ROUNDS = 10;

Expand Down Expand Up @@ -38,6 +40,42 @@ module.exports = (sequelize) => {
'activeHospitals',
]);
}

async generateToTPSecret() {
const secret = new OTPAuth.Secret();
// new TOTP object using the secret key
const totp = new OTPAuth.TOTP({
secret: secret.base32,
issuer: 'Routed',
period: -1,
digits: 6,
});
console.log('TOTP Secret: ', secret.base32);
// save the secret key to the session
// generate secret token
const token = totp.generate();
// Save token and expiration timestamp in DB (Expires in 15 minutes from inital Log In)
// save totp secret and expiration timestamp in DB

await this.update({ twoFactorData: { totptimestamp: Date.now() + 900000, totptoken: token } });
await this.save();

// send email with token
sendMail(
createTransport(),
this.email,
'Your Authentication Code from Routed',
`This is your Authentication Code: ${token} . It will expire in 15 minutes.`
);
}

verifyTwoFactor(req) {
const token = req.body.code;
const totptoken = this.dataValues.twoFactorData.totptoken;
const totptimestamp = this.dataValues.twoFactorData.totptimestamp;
const verified = token === totptoken && Date.now() < totptimestamp;
return verified;
}
}

initModel(User, metadata, sequelize);
Expand Down
7 changes: 6 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"axios": "^0.21.4",
"bcrypt": "^5.0.1",
"bufferutil": "^4.0.6",
"chai": "^4.3.7",
"cookie-parser": "^1.4.6",
"cookie-session": "^1.4.0",
"debug": "^4.3.4",
Expand All @@ -16,7 +17,11 @@
"http-status-codes": "^1.4.0",
"lodash": "^4.17.21",
"luxon": "^1.28.0",
"mockery": "^2.1.0",
"morgan": "^1.10.0",
"nodemailer": "^6.9.1",
"nodemailer-mock": "^2.0.1",
"otpauth": "^9.1.1",
"passport": "^0.4.1",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.6.1",
Expand All @@ -32,7 +37,7 @@
"ws": "^7.5.8"
},
"devDependencies": {
"mocha": "^8.4.0",
"mocha": "^10.2.0",
"nodemon": "^2.0.18"
},
"scripts": {
Expand Down
40 changes: 37 additions & 3 deletions server/routes/auth/local.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
const express = require('express');
const HttpStatus = require('http-status-codes');
const passport = require('passport');

const router = express.Router();

router.get('/login', (req, res) => {
res.render('auth/local/login');
// If user is already logged in and two Factor authenticated then redirect to home page
if (req.session.twoFactor) {
res.redirect('/');
} else {
// Check if user is already logged in through passport, if so, log them out
if (req.user) req.logout();
res.render('auth/local/login');
}
});

router.get('/twoFactor', async (req, res) => {
if (req.session.twoFactor) {
res.redirect('/');
} else if (req.user) {
await req.user.generateToTPSecret();
res.render('auth/local/twoFactor');
} else {
res.redirect('/auth/local/login');
}
});

router.post('/login', (req, res, next) => {
Expand All @@ -22,7 +39,7 @@ router.post('/login', (req, res, next) => {
} else if (user) {
req.login(user, () => {
if (req.accepts('html')) {
res.redirect('/');
res.redirect('/auth/local/twoFactor');
} else {
res.status(HttpStatus.OK).end();
}
Expand All @@ -38,8 +55,25 @@ router.post('/login', (req, res, next) => {
})(req, res, next);
});

router.post('/twoFactor', async (req, res) => {
// Redirect if Session is interrupted
if (req.user) {
const verified = await req.user.verifyTwoFactor(req);
// If the code is verified, set the session to twoFactor and redirect to home page
if (verified) {
req.session.twoFactor = true;
res.redirect('/');
} else {
res.render('auth/local/twoFactor', { incorrectCode: true });
}
} else {
res.redirect('/auth/local/login');
}
});

router.get('/logout', (req, res) => {
req.logout();
req.session.twoFactor = false;
if (req.accepts('html')) {
res.redirect('/');
} else {
Expand Down
14 changes: 14 additions & 0 deletions server/test/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const fixtures = require('sequelize-fixtures');
const path = require('path');

const models = require('../models');
const nodemailermock = require('nodemailer-mock');

const loadFixtures = async (files) => {
const filePaths = files.map((f) => path.resolve(__dirname, `fixtures/${f}.json`));
Expand All @@ -30,6 +31,18 @@ const resetDatabase = async () => {
`);
};

const twoFactorAuthSession = async (testSession) => {
// Call the two-factor authentication endpoint
await testSession.get('/auth/local/twoFactor').set('Accept', 'application/json');
const sentMail = nodemailermock.mock.sentMail();
// Extract authentication code from the sent email
const regex = /Authentication Code: (\d{6})/;
const match = regex.exec(sentMail[0].text);
const authCode = match[1];
// Submit the authentication code
await testSession.post('/auth/local/twoFactor').set('Accept', 'application/json').send({ code: authCode });
};

beforeEach(async () => {
await resetDatabase();
});
Expand All @@ -42,4 +55,5 @@ after(async () => {

module.exports = {
loadFixtures,
twoFactorAuthSession,
};
8 changes: 7 additions & 1 deletion server/test/integration/api/ambulances.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
/* eslint-env mocha */

const assert = require('assert');
const HttpStatus = require('http-status-codes');
const session = require('supertest-session');

const helper = require('../../helper');
const app = require('../../../app');
const nodemailermock = require('nodemailer-mock');

describe('/api/ambulances', () => {
let testSession;

beforeEach(async () => {
await helper.loadFixtures(['organizations', 'users', 'ambulances']);

testSession = session(app);
await testSession
.post('/auth/local/login')
.set('Accept', 'application/json')
.send({ username: '[email protected]', password: 'abcd1234' });
await helper.twoFactorAuthSession(testSession);
});
afterEach(async () => {
nodemailermock.mock.reset();
});

describe('GET /identifiers', () => {
Expand Down
7 changes: 7 additions & 0 deletions server/test/integration/api/emsCalls.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* eslint-env mocha */

const assert = require('assert');
const HttpStatus = require('http-status-codes');
const session = require('supertest-session');

const helper = require('../../helper');
const app = require('../../../app');
const nodemailermock = require('nodemailer-mock');

describe('/api/emscalls', () => {
let testSession;
Expand All @@ -24,6 +27,10 @@ describe('/api/emscalls', () => {
.post('/auth/local/login')
.set('Accept', 'application/json')
.send({ username: '[email protected]', password: 'abcd1234' });
await helper.twoFactorAuthSession(testSession);
});
afterEach(async () => {
nodemailermock.mock.reset();
});

describe('GET /dispatch-call-numbers', () => {
Expand Down
Loading

0 comments on commit a05e47b

Please sign in to comment.