Skip to content

Commit

Permalink
Merge pull request #2369 from alphagov/support-multiple-passwords
Browse files Browse the repository at this point in the history
Support multiple passwords
  • Loading branch information
BenSurgisonGDS authored Nov 13, 2023
2 parents 00b8a82 + 349c147 commit 7cf4b01
Show file tree
Hide file tree
Showing 10 changed files with 51 additions and 20 deletions.
22 changes: 20 additions & 2 deletions cypress/e2e/prod/2-management-tests/password-page.cypress.js
Original file line number Diff line number Diff line change
@@ -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)
})
Expand All @@ -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)
})
})
)
})
4 changes: 4 additions & 0 deletions cypress/events/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions lib/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,21 @@ 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') ||
req.path.startsWith('/plugin-assets/govuk-prototype-kit') ||
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)
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/authentication.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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', () => {
Expand Down
9 changes: 8 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ describe('config', () => {
isProduction: false,
isDevelopment: false,
isTest: true,
passwordKeys: '',
passwords: [],
onGlitch: false,
useNjkExtensions: false,
logPerformance: false,
Expand Down
6 changes: 3 additions & 3 deletions lib/manage-prototype-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/manage-prototype-handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion npm-shrinkwrap.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 @@ -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",
Expand Down

0 comments on commit 7cf4b01

Please sign in to comment.