Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create authentication plugin #351

Merged
merged 32 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
15b2767
Authentication plugin
StuAA78 Aug 11, 2023
2347f01
Install `@hapi/cookie` package
StuAA78 Aug 11, 2023
535fa43
First pass at plugin
StuAA78 Aug 11, 2023
b6cf4a1
Add TODO
StuAA78 Aug 11, 2023
041c3be
Use `server.dependency()` in plugin
StuAA78 Aug 11, 2023
c5c5d55
Directly add authentication to route
StuAA78 Aug 11, 2023
8220b44
Remove comment
StuAA78 Aug 11, 2023
6e6fe17
Add `COOKIE_SECRET` to CI workflow
StuAA78 Aug 22, 2023
be3915d
Create `AuthenticationConfig` file
StuAA78 Aug 22, 2023
891e8c2
Create db migrations
StuAA78 Aug 24, 2023
70758e1
Create models
StuAA78 Aug 25, 2023
81d3741
Merge branch 'main' into authentication-plugin
StuAA78 Aug 30, 2023
7091643
Comment typo
StuAA78 Aug 30, 2023
64d89b1
Merge branch 'main' into authentication-plugin
StuAA78 Sep 4, 2023
025005b
Rename required config
StuAA78 Sep 4, 2023
1ac0878
Add `userFound` flag to `FetchUserRolesAndGroupsService`
StuAA78 Sep 4, 2023
2b65b63
Remove `.only`
StuAA78 Sep 4, 2023
70310fd
Return `user` from `FetchUserRoles...` service
StuAA78 Sep 5, 2023
0fe0844
Correct `redirectTo` path in plugin
StuAA78 Sep 5, 2023
2ff7d3a
Add second test path
StuAA78 Sep 5, 2023
713f5db
Integrate `FetchUserRolesAndGroupsService` into plugin
StuAA78 Sep 5, 2023
418c227
Update docs
StuAA78 Sep 5, 2023
9fdedaa
Move auth logic into separate `AuthenticationService`
StuAA78 Sep 5, 2023
29f54f3
Add `TODO` to set default auth
StuAA78 Sep 5, 2023
dbd255e
Remove test paths
StuAA78 Sep 5, 2023
21cf899
Fix comment
StuAA78 Sep 5, 2023
e7759cd
Merge branch 'main' into authentication-plugin
StuAA78 Sep 5, 2023
d094495
Add `COOKIE_SECRET` to `.env.example`
StuAA78 Sep 6, 2023
b123425
Update app/services/plugins/authentication.service.js
StuAA78 Sep 7, 2023
c28156e
Fix JSdoc
StuAA78 Sep 7, 2023
1439f3b
Remove `TODO`
StuAA78 Sep 7, 2023
57f0ed6
Merge branch 'main' into authentication-plugin
StuAA78 Sep 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,9 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=

# Cookie secret for authenticating requests coming via the UI. Note that the value should be the same as `COOKIE_SECRET`
# on the UI side
COOKIE_SECRET=

# Feature flags
ENABLE_REISSUING_BILLING_BATCHES=false
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
POSTGRES_DB: wabs_system_test
POSTGRES_DB_TEST: wabs_system_test
ENVIRONMENT: dev
COOKIE_SECRET: cookiesecret


# Service containers to run with `runner-job`
Expand Down
66 changes: 66 additions & 0 deletions app/plugins/authentication.plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use strict'

/**
* Plugin to authenticate users
* @module AuthenticationPlugin
*/

const AuthenticationConfig = require('../../config/authentication.config.js')

const AuthenticationService = require('../services/plugins/authentication.service.js')

const TWO_HOURS_IN_MS = 2 * 60 * 60 * 1000

/**
* Some of our routes serve up pages, and are intended to be called from the UI via its `/system/` proxy path rather
* than being hit directly. We do not rely on the proxy authenticating requests first as there are some pages which we
* do not wish to have authentication on (eg. service status pages). We therefore authenticate pages on the System side,
* relying on an authenticated cookie being passed on by the UI. A request that is not authenticated is automatically
* redirected to the sign-in page.
*
* If the request is authenticated then we pass the user id along to AuthenticationService. This will give us a
* UserModel object, along with RoleModel and GroupModel objects representing the roles and groups the user is assigned
* to. These are all added to the request under request.auth.credentials.
*
* We also take the role names and add them to an array request.auth.credentials.scope. This scope array is used for
* authorisation.
*
* Routes can have 'scope' added to them via their options.auth.access.scope. This is an array of strings. If a route
* has a scope array then Hapi will check that request.auth.credentials.scope contains at least one of those strings,
* and reject the request with a 403 error if it doesn't. In other words, if we add a role name to a route's scope, we
* can ensure that only users with that role can access the route.
*
* More info on authorisation and scope can be found at https://hapi.dev/api/?v=21.3.2#-routeoptionsauthaccessscope
*
* TODO: Add a line to the plugin to set `session` as the default auth strategy, ie:
*
* server.auth.default('session')
*
* Since this is applied to all routes by default, any routes which don't require authentication (eg. service status)
* will need to have its options.auth.strategy set to `false`.
*/

const AuthenticationPlugin = {
name: 'authentication',
register: async (server, _options) => {
// We wait for @hapi/cookie to be registered before setting up the authentication strategy
server.dependency('@hapi/cookie', async (server) => {
server.auth.strategy('session', 'cookie', {
cookie: {
name: 'sid',
password: AuthenticationConfig.password,
isSecure: false,
isSameSite: 'Lax',
ttl: TWO_HOURS_IN_MS,
isHttpOnly: true
},
redirectTo: '/signin',
validate: async (_request, session) => {
return AuthenticationService.go(session.userId)
}
})
})
}
}

module.exports = AuthenticationPlugin
3 changes: 3 additions & 0 deletions app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const Hapi = require('@hapi/hapi')

const AirbrakePlugin = require('./plugins/airbrake.plugin.js')
const AuthenticationPlugin = require('./plugins/authentication.plugin.js')
const BlippPlugin = require('./plugins/blipp.plugin.js')
const ChargingModuleTokenCachePlugin = require('./plugins/charging-module-token-cache.plugin.js')
const ErrorPagesPlugin = require('./plugins/error-pages.plugin.js')
Expand All @@ -21,6 +22,8 @@ const registerPlugins = async (server) => {
// Register the remaining plugins
await server.register(StopPlugin)
await server.register(require('@hapi/inert'))
await server.register(AuthenticationPlugin)
await server.register(require('@hapi/cookie'))
await server.register(RouterPlugin)
await server.register(HapiPinoPlugin())
await server.register(AirbrakePlugin)
Expand Down
20 changes: 18 additions & 2 deletions app/services/idm/fetch-user-roles-and-groups.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ const UserModel = require('../../models/idm/user.model.js')
* This service looks up the user in the `idm` (identity management) schema and returns a combined array of all roles
* (deduped in case the user is given the same role multiple times; for example, by being assigned a role directly, then
* later added to a group which also includes that role). It also returns an array of groups that the user is a member
* of.
* of, along with `userFound` to explicitly indicate whether or not the user id exists.
*
* @param {Number} userId The user id to get roles and groups for
*
* @returns {Object} result The resulting roles and groups
* @returns {UserModel} result.user Returns the UserModel representing the user, or `null` if the user is not found
* @returns {RoleModel[]} result.roles An array of RoleModel objects representing the roles the user has
* @returns {GroupModel[]} result.groups An array of GroupModel objects representing the groups the user is a member of
*/
Expand All @@ -31,21 +32,36 @@ async function go (userId) {

if (!user) {
return {
user: null,
roles: [],
groups: []
}
}

const { groups, roles } = user
const { roles, groups } = _extractRolesAndGroupsFromUser(user)
const rolesFromGroups = _extractRolesFromGroups(groups)
const combinedAndDedupedRoles = _combineAndDedupeRoles([...roles, ...rolesFromGroups])

return {
user,
roles: combinedAndDedupedRoles,
groups
}
}

/**
* The user object we get back from the query has the roles and groups attached to it. The service returns the user,
* roles and groups separately so we remove the roles and groups from the user object so we aren't returning the same
* data twice.
*/
function _extractRolesAndGroupsFromUser (user) {
const { roles, groups } = user
delete user.roles
delete user.groups

return { roles, groups }
}

/**
* The roles that the user is assigned to via groups are returned from our main query within the user's Group objects.
* We want to extract the roles and remove them from the groups in order to keep the Group objects clean and avoid
Expand Down
43 changes: 43 additions & 0 deletions app/services/plugins/authentication.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict'

/**
* Used by `AuthenticationPlugin` to authenticate and authorise users
* @module AuthenticationService
*/

const FetchUserRolesAndGroupsService = require('../idm/fetch-user-roles-and-groups.service.js')

/**
* This service is intended to be used by our `AuthenticationPlugin` to authenticate and authorise users.
*
* We take a user id and look it up in the `idm` schema using `FetchUserRolesAndGroupsService`. This gives us a user
* object along with arrays of role objects and group objects that the user has been assigned to.
*
* We return an object that indicates whether the user is valid (based on whether the user exists), along with user
* information, their roles and their groups. We also return a "scope" array of strings, which correspond to the names
* of the roles the user has. This is used by hapi when authorising the user against a route; if the route has a scope
* array of strings then the user's scope array must contain at least one of the strings.
*
* @param {Number} userId The user id to be authenticated
* @returns {Object} response
* @returns {Boolean} response.isValid Indicates whether the user was found
* @returns {Object} response.credentials User credentials found in the IDM
* @returns {UserModel} response.credentials.user Object representing the user
* @returns {RoleModel[]} response.credentials.roles Objects representing the roles the user has
* @returns {GroupModel[]} response.credentials.groups Objects representing the groups the user is assigned to
* @returns {String[]} response.credentials.scope The names of the roles the user has, for route authorisation purposes
*/
async function go (userId) {
const { user, roles, groups } = await FetchUserRolesAndGroupsService.go(userId)

// We put each role's name into the scope array for hapi to use for its scope authorisation
const scope = roles.map((role) => {
return role.role
})

return { isValid: !!user, credentials: { user, roles, groups, scope } }
}

module.exports = {
go
}
16 changes: 16 additions & 0 deletions config/authentication.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use strict'

/**
* Config values used for cookie authentication
* @module AuthenticationConfig
*/

// We require dotenv directly in each config file to support unit tests that depend on this this subset of config.
// Requiring dotenv in multiple places has no effect on the app when running for real.
require('dotenv').config()

const config = {
password: process.env.COOKIE_SECRET
}

module.exports = config
Loading