Skip to content

Commit

Permalink
Merge pull request #324 from nhsuk/replace-basic-auth-with-a-password…
Browse files Browse the repository at this point in the history
…-page

Replace basic authentication with a password page and a cookie.
  • Loading branch information
roshaanbajwa authored Aug 15, 2024
2 parents 8a4aeab + 5b4ac55 commit 62a35fd
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 63 deletions.
18 changes: 12 additions & 6 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const fs = require('fs');

// External dependencies
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const dotenv = require('dotenv');
const express = require('express');
const nunjucks = require('nunjucks');
Expand All @@ -23,6 +24,8 @@ const routes = require('./app/routes');
const documentationRoutes = require('./docs/documentation_routes');
const utils = require('./lib/utils');

const prototypeAdminRoutes = require('./middleware/prototype-admin-routes');

// Set configuration variables
const port = process.env.PORT || config.port;
const useDocumentation = process.env.SHOW_DOCS || config.useDocumentation;
Expand All @@ -42,11 +45,15 @@ app.locals.useAutoStoreData = (useAutoStoreData === 'true');
app.locals.useCookieSessionStore = (useCookieSessionStore === 'true');
app.locals.serviceName = config.serviceName;

// Use cookie middleware to parse cookies
app.use(cookieParser());

// Nunjucks configuration for application
const appViews = [
path.join(__dirname, 'app/views/'),
path.join(__dirname, 'node_modules/nhsuk-frontend/packages/components'),
path.join(__dirname, 'docs/views/'),
path.join(__dirname, 'lib/prototype-admin/'),
];

const nunjucksConfig = {
Expand All @@ -71,6 +78,9 @@ const sessionOptions = {
},
};

// Authentication
app.use(authentication);

// Support session data in cookie or memory
if (useCookieSessionStore === 'true' && !onlyDocumentation) {
app.use(sessionInCookie(Object.assign(sessionOptions, {
Expand Down Expand Up @@ -132,12 +142,6 @@ if (!sessionDataDefaultsFileExists) {
.pipe(fs.createWriteStream(sessionDataDefaultsFile));
}

// Check if the app is documentation only
if (onlyDocumentation !== 'true') {
// Require authentication if not
app.use(authentication);
}

// Local variables
app.use(locals(config));

Expand Down Expand Up @@ -213,6 +217,8 @@ app.post('/examples/passing-data/clear-data', (req, res) => {
res.render('examples/passing-data/clear-data-success');
});

app.use('/prototype-admin', prototypeAdminRoutes);

// Redirect all POSTs to GETs - this allows users to use POST for autoStoreData
app.post(/^\/([^.]+)$/, (req, res) => {
res.redirect(`/${req.params[0]}`);
Expand Down
10 changes: 5 additions & 5 deletions docs/views/how-tos/publish-your-prototype-online.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ <h2>Hosting services</h2>
<p>Some hosting services may automatically deploy your prototype every time you merge new changes in the connected GitHub repository. You may be able to enable automatic deploys. If it hasn't automatically update your prototype, you will need to do this manually.</p>

<h2>Setting a password</h2>
<p>When running the prototype kit online, you must set a username and password. This is to stop anyone accidentally finding your prototype and mistaking it for a real service.</p>
<p>When running the prototype kit online, you must set a password. This is to stop anyone accidentally finding your prototype and mistaking it for a real service.</p>
<p>To set a password, check your hosting services documentation on how to set "environment variables". (It may have a slightly different name like "config vars" or "variables".)</p>
<p>Set environment variables. For example:</p>
<p><code class="app-code">PROTOTYPE_USERNAME</code><code class="app-code">nameofservice</code></p>
<p><code class="app-code">PROTOTYPE_PASSWORD</code><code class="app-code">yourpassword123</code></p>
<p>The password should be set using the variable <code class="app-code">PROTOTYPE_PASSWORD</code>.</p>

<p>In Railway, you also need to create:</p>
<p>If you are using an older version of the prototype kit (before v4.12.0) you will also need to set a username using the variable <code class="app-code">PROTOTYPE_USERNAME</code>.</p>

<p>In Railway, you also need to create:</p>
<ul>
<li>a variable called <code class="app-code">NODE-ENV</code> and set the value to <code class="app-code">production</code></li>
<li>a variable called <code class="app-code">USE_AUTH</code> with the value of <code class="app-code">true</code></li>
Expand Down
58 changes: 58 additions & 0 deletions lib/prototype-admin/password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{% extends 'layout.html' %}

{% block pageTitle %}
{% if error == "wrong-password" %}
Error:
{% endif %}
Enter password - NHS prototype kit
{% endblock %}

{% block content %}

<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-two-thirds">
<form method="post" action="/prototype-admin/password">

{% if error == "wrong-password" %}
{{ errorSummary({
titleText: "There is a problem",
errorList: [
{
text: "The password is not correct",
href: "#password"
}
]
})}}
{% endif %}

<h1 class="nhsuk-heading-l">
This is a prototype used for research
</h1>

<p>
It is not a real service. You should only continue if you have been invited to test this prototype.
</p>

{{ input({
classes: "nhsuk-input--width-10",
name: "password",
id: "password",
type: "password",
errorMessage: {
text: "The password is not correct"
} if error == "wrong-password",
label:{
text: "Password"
}
}) }}

<input type="hidden" name="returnURL" value="{{returnURL}}">

{{ button({
text: "Continue"
}) }}

</form>
</div>
</div>
{% endblock %}
10 changes: 10 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// NPM dependencies
const { get: getKeypath } = require('lodash');
const path = require('path');
const crypto = require('crypto');

// Require core and custom filters, merges to one object
// and then add the methods to Nunjucks environment
Expand Down Expand Up @@ -310,3 +311,12 @@ exports.handleCookies = function (app) {
}
}
*/

function encryptPassword(password) {
if (!password) { return undefined; }
const hash = crypto.createHash('sha256');
hash.update(password);
return hash.digest('hex');
}

exports.encryptPassword = encryptPassword;
77 changes: 46 additions & 31 deletions middleware/authentication.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,51 @@
/**
* Simple basic auth middleware for use with Express 4.x.
*
* Based on template found at: http://www.danielstjules.com/2014/08/03/basic-auth-with-express-4/
*
* @example
* const authentication = required('authentication');
* app.use(authentication);
*
* @param {string} req Express Request object
* @param {string} res Express Response object
* @returns {function} Express 4 middleware requiring the given credentials
*/
// External dependencies
const basicAuth = require('basic-auth');
const url = require('url');
const { encryptPassword } = require('../lib/utils');

module.exports = function (req, res, next) { /* eslint-disable-line func-names,consistent-return */
// Set configuration variables
const env = (process.env.NODE_ENV || 'development').toLowerCase();
const username = process.env.PROTOTYPE_USERNAME;
const password = process.env.PROTOTYPE_PASSWORD;
const allowedPathsWhenUnauthenticated = [
'/prototype-admin/password',
'/css/main.css',
'/nhsuk-frontend/nhsuk.min.js',
'/js/auto-store-data.js',
'/js/jquery-3.5.1.min.js',
'/js/main.js',
];

if (env === 'production' || env === 'staging') {
if (!username || !password) {
return res.send('<p>Username or password not set in environment variables.</p>');
}
const encryptedPassword = encryptPassword(process.env.PROTOTYPE_PASSWORD);
const nodeEnv = process.env.NODE_ENV || 'development';

const user = basicAuth(req);
// Redirect the user to the password page, with
// the current page path set as the returnURL in a query
// string so the user can be redirected back after successfully
// entering a password
function sendUserToPasswordPage(req, res) {
const returnURL = url.format({
pathname: req.path,
query: req.query,
});
const passwordPageURL = url.format({
pathname: '/prototype-admin/password',
query: { returnURL },
});
res.redirect(passwordPageURL);
}

if (!user || user.name !== username || user.pass !== password) {
res.set('WWW-Authenticate', 'Basic realm=Authorization Required');
return res.sendStatus(401);
}
// Give the user some instructions on how to set a password
function showNoPasswordError(res) {
return res.send('<h1>Error:</h1><p>Password not set. <a href="https://nhsuk-prototype-kit.azurewebsites.net/docs/how-tos/publish-your-prototype-online">See guidance for setting a password</a>.</p>');
}

function authentication(req, res, next) {
if (nodeEnv !== 'production') {
next();
} else if (!process.env.PROTOTYPE_PASSWORD) {
showNoPasswordError(res);
} else if (allowedPathsWhenUnauthenticated.includes(req.path)) {
next();
} else if (req.cookies.authentication === encryptedPassword) {
next();
} else {
sendUserToPasswordPage(req, res);
}
next();
};
}

module.exports = authentication;
37 changes: 37 additions & 0 deletions middleware/prototype-admin-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const express = require('express');

const router = express.Router();

const password = process.env.PROTOTYPE_PASSWORD;

const { encryptPassword } = require('../lib/utils');

router.get('/password', (req, res) => {
const returnURL = req.query.returnURL || '/';
const { error } = req.query;
res.render('password', {
returnURL,
error,
});
});

// Check authentication password
router.post('/password', (req, res) => {
const submittedPassword = req.body.password;
const { returnURL } = req.body;

if (submittedPassword === password) {
// see lib/middleware/authentication.js for explanation
res.cookie('authentication', encryptPassword(password), {
maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days
sameSite: 'None', // Allows GET and POST requests from other domains
httpOnly: true,
secure: true,
});
res.redirect(returnURL);
} else {
res.redirect(`/prototype-admin/password?error=wrong-password&returnURL=${encodeURIComponent(returnURL)}`);
}
});

module.exports = router;
57 changes: 37 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
"dependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"browser-sync": "^3.0.2",
"client-sessions": "^0.8.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
Expand Down

0 comments on commit 62a35fd

Please sign in to comment.