diff --git a/cypress/e2e/prod/2-management-tests/password-page.cypress.js b/cypress/e2e/prod/2-management-tests/password-page.cypress.js index b0a761117b..19b8755576 100644 --- a/cypress/e2e/prod/2-management-tests/password-page.cypress.js +++ b/cypress/e2e/prod/2-management-tests/password-page.cypress.js @@ -1,19 +1,22 @@ -const { restoreStarterFiles } = require('../../utils') +const { restoreStarterFiles, log } = require('../../utils') const homePath = '/index' const passwordPath = '/manage-prototype/password' const errorQuery = 'error=wrong-password' const returnURLQuery = `returnURL=${encodeURIComponent(homePath)}` +const additionalPasswords = Cypress.env('additionalPasswords') || [] describe('password page', () => { after(restoreStarterFiles) it('valid password', () => { + const password = Cypress.env('password') cy.task('waitUntilAppRestarts') cy.visit(homePath) cy.url().then(passwordUrl => { const urlObject = new URL(passwordUrl) expect(passwordUrl).equal(`${urlObject.origin + passwordPath}?${returnURLQuery}`) - cy.get('input#password').type(Cypress.env('password')) + log(`Authenticating with ${password}`) + cy.get('input#password').type(password) cy.get('form').submit() cy.url().should('eq', urlObject.origin + homePath) }) @@ -32,4 +35,19 @@ describe('password page', () => { cy.url().should('eq', `${urlObject.origin + passwordPath}?${errorQuery}&${returnURLQuery}`) }) }) + + additionalPasswords.map(password => + it(`valid additional password "${password}"`, () => { + cy.task('waitUntilAppRestarts') + cy.visit(homePath) + cy.url().then(passwordUrl => { + const urlObject = new URL(passwordUrl) + expect(passwordUrl).equal(`${urlObject.origin + passwordPath}?${returnURLQuery}`) + log(`Authenticating with ${password}`) + cy.get('input#password').type(password) + cy.get('form').submit() + cy.url().should('eq', urlObject.origin + homePath) + }) + }) + ) }) diff --git a/cypress/events/index.js b/cypress/events/index.js index 5c7bb6379c..2161b7d4c7 100644 --- a/cypress/events/index.js +++ b/cypress/events/index.js @@ -61,6 +61,10 @@ module.exports = function setupNodeEvents (on, config) { // `config` is the resolved Cypress config config.env.password = process.env.PASSWORD + config.env.additionalPasswords = (process.env.PASSWORD_KEYS || '') + .split(',') + .map(passwordKey => process.env[passwordKey.trim()]) + .filter(password => !!password) config.env.projectFolder = path.resolve(process.env.KIT_TEST_DIR || process.cwd()) config.env.tempFolder = path.join(__dirname, '..', 'temp') config.env.skipPluginActionInterimStep = process.env.SKIP_PLUGIN_ACTION_INTERIM_STEP diff --git a/lib/authentication.js b/lib/authentication.js index 50d837006f..6a40b24a4e 100644 --- a/lib/authentication.js +++ b/lib/authentication.js @@ -22,18 +22,13 @@ function authentication () { } } - if (!config.getConfig().password) { + if (!config.getConfig().passwords.length) { // show errors return (req, res) => { showNoPasswordError(res) } } - // password is encrypted because we store it in a cookie - // we store the password to compare in case it is changed server-side - // changing the password should require users to re-authenticate - const password = encryptPassword(config.getConfig().password) - return (req, res, next) => { if (allowedPathsWhenUnauthenticated.includes(req.path) || req.path.startsWith('/manage-prototype/dependencies') || @@ -41,7 +36,7 @@ function authentication () { req.path === '/public/stylesheets/manage-prototype.css' ) { next() - } else if (isAuthenticated(password, req)) { + } else if (isAuthenticated(config.getConfig().passwords, req)) { next() } else { sendUserToPasswordPage(req, res) @@ -80,8 +75,13 @@ function sendUserToPasswordPage (req, res) { res.redirect(passwordPageURL) } -function isAuthenticated (encryptedPassword, req) { - return req.cookies.authentication === encryptedPassword +function isAuthenticated (passwords, req) { + // password is encrypted because we store it in a cookie + // we store the password to compare in case it is changed server-side + // changing the password should require users to re-authenticate + + // Make sure the password matches any of the allowed passwords in the config + return passwords.some(password => req.cookies.authentication === encryptPassword(password)) } module.exports = authentication diff --git a/lib/authentication.test.js b/lib/authentication.test.js index 3ad834bfad..5864c4ae43 100644 --- a/lib/authentication.test.js +++ b/lib/authentication.test.js @@ -58,11 +58,11 @@ describe('authentication', () => { beforeEach(() => { testScope.appConfig.isProduction = true testScope.appConfig.useAuth = true + testScope.appConfig.passwords = [] }) describe('server with no password set', () => { beforeEach(() => { - delete testScope.appConfig.password // Jest mocks stores each call to the mocked function // so we want to clear them before running the authentication again. console.error.mockClear() @@ -83,7 +83,7 @@ describe('authentication', () => { describe('server with password set', () => { beforeEach(() => { - testScope.appConfig.password = userPassword + testScope.appConfig.passwords = [userPassword] }) describe('when a user is not authenticated', () => { diff --git a/lib/config.js b/lib/config.js index 5f7a1169f1..f06da2bd4e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -14,6 +14,7 @@ const { isString } = require('lodash') const { appDir } = require('./utils/paths') const appConfigPath = path.join(appDir, 'config.json') +const validEnvironmentVariableRegex = /^[_a-zA-Z][_a-zA-Z0-9]*$/ function getConfigFromFile (swallowError = true) { const configFileExists = fse.existsSync(appConfigPath) @@ -96,13 +97,19 @@ function getConfig (config, swallowError = true) { overrideOrDefault('verbose', 'VERBOSE', asBoolean, false) overrideOrDefault('showPrereleases', 'SHOW_PRERELEASES', asBoolean, false) overrideOrDefault('allowGovukFrontendUninstall', 'ALLOW_GOVUK_FRONTEND_UNINSTALL', asBoolean, false) + overrideOrDefault('passwordKeys', 'PASSWORD_KEYS', asString, '') if (config.serviceName === undefined) { config.serviceName = 'GOV.UK Prototype Kit' } + config.passwords = (config.passwordKeys.split(',')) + .map(passwordKey => passwordKey.trim()) + .filter(passwordKey => validEnvironmentVariableRegex.test(passwordKey) && !!process.env[passwordKey]) + .map(passwordKey => process.env[passwordKey]) + if (process.env.PASSWORD) { - config.password = process.env.PASSWORD + config.passwords.push(process.env.PASSWORD) } return config diff --git a/lib/config.test.js b/lib/config.test.js index d7f13e8099..d9fd591ac9 100644 --- a/lib/config.test.js +++ b/lib/config.test.js @@ -29,6 +29,8 @@ describe('config', () => { isProduction: false, isDevelopment: false, isTest: true, + passwordKeys: '', + passwords: [], onGlitch: false, useNjkExtensions: false, logPerformance: false, diff --git a/lib/manage-prototype-handlers.js b/lib/manage-prototype-handlers.js index 7adedd3719..6351767d5a 100644 --- a/lib/manage-prototype-handlers.js +++ b/lib/manage-prototype-handlers.js @@ -108,14 +108,14 @@ function getPasswordHandler (req, res) { // Check authentication password function postPasswordHandler (req, res) { - const password = config.getConfig().password + const passwords = config.getConfig().passwords const submittedPassword = req.body.password const providedUrl = req.body.returnURL const processedRedirectUrl = (!providedUrl || providedUrl.startsWith('/manage-prototype/password')) ? '/' : providedUrl - if (submittedPassword === password) { + if (passwords.some(password => submittedPassword === password)) { // see lib/middleware/authentication.js for explanation - res.cookie('authentication', encryptPassword(password), { + res.cookie('authentication', encryptPassword(submittedPassword), { maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days sameSite: 'None', // Allows GET and POST requests from other domains httpOnly: true, diff --git a/lib/manage-prototype-handlers.test.js b/lib/manage-prototype-handlers.test.js index a719a834ef..c364b800ed 100644 --- a/lib/manage-prototype-handlers.test.js +++ b/lib/manage-prototype-handlers.test.js @@ -190,7 +190,7 @@ describe('manage-prototype-handlers', () => { describe('postPasswordHandler', () => { beforeEach(() => { - jest.spyOn(config, 'getConfig').mockImplementation(() => ({ password: 'password' })) + jest.spyOn(config, 'getConfig').mockImplementation(() => ({ passwords: ['password'] })) }) it('correct password', () => { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 478baba794..ad20a2319e 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -56,7 +56,7 @@ "wait-on": "^7.0.1" }, "engines": { - "node": "^18.x || >= 20.x" + "node": "^16.x || ^18.x || >= 20.x" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index 033ab13701..d85da9bbb5 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test:heroku": "cross-env KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test:heroku 3000 cypress:e2e:smoke", "test:acceptance": "npm run test:acceptance:dev && npm run test:acceptance:prod && npm run test:acceptance:smoke && npm run test:acceptance:styles && npm run test:acceptance:plugins && npm run test:acceptance:errors", "test:acceptance:dev": "cross-env KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test 3000 cypress:e2e:dev", - "test:acceptance:prod": "cross-env PASSWORD=password KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test:prod 3000 cypress:e2e:prod", + "test:acceptance:prod": "cross-env PASSWORD=password PASSWORD_KEYS=PASSWORD_01,PASSWORD_02 PASSWORD_01=p1 PASSWORD_02=p2 KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test:prod 3000 cypress:e2e:prod", "test:acceptance:smoke": "cross-env KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test 3000 cypress:e2e:smoke", "test:acceptance:styles": "cross-env KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test 3000 cypress:e2e:styles", "test:acceptance:plugins": "cross-env KIT_TEST_DIR=tmp/test-prototype start-server-and-test start:test 3000 cypress:e2e:plugins",