Skip to content

Commit

Permalink
Support multiple passwords
Browse files Browse the repository at this point in the history
  • Loading branch information
BenSurgisonGDS committed Nov 9, 2023
1 parent 9afd727 commit 9a02866
Show file tree
Hide file tree
Showing 10 changed files with 49 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)
})
})
)
})
2 changes: 1 addition & 1 deletion cypress/e2e/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const log = (message) => cy.task('log', message)

const authenticate = () => {
const password = Cypress.env('password')
const password = Cypress.env('password')
if (password) {
log(`Authenticating with ${password}`)
cy.get('input#password').type(password)
Expand Down
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.ADDITIONAL_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
7 changes: 6 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,18 @@ 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('additionalPasswordKeys', 'ADDITIONAL_PASSWORD_KEYS', asString, '')

if (config.serviceName === undefined) {
config.serviceName = 'GOV.UK Prototype Kit'
}

config.passwords = (config.additionalPasswordKeys.split(','))
.map(passwordKey => process.env[passwordKey.trim()])
.filter(password => !!password)

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,
additionalPasswordKeys: '',
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 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 ADDITIONAL_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 9a02866

Please sign in to comment.