diff --git a/Jenkinsfile b/Jenkinsfile index c313036..84fc8af 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -90,17 +90,17 @@ pipeline { parallel ( "Unit" : { colourText("info","Running unit tests...") - sh 'npm run-script test-unit' + sh 'npm run test:unit' }, "Stress" : { colourText("info","Running stress tests...") - sh 'ENV=local node server/ & HOST=http://localhost:3001 REQUEST=5000 REQ_PER_SECOND=50 npm run-script test-load' + sh 'ENV=local node server/ & HOST=http://localhost:3001 REQUEST=5000 REQ_PER_SECOND=50 npm run test:load' // sh 'killall node' // The above command will leave node running, will this be closed along with the workspace? }, "Server" : { colourText("info","Running server tests...") - sh "npm run-script test-server" + sh "npm run test:server" } ) } @@ -124,11 +124,11 @@ pipeline { parallel ( "Coverage Report" : { colourText("info","Generating coverage report...") - sh "npm run-script cover" + sh "npm run cover" }, "Style Report" : { colourText("info","Generating style report...") - sh 'npm run-script lint-report-xml' + sh 'npm run lint-report-xml' } ) } diff --git a/README.md b/README.md index cd7c044..da3733d 100644 --- a/README.md +++ b/README.md @@ -29,32 +29,29 @@ export BI_UI_TEST_ADMIN_USERNAME=admin export BI_UI_TEST_ADMIN_PASSWORD=admin export BI_UI_TEST_USER_USERNAME=test export BI_UI_TEST_USER_PASSWORD=test -export JWT_SECRET=SECRET ``` ## Running the UI: -1. Clone this repo, install dependencies and start NPM +1. Clone this repo and install dependencies ```shell git clone https://github.com/ONSdigital/bi-ui.git cd bi-ui npm install -npm start ``` -The NPM start command uses the following commands: +2. Start the `Node.js` server ```shell -npm run build -SERVE_HTML=true ENV=local node server +npm run start:server ``` -This will run Node and React on localhost:3001, since Node is serving -`index.html`, hot-reloading will not work. +3. Start the `React.js` development server (with hot reloading) -To use hot-reloading, use `npm restart` which runs `react-scripts start`, this -will start React on port 3000. To start the server, use `ENV=local node server/index.js`. +```shell +npm run start:react +``` ## Running the API diff --git a/package.json b/package.json index ee1bac5..d543007 100644 --- a/package.json +++ b/package.json @@ -15,71 +15,47 @@ "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "chai": "^3.5.0", - "enzyme": "^2.9.0", - "enzyme-redux": "^0.1.6", "eslint": "^3.0.0", "eslint-config-airbnb": "^15.0.1", "eslint-plugin-flowtype": "^2.34.1", "eslint-plugin-import": "^2.3.0", "eslint-plugin-jsx-a11y": "^4.0.0", "eslint-plugin-react": "^7.1.0", - "flow-bin": "^0.48.0", + "eslint-html-reporter": "^0.5.2", "jasmine": "^2.5.3", - "jasmine-enzyme": "^3.3.0", - "jasmine-es6": "^0.4.0", "jasmine-node": "^1.14.5", - "jsdom": "11.0.0", - "jsdom-global": "3.0.2", + "jasmine-es6": "^0.4.0", + "coveralls": "^2.13.1", + "flow-bin": "^0.48.0", "mocha": "^3.0.2", + "istanbul": "^0.4.5", + "morgan": "^1.7.0", "mz": "^2.4.0", "react-scripts": "0.2.3", "react-test-renderer": "^15.6.1", - "redux-mock-store": "^1.2.3", - "redux-test-utils": "^0.1.2", "selenium-webdriver": "^3.3.0", + "loadtest": "^2.3.0", "supertest": "^3.0.0", "supertest-as-promised": "^4.0.0" }, "dependencies": { "base-64": "^0.1.0", - "bcryptjs": "^2.4.3", "body-parser": "^1.17.2", "compression": "^1.7.0", - "coveralls": "^2.13.1", - "d3": "^4.10.0", - "eslint-html-reporter": "^0.5.2", "express": "^4.14.0", "halogen": "^0.2.0", - "ie-version": "^0.1.0", - "istanbul": "^0.4.5", - "jasmine": "^2.5.3", - "jasmine-node": "^1.14.5", - "jquery": "^3.2.1", - "loadtest": "^2.3.0", "memory-cache": "^0.2.0", - "morgan": "^1.7.0", - "npm-run-script": "0.0.4", "object.assign": "^4.0.4", "prop-types": "^15.5.10", - "rc-pagination": "^1.7.3", "react": "^15.6.2", "react-a11y": "^0.3.4", - "react-bootstrap": "^0.30.7", - "react-bootstrap-button-loader": "1.0.8", - "react-bootstrap-table": "^3.4.5", "react-confetti": "^2.0.1", - "react-copy-to-clipboard": "^5.0.1", - "react-d3-tree": "^1.4.0", "react-dom": "^15.5.4", "react-modal-dialog": "^4.0.7", "react-redux": "^5.0.5", "react-router": "^2.6.1", - "react-router-bootstrap": "^0.23.1", "react-select": "^1.0.0-rc.10", - "react-stepper-horizontal": "^1.0.9", "react-table": "^6.5.3", - "react-toggle": "^4.0.1", - "react-tooltip": "^3.3.0", "redux": "^3.6.0", "redux-thunk": "^2.2.0", "registers-react-library": "^1.0.0", @@ -93,18 +69,17 @@ "npm": "3.8.3" }, "scripts": { - "start:server": "ENV=local SERVER_AUTH_URL=http://localhost:3002/auth SERVER_API_GW_URL=http://localhost:3002 node server", - "start": "npm run build; SERVE_HTML=true ENV=local SERVER_AUTH_URL=http://localhost:3002/auth SERVER_API_GW_URL=http://localhost:3002 node server", - "restart": "REACT_APP_ENV=local REACT_APP_AUTH_URL=http://localhost:3001 REACT_APP_API_URL=http://localhost:3001/api react-scripts start", + "start": "REACT_APP_ENV=local REACT_APP_AUTH_URL=http://localhost:3001 REACT_APP_API_URL=http://localhost:3001/api npm run build; SERVE_HTML=true NODE_ENV=development SERVER_AUTH_URL=http://localhost:3002/auth SERVER_API_GW_URL=http://localhost:3002 node server", + "start:server": "NODE_ENV=development SERVER_AUTH_URL=http://localhost:3002/auth SERVER_API_GW_URL=http://localhost:3002 node server", + "start:react": "REACT_APP_ENV=local REACT_APP_AUTH_URL=http://localhost:3001 REACT_APP_API_URL=http://localhost:3001/api react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject", "cover": "NODE_ENV=test SERVE_HTML=true ENV=local ./node_modules/istanbul/lib/cli.js cover --report cobertura ./node_modules/mocha/bin/_mocha -- -R spec test/server.test.js", - "test": "npm run-script test-unit; npm run-script test-components; npm run-script test-server; npm run-script test-server", - "test-components": "./node_modules/babel-cli/bin/babel-node.js test/component-tests.js", - "test-unit": "./node_modules/babel-cli/bin/babel-node.js test/utils-unit-tests.js", - "test-load": "./node_modules/babel-cli/bin/babel-node.js test/loadtest-unit-tests.js", - "test-server": "ENV=local SERVE_HTML=true ./node_modules/mocha/bin/mocha test/server.test.js", - "test-selenium": "NODE_ENV=test UI_URL=http://localhost:3000 ./node_modules/jasmine/bin/jasmine.js test/integration-test.js", + "test": "npm run test:unit; npm run test:server; npm run test:server", + "test:unit": "./node_modules/babel-cli/bin/babel-node.js test/utils-unit-tests.js", + "test:load": "./node_modules/babel-cli/bin/babel-node.js test/loadtest-unit-tests.js", + "test:server": "./node_modules/mocha/bin/mocha test/server.test.js; ./node_modules/mocha/bin/mocha test/server-test-spec --recursive", + "test:selenium": "NODE_ENV=test UI_URL=http://localhost:3000 ./node_modules/jasmine/bin/jasmine.js test/integration-test.js", "lint": "./node_modules/eslint/bin/eslint.js **/*.js --ignore-pattern /test/*.js", "lint-report-xml": "./node_modules/eslint/bin/eslint.js . -f checkstyle -o ./coverage/eslint-report-checkstyle.xml; exit 0", "flow": "flow" diff --git a/server/app.js b/server/app.js deleted file mode 100644 index a8f66b6..0000000 --- a/server/app.js +++ /dev/null @@ -1,236 +0,0 @@ -'use strict'; - -// Rule exceptions: -/* eslint strict: "off" */ -/* eslint comma-dangle: ["error", "never"] */ - -const express = require('express'); -const morgan = require('morgan'); -const path = require('path'); -const uuidv4 = require('uuid/v4'); -const myParser = require('body-parser'); -const urls = require('./config/urls'); -const timeouts = require('./config/timeouts'); -const version = require('./package.json').version; -const rp = require('request-promise'); -const compression = require('compression'); -const cache = require('./helpers/cache'); -const formatDate = require('./helpers/formatDate.js'); -const logger = require('./logger')(module); - -// To allow hot-reloading, the node server only serves the React.js index.html -// in the /build file if SERVE_HTML is true -const ENV = process.env.ENV; -const SERVE_HTML = (process.env.SERVE_HTML === 'true'); - -const sessions = {}; // For the user sessions -const startTime = formatDate(new Date()); - -const app = express(); - -app.use(compression()); // gzip all responses -morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] :response-time ms'); -app.use(morgan('combined', { stream: logger.stream })); -app.use(myParser.json()); // For parsing body of POSTs - -// Serve static assets (static js files for React from 'npm run build') -if (SERVE_HTML) { - logger.info('Serving static html in build dir'); - app.use(express.static(path.resolve(__dirname, '..', 'build'))); -} - -// Below is for CORS, CORS is only needed when React/Node are on different ports -// e.g. when testing locally and React is on 3000 and Node is on 3001 -if (ENV === 'local') { - logger.info('Using Access-Control-Allow-Origin CORS headers'); - app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); - next(); - }); -} - -// This method needs to be above the serve React code -// If it's below, the get('*') will point all GETs to the React -app.get('/info', cache(), (req, res) => { - logger.info('Returning /info'); - res.send(JSON.stringify({ - version, - lastUpdate: startTime - })); -}); - -// Always return the main index.html, so react-router renders the route in the client -if (SERVE_HTML) { - logger.info('Serving /build dir static files'); - app.get('*', cache(), (req, res) => { - res.sendFile(path.resolve(__dirname, '..', 'build', 'index.html')); - }); -} - -app.post('/login', (req, res) => { - logger.info('Logging user in'); - // Get the username from the body of the POST - const username = req.body.username; - - const basicAuth = req.get('Authorization'); - let options = { - method: 'POST', - uri: urls.AUTH_URL, - timeout: timeouts.API_GW, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `${basicAuth}` - }, - json: true, - body: { username } - }; - if (ENV === 'prod') { - options = { - method: 'POST', - uri: urls.AUTH_URL, - timeout: timeouts.API_GW, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Authorization': `${basicAuth}` - }, - json: true - }; - } - - rp(options) - .then((gatewayJson) => { - // Create user session - const accessToken = uuidv4(); - // We get the showConfetti env var on every login, as it can dynamically change in CF - const showConfetti = (process.env.SHOW_CONFETTI === 'true'); - sessions[accessToken] = { - key: gatewayJson.key, - role: gatewayJson.role, - username - }; - - logger.info('Successful login'); - res.setHeader('Content-Type', 'application/json'); - return res.send(JSON.stringify({ - username, - accessToken, - role: gatewayJson.role, - showConfetti - })); - }) - .catch((err) => { - logger.error('Unable to login, timeout or server error'); - if (err.statusCode) return res.sendStatus(err.statusCode); - return res.sendStatus(504); // Timeout - }); -}); - -app.post('/checkToken', (req, res) => { - logger.info('Checking token'); - const accessToken = req.body.accessToken; - - if (sessions[accessToken]) { - logger.info('Valid token'); - res.setHeader('Content-Type', 'application/json'); - res.send(JSON.stringify({ - username: sessions[accessToken].username, - accessToken, - role: sessions[accessToken].role - })); - } else { - logger.info('Invalid token'); - res.sendStatus(401); - } -}); - -app.post('/logout', (req, res) => { - const token = req.body.token; - // Remove user from storage - delete sessions[token]; - logger.info('Logging user out'); - res.sendStatus(200); -}); - -app.post('/logout', (req, res) => { - logger.info('Logging user out'); - const token = req.body.token; - try { - // Remove user from storage - delete sessions[token]; - logger.info('Successful logout'); - res.sendStatus(200); - } catch (e) { - logger.error(`Unable to log user out: ${e}`); - res.sendStatus(500); - } -}); - -app.post('/api', (req, res) => { - // re route api requests with API key - const method = req.body.method; - const endpoint = req.body.endpoint; - const accessToken = req.get('Authorization'); - - if (sessions[accessToken]) { - const key = sessions[accessToken].key; - if (method === 'GET') { - getApiEndpoint(`${urls.API_GW}/bi/${endpoint}`, key) - .then((response) => { - logger.info('Returning GET response from API Gateway'); - return res.send(response); - }) - .catch((err) => { - logger.info('Error in API Gateway for GET request'); - return res.status(err.statusCode).send(err); - }); - } else if (method === 'POST') { - const postBody = req.body.postBody; - postApiEndpoint(`${urls.API_GW}/bi/${endpoint}`, postBody, key) - .then((response) => { - logger.info('Returning POST response from API Gateway'); - return res.send(response); - }) - .catch((err) => { - logger.info('Error in API Gateway for POST request'); - return res.status(err.statusCode).send(err); - }); - } - } else { - logger.info('Unable to use /api endpoint, not authenticated'); - return res.sendStatus(401); - } -}); - -function getApiEndpoint(url, apiKey) { - logger.debug(`GET API endpoint for url: ${url}`); - const options = { - method: 'GET', - headers: { - 'Authorization': apiKey - }, - uri: url, - timeout: timeouts.API_GET - }; - - return rp(options); -} - -function postApiEndpoint(url, postBody, apiKey) { - logger.debug(`POST API endpoint for url: ${url}`); - const options = { - method: 'POST', - uri: url, - timeout: timeouts.API_POST, - headers: { - 'Authorization': apiKey, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(postBody), - json: false - }; - - return rp(options); -} - -module.exports = app; diff --git a/server/config/sessions.js b/server/config/sessions.js new file mode 100644 index 0000000..d929ce1 --- /dev/null +++ b/server/config/sessions.js @@ -0,0 +1,6 @@ +const sessions = { + SESSION_EXPIRE: 60 * 60 * 8, + SESSION_EXPIRE_HOURS: 8, +}; + +module.exports = sessions; diff --git a/server/index.js b/server/index.js index d5e3316..ccd4e60 100644 --- a/server/index.js +++ b/server/index.js @@ -1,21 +1,85 @@ 'use strict'; -/* eslint strict: "off" */ -/* eslint no-console: "off" */ - +const express = require('express'); const fork = require('child_process').fork; -const app = require('./app'); const logger = require('./logger')(module); +const compression = require('compression'); +const morgan = require('morgan'); +const myParser = require('body-parser'); +const path = require('path'); +const JsonSession = require('./sessions/JsonSession'); +// Environment Variables +const SERVE_HTML = (process.env.SERVE_HTML === 'true'); // To server the React /build const PORT = process.env.PORT || 3001; +const LOG_LEVEL = (process.env.NODE_ENV === 'production') ? 'info' : 'debug'; +const SESSION_DB = process.env.SESSION_DB || 'json'; +const ENV = process.env.NODE_ENV; -// On a local environment, we mock the API Gateway with the a node script on localhost:3002 -const child = (process.env.ENV === 'local') ? fork('./server/apiGateway') : null; +// On a local environment, we mock the API Gateway with a node script on localhost:3002 +const child = (ENV === 'development') ? fork('./server/apiGateway') : null; -logger.level = 'debug'; +logger.level = LOG_LEVEL; logger.info('Started Winston logger & created log file'); +logger.info(`NODE_ENV: ${process.env.NODE_ENV}`); +logger.info(`LOG_LEVEL: ${LOG_LEVEL}`); + +// Choose which session type to use +const session = ((db) => { + switch (db) { + case 'json': + logger.debug('Creating new JsonSession'); + return new JsonSession(); + default: + logger.debug('Creating new JsonSession'); + return new JsonSession(); + } +})(SESSION_DB); +logger.info(`Using session type: ${session.name}`); + +// https://stackoverflow.com/questions/10090414/express-how-to-pass-app-instance-to-routes-from-a-different-file +const app = module.exports = express(); + +// Attach the cache function to the app +app.cache = require('./helpers/cache'); + +// Gzip all responses +app.use(compression()); + +// Logging configuration +morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] :response-time ms'); +app.use(morgan('combined', { stream: logger.stream })); // Send Morgan logs to Winston + +// For parsing the body of POSTs +app.use(myParser.json()); + +// Serve static assets (static js files for React from 'npm run build') +if (SERVE_HTML) { + logger.info('Serving static html in build dir'); + app.use(express.static(path.resolve(__dirname, '..', 'build'))); +} + +// Below is for CORS, CORS is only needed when React/Node are on different ports +// e.g. when testing locally and React is on 3000 and Node is on 3001 +if (ENV === 'development') { + logger.info('Using Access-Control-Allow-Origin CORS headers'); + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); + res.header('Access-Control-Allow-Methods', 'POST, PUT, GET, OPTIONS'); + next(); + }); +} + +// Attach the session class to the app +app.session = session; +app.env = ENV; + +// Routes +app.use(require('./routes/auth')); +app.use(require('./routes/api')); +if (SERVE_HTML) app.use(require('./routes/staticFiles')); -app.maxSockets = 500; app.listen(PORT, () => { logger.info(`bi-ui-node-server listening on port ${PORT}!`); }); @@ -26,12 +90,11 @@ process.stdin.resume(); // so the program will not close instantly function exitHandler(options, err) { if (options.cleanup) { if (process.env.ENV === 'local') { - console.log('Killing child process (bi-ui-mock-api-gateway)...'); logger.info('Killing child process (bi-ui-mock-api-gateway)...'); child.kill('SIGINT'); } } - if (err) console.log(err.stack); + if (err) logger.error(err.stack); if (options.exit) process.exit(); } diff --git a/server/package.json b/server/package.json index 44f31a0..980247c 100644 --- a/server/package.json +++ b/server/package.json @@ -26,6 +26,6 @@ "npm": "3.8.3" }, "scripts": { - "start": "SERVE_HTML=true ENV=local node index.js" + "start": "NODE_ENV=development SERVER_AUTH_URL=http://localhost:3002/auth SERVER_API_GW_URL=http://localhost:3002 node index.js" } } diff --git a/server/routes/api.js b/server/routes/api.js new file mode 100644 index 0000000..e796234 --- /dev/null +++ b/server/routes/api.js @@ -0,0 +1,103 @@ +'use strict'; + +const app = require('../index'); +const express = require('express'); +const logger = require('../logger')(module); +const formatDate = require('../helpers/formatDate'); +const version = require('../package.json').version; +const urls = require('../config/urls'); +const timeouts = require('../config/timeouts'); +const rp = require('request-promise'); + +const router = express.Router(); + +const startTime = formatDate(new Date()); + +const authMiddleware = function (req, res, next) { + // This middleware will be used in every /api/ method to + // validate the user provided accessToken + const accessToken = req.get('Authorization'); + + app.session.getSession(accessToken) + .then((json) => { + logger.info('Valid token'); + req.username = json.username; + req.apiKey = json.apiKey; + next(); + }) + .catch(() => { + logger.info('Invalid token'); + res.sendStatus(401).end(); + }); +}; + +router.get('/api/info', authMiddleware, (req, res) => { + logger.info('Returning /info'); + res.send(JSON.stringify({ + version, + lastUpdate: startTime, + })); +}); + +router.post('/api', authMiddleware, (req, res) => { + // re route api requests with API key + const method = req.body.method; + const endpoint = req.body.endpoint; + + const key = req.apiKey; + if (method === 'GET') { + getApiEndpoint(`${urls.API_GW}/bi/${endpoint}`, key) + .then((response) => { + logger.info('Returning GET response from API Gateway'); + return res.send(response); + }) + .catch((err) => { + logger.info('Error in API Gateway for GET request'); + return res.sendStatus(err.statusCode); + }); + } else if (method === 'POST') { + const postBody = req.body.postBody; + postApiEndpoint(`${urls.API_GW}/bi/${endpoint}`, postBody, key) + .then((response) => { + logger.info('Returning POST response from API Gateway'); + return res.send(response); + }) + .catch((err) => { + logger.info('Error in API Gateway for POST request'); + return res.sendStatus(err.statusCode); + }); + } +}); + +function getApiEndpoint(url, apiKey) { + logger.debug(`GET API endpoint for url: ${url}`); + const options = { + method: 'GET', + headers: { + 'Authorization': apiKey, + }, + uri: url, + timeout: timeouts.API_GET, + }; + + return rp(options); +} + +function postApiEndpoint(url, postBody, apiKey) { + logger.debug(`POST API endpoint for url: ${url}`); + const options = { + method: 'POST', + uri: url, + timeout: timeouts.API_POST, + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postBody), + json: false, + }; + + return rp(options); +} + +module.exports = router; diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..ee1129d --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,121 @@ +'use strict'; + +const app = require('../index'); +const express = require('express'); +const rp = require('request-promise'); +const uuidv4 = require('uuid/v4'); +const logger = require('../logger')(module); +const urls = require('../config/urls'); +const timeouts = require('../config/timeouts'); + +const router = express.Router(); + +const createSessionMiddleware = (req, res) => { + app.session.createSession(req.username, req.connection.remoteAddress, req.role, req.key) + .then((sessionJson) => { + logger.info('Successful login'); + res.send(JSON.stringify({ + accessToken: sessionJson.accessToken, + username: req.body.username, + role: req.role, + showConfetti: req.showConfetti, + })).end(); + }) + .catch((error) => { + logger.error(`Login 500 server error: ${error}`); + res.sendStatus(500).end(); + }); +}; + +const loginMiddleware = (req, res, next) => { + const username = req.body.username; + const basicAuth = req.get('Authorization'); + + let options = { + method: 'POST', + uri: urls.AUTH_URL, + timeout: timeouts.API_GW, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `${basicAuth}`, + }, + json: true, + body: { username }, + }; + if (app.ENV === 'prod') { + options = { + method: 'POST', + uri: urls.AUTH_URL, + timeout: timeouts.API_GW, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'Authorization': `${basicAuth}`, + }, + json: true, + }; + } + + rp(options) + .then((gatewayJson) => { + // Create user session + const accessToken = uuidv4(); + // We get the showConfetti env var on every login, as it can dynamically change in CF + const showConfetti = (process.env.SHOW_CONFETTI === 'true'); + + // Attach variables to the request for later use + req.showConfetti = showConfetti; + req.role = gatewayJson.role; + req.key = gatewayJson.key; + req.accessToken = accessToken; + req.username = username; + + logger.info('Successful login'); + next(); + }) + .catch((err) => { + logger.error(`Unable to login, timeout or server error: ${err}`); + if (err.statusCode) return res.sendStatus(err.statusCode).end(); + return res.sendStatus(504).end(); // Timeout + }); +}; + +const checkTokenMiddleware = (req, res) => { + logger.info('Checking token'); + const accessToken = req.get('Authorization'); + + app.session.getSession(accessToken) + .then((json) => { + logger.info('Valid token'); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify({ + username: json.username, + role: json.role, + accessToken, + })).end(); + }) + .catch(() => { + logger.info('Invalid token'); + res.sendStatus(401).end(); + }); +}; + +const logoutMiddleware = (req, res) => { + logger.info('Logging user out'); + const accessToken = req.get('Authorization'); + + app.session.killSession(accessToken) + .then(() => { + logger.info('Successful user log out'); + res.sendStatus(200).end(); + }) + .catch((err) => { + logger.info(`Unable to log user out: ${err}`); + res.sendStatus(401).end(); + }); +}; + +router.post('/auth/login', [loginMiddleware, createSessionMiddleware]); +router.post('/auth/checkToken', [checkTokenMiddleware]); +router.post('/auth/logout', [logoutMiddleware]); + +module.exports = router; diff --git a/server/routes/staticFiles.js b/server/routes/staticFiles.js new file mode 100644 index 0000000..bf625c6 --- /dev/null +++ b/server/routes/staticFiles.js @@ -0,0 +1,14 @@ +'use strict'; + +const app = require('../index'); +const express = require('express'); +const path = require('path'); + +const router = express.Router(); + +// Always return the main index.html, so react-router renders the route in the client +router.get('*', app.cache(), (req, res) => { + res.sendFile(path.resolve(__dirname, '../..', 'build', 'index.html')); +}); + +module.exports = router; diff --git a/server/sessions/JsonSession.js b/server/sessions/JsonSession.js new file mode 100644 index 0000000..5244573 --- /dev/null +++ b/server/sessions/JsonSession.js @@ -0,0 +1,79 @@ +'use strict'; + +const uuidv4 = require('uuid/v4'); +const logger = require('../logger')(module); +const config = require('../config/sessions'); + +class JsonSession { + constructor() { + this.name = 'json'; + this.sessionExpireHours = config.SESSION_EXPIRE_HOURS; + this.session = {}; + } + + createSession(username, remoteAddress, role, apiKey) { + logger.debug('Creating new JSON session'); + + return new Promise((resolve, reject) => { + const accessToken = uuidv4(); + try { + const sessionExpire = new Date(); + sessionExpire.setHours(sessionExpire.getHours() + this.sessionExpireHours); + this.session[accessToken] = { + username, + role, + apiKey, + remoteAddress, + sessionExpire, + }; + logger.debug('Create JSON session was successful'); + resolve({ accessToken }); + } catch (error) { + logger.error(`Unable to create JSON session: ${error}`); + reject({ error }); + } + }); + } + + getSession(accessToken) { + logger.debug('Getting JSON session'); + + return new Promise((resolve, reject) => { + try { + const userSession = this.session[accessToken]; + const username = userSession.username; + const role = userSession.role; + const apiKey = userSession.apiKey; + const sessionExpire = userSession.sessionExpire; + if (new Date() > sessionExpire) { + logger.debug('JSON session has timed out'); + delete this.userSession[accessToken]; + reject({ error: 'JSON session has timed out' }); + } else { + logger.debug('Get JSON session was successful'); + resolve({ username, accessToken, role, apiKey }); + } + } catch (error) { + logger.error(`Unable to get JSON session: ${error}`); + reject({ error }); + } + }); + } + + killSession(accessToken) { + logger.debug('Killing JSON session'); + + return new Promise((resolve, reject) => { + try { + delete this.session[accessToken]; + logger.debug('JSON session was successfully killed'); + resolve(); + } catch (error) { + logger.error(`Unable to kill JSON session: ${error}`); + reject({ error }); + } + }); + } +} + +module.exports = JsonSession; diff --git a/src/utils/apiInfo.js b/src/utils/apiInfo.js index 8b6c161..d53f072 100644 --- a/src/utils/apiInfo.js +++ b/src/utils/apiInfo.js @@ -2,7 +2,7 @@ import config from '../config/api-urls'; -const { AUTH_URL, API_URL } = config; +const { AUTH_URL, REROUTE_URL, API_VERSION } = config; /** * API lib for getting info (version/last updated etc.) @@ -14,10 +14,11 @@ const apiInfo = { * @param {Function} callback Called with returned data. */ getUiInfo(callback: (success: boolean, data: {}) => void) { - fetch(`${AUTH_URL}/info`, { + fetch(`${AUTH_URL}/api/info`, { method: 'GET', headers: { 'Content-Type': 'application/json', + 'Authorization': sessionStorage.accessToken, }, }).then((response) => { if (response.status === 200) { @@ -37,8 +38,16 @@ const apiInfo = { * @param {Function} callback Called with returned data. */ getApiInfo(callback: (success: boolean, data: {}) => void) { - fetch(`${API_URL}/version`, { - method: 'GET', + fetch(`${REROUTE_URL}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': sessionStorage.getItem('accessToken'), + }, + body: JSON.stringify({ + method: 'GET', + endpoint: 'version', + }), }).then((response) => { if (response.status === 200) { return response.json().then((json) => { diff --git a/src/utils/auth.js b/src/utils/auth.js index 63a7f63..83ca1ef 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -20,7 +20,7 @@ const auth = { // routes.js before this method is called // POST to the backend with username/password - fetch(`${AUTH_URL}/login`, { + fetch(`${AUTH_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,12 +48,12 @@ const auth = { }); }, checkToken(accessToken: string, callback: (success: boolean, data: ?{}) => void) { - fetch(`${AUTH_URL}/checkToken`, { + fetch(`${AUTH_URL}/auth/checkToken`, { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Authorization': sessionStorage.accessToken, }, - body: JSON.stringify({ accessToken }), }).then((response) => { if (response.status === 200) { return response.json().then((json) => { @@ -75,12 +75,12 @@ const auth = { */ logout(accessToken: string, callback: (success: boolean) => void) { // const token: string = sessionStorage.token; - fetch(`${AUTH_URL}/logout`, { + fetch(`${AUTH_URL}/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Authorization': sessionStorage.accessToken, }, - body: JSON.stringify({ accessToken }), }).then(() => { // Whatever the response, log the user out. sessionStorage.clear(); diff --git a/test/component-tests.js b/test/component-tests.js deleted file mode 100644 index 987a284..0000000 --- a/test/component-tests.js +++ /dev/null @@ -1,17 +0,0 @@ -import Jasmine from 'jasmine' - -var jasmine = new Jasmine() - -// Load all files in the /spec dir ending in spec.js -jasmine.loadConfig({ - "spec_dir": "test/component-tests", - "spec_files": [ - "*.js" - ], - "helpers": [ - "../node_modules/jasmine-es6/lib/install.js", - "helpers/**/*.js" - ] -}); - -jasmine.execute() diff --git a/test/component-tests/LoginErrorMessage.test.js b/test/component-tests/LoginErrorMessage.test.js deleted file mode 100644 index 7e44f4e..0000000 --- a/test/component-tests/LoginErrorMessage.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import 'jsdom-global/register'; -import React from 'react'; -import { expect } from 'chai'; -import { mount, shallow } from 'enzyme'; -import jasmineEnzyme from 'jasmine-enzyme'; -import { connect } from 'react-redux'; -import { shallowWithStore } from 'enzyme-redux'; -import { createMockStore } from 'redux-test-utils'; -import configureStore from 'redux-mock-store'; -import LoginErrorMessage from '../../src/components/LoginErrorMessage'; - -const mockStore = configureStore(); - -describe('', () => { - const initialState = { login: { errorMessage: "General error." }}; - const mockStore = configureStore(); - let store,container; - - beforeEach(() => { - jasmineEnzyme(); - }); - - it('renders the correct error message from props', () => { - store = mockStore(initialState); - container = shallow( ); - expect(container.prop('errorMessage')).to.equal(initialState.login.errorMessage); - }); -}); diff --git a/test/component-tests/UserDetailsModal.test.js b/test/component-tests/UserDetailsModal.test.js deleted file mode 100644 index 8edde80..0000000 --- a/test/component-tests/UserDetailsModal.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import 'jsdom-global/register'; -import React from 'react'; -import UserDetailsModal from '../../src/components/UserDetailsModal'; -import { mount, shallow } from 'enzyme'; -import { expect } from 'chai'; - -describe('', () => { - it('renders the correct error message from props', () => { - const wrapper = mount( - - ); - expect(wrapper.props().username).to.equal('jonDoe123'); - expect(wrapper.props().role).to.equal('admin'); - }); - - it('renders the correct error message from props', () => { - const wrapper = mount( - - ); - expect(wrapper.state().isShowingModal).to.equal(false); - //wrapper.find('#userDetailsModal').simulate('click'); - //expect(wrapper.state().isShowingModal).to.equal(true); - }); -}); diff --git a/test/server-test-spec/api.test.js b/test/server-test-spec/api.test.js new file mode 100644 index 0000000..f9c9614 --- /dev/null +++ b/test/server-test-spec/api.test.js @@ -0,0 +1,58 @@ +const exec = require('mz/child_process').exec; +const request = require('supertest-as-promised'); +const expect = require('chai').expect; +const base64 = require('base-64'); +const fork = require('child_process').fork; + +// Set some environment variables first: +process.env.SERVER_AUTH_URL = 'http://localhost:3002/auth'; +process.env.SERVER_API_GW_URL = 'http://localhost:3002'; + +// Run the gateway - remember to kill it afterwards +const gateway = fork(require('../../server/apiGateway')); + +const app = require('../../server/index'); + +describe('node server api', function () { + let adminToken; + + function login(done) { + return request(app) + .post('/auth/login') + .type('application/json') + .set('Authorization', `Basic ${base64.encode('admin:admin')}`) + .send({ + 'username': 'admin', + }) + .then(res => { + expect('Content-Type', 'application/json; charset=utf-8') + const resp = JSON.parse(res.text); + adminToken = resp.accessToken; + expect(200); + done() + }); + } + + // Before we start the tests, we need to log a user in + before(function(done) { + login(done); + }) + + it('gets information from the /api/info endpoint', function () { + return request(app) + .get('/api/info') + .type('application/json') + .set('Authorization', adminToken) + .expect(200) + .then(res => { + const json = JSON.parse(res.text); + // Check we get the right keys back + const lastUpdate = 'lastUpdate' in json; + const version = 'version' in json; + expect(lastUpdate).to.be.equal(true); + expect(version).to.be.equal(true); + }); + }); +}); + +gateway.kill('SIGINT'); diff --git a/test/server-test-spec/authentication.test.js b/test/server-test-spec/authentication.test.js new file mode 100644 index 0000000..1e1f997 --- /dev/null +++ b/test/server-test-spec/authentication.test.js @@ -0,0 +1,116 @@ +const exec = require('mz/child_process').exec; +const request = require('supertest-as-promised'); +const expect = require('chai').expect; +const base64 = require('base-64'); +const fork = require('child_process').fork; + +// Set some environment variables first: +process.env.SERVER_AUTH_URL = 'http://localhost:3002/auth'; +process.env.SERVER_API_GW_URL = 'http://localhost:3002'; + +// Run the gateway - remember to kill it afterwards +const gateway = fork(require('../../server/apiGateway')); + +const app = require('../../server/index'); + +describe('node server authentication', function () { + let adminToken; + let userToken; + + it('logs a local user in (admin)', function () { + return request(app) + .post('/auth/login') + .type('application/json') + .set('Authorization', `Basic ${base64.encode('admin:admin')}`) + .send({ + 'username': 'admin', + }) + .then(res => { + expect('Content-Type', 'application/json; charset=utf-8') + const resp = JSON.parse(res.text); + adminToken = resp.accessToken; + expect(200); + }); + }); + + it('checks the users token (admin)', function () { + return request(app) + .post('/auth/checkToken') + .type('application/json') + .set('Authorization', adminToken) + .expect(200) + .then(res => { + const json = JSON.parse(res.text); + expect(json.username).to.be.equal('admin'); + expect(json.role).to.be.equal('admin'); + expect(json.accessToken).to.be.equal(adminToken); + }); + }); + + it('logs a local user out (admin)', function () { + return request(app) + .post('/auth/logout') + .type('application/json') + .set('Authorization', adminToken) + .expect(200); + }); + + it('logs a local user in (test)', function () { + return request(app) + .post('/auth/login') + .type('application/json') + .set('Authorization', `Basic ${base64.encode('test:test')}`) + .send({ + 'username': 'test', + }) + .then(res => { + expect('Content-Type', 'application/json; charset=utf-8') + const resp = JSON.parse(res.text); + userToken = resp.accessToken; + expect(200); + }); + }); + + it('checks the users token (test)', function () { + return request(app) + .post('/auth/checkToken') + .type('application/json') + .set('Authorization', userToken) + .expect(200) + .then(res => { + const json = JSON.parse(res.text); + expect(json.username).to.be.equal('test'); + expect(json.role).to.be.equal('admin'); + expect(json.accessToken).to.be.equal(userToken); + }); + }); + + it('logs a local user out (test)', function () { + return request(app) + .post('/auth/logout') + .type('application/json') + .set('Authorization', userToken) + .expect(200); + }); + + it('will not log a user in with incorrect credentials', function () { + return request(app) + .post('/auth/login') + .type('application/json') + .set('Authorization', `Basic ${base64.encode('jonDoe:jonDoe')}`) + .send({ + 'username': 'jonDoe', + }) + .expect(401); + }); + + it('will return 401 for an invalid token', function () { + return request(app) + .post('/auth/checkToken') + .type('application/json') + .set('Authorization', 'abc123') + .expect(401); + }); +}); + +gateway.kill('SIGINT'); diff --git a/test/server.test.js b/test/server.test.js index 03f7dcf..8060459 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -3,8 +3,6 @@ const request = require('supertest-as-promised'); const expect = require('chai').expect; const base64 = require('base-64'); -const app = require('../server/app'); - describe('builds application', function () { it('builds to "build" directory', function () { // Disable mocha time-out because this takes a lot of time @@ -15,9 +13,10 @@ describe('builds application', function () { }); }); -describe('routes and authentication work', function () { - let adminToken; - let userToken; +process.env.SERVE_HTML = 'true'; +const app = require('../server/index'); + +describe('static assets are served (react /build dir)', function () { it('responds to / with the index.html', function () { return request(app) @@ -33,105 +32,4 @@ describe('routes and authentication work', function () { .expect('Content-Type', 'image/x-icon') .expect(200); }); - - it('responds to any route with the index.html', function () { - return request(app) - .get('/foo/bar') - .expect('Content-Type', /html/) - .expect(200) - .then(res => expect(res.text).to.contain('
')); - }); - - it('logs a local user in (admin)', function () { - return request(app) - .post('/login') - .type('application/json') - .set('Authorization', `Basic ${base64.encode('admin:admin')}`) - .send({ - 'username': 'admin', - }) - .then(res => { - expect('Content-Type', 'application/json; charset=utf-8') - const resp = JSON.parse(res.text); - adminToken = resp.accessToken; - expect(200); - }); - }); - - it('checks the users token (admin)', function () { - return request(app) - .post('/checkToken') - .type('application/json') - .send({ - 'accessToken': adminToken - }) - .expect(200); - }); - - it('logs a local user out (admin)', function () { - return request(app) - .post('/logout') - .type('application/json') - .send({ - 'accessToken': adminToken - }) - .expect(200); - }); - - it('logs a local user in (test)', function () { - return request(app) - .post('/login') - .type('application/json') - .set('Authorization', `Basic ${base64.encode('test:test')}`) - .send({ - 'username': 'test', - }) - .then(res => { - expect('Content-Type', 'application/json; charset=utf-8') - const resp = JSON.parse(res.text); - userToken = resp.accessToken; - expect(200); - }); - }); - - it('checks the users token (test)', function () { - return request(app) - .post('/checkToken') - .type('application/json') - .send({ - 'accessToken': userToken - }) - .expect(200); - }); - - it('logs a local user out (test)', function () { - return request(app) - .post('/logout') - .type('application/json') - .send({ - 'token': userToken - }) - .expect(200); - }); - - it('will not log a user in with incorrect credentials', function () { - return request(app) - .post('/login') - .type('application/json') - .send({ - 'username': 'jonDoe', - 'password': 'qwerty' - }) - .expect(401); - }); - - it('will return 401 for an invalid token', function () { - return request(app) - .post('/checkToken') - .type('application/json') - .send({ - 'token': 'abc' - }) - .expect(401); - }); -}); +}); \ No newline at end of file