Skip to content

Commit

Permalink
Merge ab76407 into 5ece1a1
Browse files Browse the repository at this point in the history
  • Loading branch information
nour-borgi authored Mar 24, 2023
2 parents 5ece1a1 + ab76407 commit 927a69f
Show file tree
Hide file tree
Showing 50 changed files with 7,092 additions and 5,112 deletions.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"es6": true,
"node": true
},
"parser": "@babel/eslint-parser",
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module"
Expand Down
13 changes: 12 additions & 1 deletion config/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ The following config option are provided by the OpenHIM. All of these options ha
"timeout": 60000
},
"api": {
// The session secret key used for the hashing of signed cookie (used to detect if the client modified the cookie)
// Signed cookie is another cookie of the same name with the .sig suffix appended
"sessionKey": "r8q,+&1LM3)CD*zAGpx1xm{NeQhc;#",
// The session max age is the session cookie expiration time (in milliseconds)
"maxAge": 7200000,
// The number of characters that will be used to generate a random salt for the encryption of passwords
"salt": 10,
// The port that the OpenHIM API uses
"port": 8080,
// The protocol that the OpenHIM API uses
Expand All @@ -60,7 +67,11 @@ The following config option are provided by the OpenHIM. All of these options ha
// A message to append to detail strings that have been truncated
"truncateAppend": "\n[truncated ...]",
// The types of authentication to use for the API
// Supported types are "token" and "basic"
// Supported types are "token" and "basic" and "local"
// * "local" means through the UI with hitting "/authentication/local" endpoint with username and password,
// this will create a session for the user and set cookies in the browser.
// * "basic" means with basic auth either through browser or postman by giving also username and password.
// * [Deprecated] "token" means that a request should provide in the header an 'auth-token', 'auth-salt' and 'auth-ts' to be authenticated.
"authenicationTypes": ["token"]
},
"rerun": {
Expand Down
5 changes: 4 additions & 1 deletion config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"pollPeriodMins": 60
},
"api": {
"sessionKey": "r8q,+&1LM3)CD*zAGpx1xm{NeQhc;#",
"maxAge": 7200000,
"salt": 10,
"enabled": true,
"protocol": "https",
"port": 8080,
Expand All @@ -39,7 +42,7 @@
"maxPayloadSizeMB": 50,
"truncateSize": 15000,
"truncateAppend": "\n[truncated ...]",
"authenticationTypes": ["basic", "token"]
"authenticationTypes": ["basic", "local", "token"]
},
"rerun": {
"httpPort": 7786,
Expand Down
2 changes: 1 addition & 1 deletion config/test.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"maxPayloadSizeMB": 50,
"truncateSize": 10,
"truncateAppend": "\n[truncated ...]",
"authenticationTypes": ["token", "basic"]
"authenticationTypes": ["token", "basic", "local"]
},
"caching": {
"enabled": false
Expand Down
5,585 changes: 2,769 additions & 2,816 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"test:seed": "node performance/seed.js",
"test:seed:ci": "npm run test:seed -- --quiet",
"start": "node lib/server.js",
"start:dev": "nodemon lib/server.js",
"stop": "pkill -SIGINT Core",
"spec": "speculate"
},
Expand All @@ -56,8 +57,11 @@
"kcors": "2.2.2",
"koa": "^2.13.0",
"koa-bodyparser": "^4.3.0",
"koa-compose": "^4.1.0",
"koa-compress": "^5.1.0",
"koa-passport": "^4.0.0",
"koa-route": "3.2.0",
"koa-session": "^6.3.1",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"moment-timezone": "^0.5.31",
Expand All @@ -67,6 +71,9 @@
"mongoose-patch-history": "^2.0.0",
"nconf": "0.10.0",
"nodemailer": "^6.6.3",
"passport-custom": "^1.1.1",
"passport-http": "^0.3.0",
"passport-local": "^1.0.0",
"pem": "^1.14.4",
"raw-body": "^2.4.1",
"semver": "^7.3.2",
Expand All @@ -81,6 +88,7 @@
"devDependencies": {
"@babel/cli": "^7.15.4",
"@babel/core": "^7.15.5",
"@babel/eslint-parser": "^7.19.1",
"@babel/preset-env": "^7.15.6",
"@babel/register": "^7.15.3",
"codecov": "^3.8.3",
Expand All @@ -94,6 +102,7 @@
"faker": "^5.5.3",
"finalhandler": "^1.1.2",
"mocha": "^8.4.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"prettier": "^2.4.0",
"progress": "2.0.3",
Expand Down
218 changes: 73 additions & 145 deletions src/api/authentication.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
'use strict'

import atna from 'atna-audit'
import basicAuth from 'basic-auth'
import crypto from 'crypto'
import logger from 'winston'
import os from 'os'

import * as auditing from '../auditing'
import * as authorisation from './authorisation'
import {UserModelAPI} from '../model/users'
import {caseInsensitiveRegex, logAndSetResponse} from '../utils'
import passport from '../passport'
import {logAndSetResponse} from '../utils'
import {config} from '../config'
import {
BASIC_AUTH_TYPE,
Expand All @@ -36,108 +34,26 @@ const auditingExemptPaths = [
/\/logs/
]

const isUndefOrEmpty = string => string == null || string === ''

async function authenticateBasic(ctx) {
const credentials = basicAuth(ctx)
if (credentials == null) {
// No basic auth details found
return null
}
const {name: email, pass: password} = credentials
const user = await UserModelAPI.findOne({
email: caseInsensitiveRegex(email)
})
if (user == null) {
// not authenticated - user not found
ctx.throw(
401,
`No user exists for ${email}, denying access to API, request originated from ${ctx.request.host}`,
{email}
)
}
// Basic auth using middleware
await passport.authenticate('basic', function (err, user) {
if (user) {
ctx.req.user = user
ctx.body = 'User Authenticated Successfully'
ctx.status = 200
}
})(ctx, () => {})

const hash = crypto.createHash(user.passwordAlgorithm)
hash.update(user.passwordSalt)
hash.update(password)
if (user.passwordHash !== hash.digest('hex')) {
// not authenticated - password mismatch
ctx.throw(
401,
`Password did not match expected value, denying access to API, the request was made by ${email} from ${ctx.request.host}`,
{email}
)
}
return user
return ctx.req.user || null
}

/**
* @deprecated
*/
async function authenticateToken(ctx) {
const {header} = ctx.request
const email = header['auth-username']
const authTS = header['auth-ts']
const authSalt = header['auth-salt']
const authToken = header['auth-token']
await passport.authenticate('token')(ctx, () => {})

// if any of the required headers aren't present
if (
isUndefOrEmpty(email) ||
isUndefOrEmpty(authTS) ||
isUndefOrEmpty(authSalt) ||
isUndefOrEmpty(authToken)
) {
ctx.throw(
401,
`API request made by ${email} from ${ctx.request.host} is missing required API authentication headers, denying access`,
{email}
)
}

// check if request is recent
const requestDate = new Date(Date.parse(authTS))

const authWindowSeconds =
config.api.authWindowSeconds != null ? config.api.authWindowSeconds : 10
const to = new Date()
to.setSeconds(to.getSeconds() + authWindowSeconds)
const from = new Date()
from.setSeconds(from.getSeconds() - authWindowSeconds)

if (requestDate < from || requestDate > to) {
// request expired
ctx.throw(
401,
`API request made by ${email} from ${ctx.request.host} has expired, denying access`,
{email}
)
}

const user = await UserModelAPI.findOne({
email: caseInsensitiveRegex(email)
})
if (user == null) {
// not authenticated - user not found
ctx.throw(
401,
`No user exists for ${email}, denying access to API, request originated from ${ctx.request.host}`,
{email}
)
}

const hash = crypto.createHash('sha512')
hash.update(user.passwordHash)
hash.update(authSalt)
hash.update(authTS)

if (authToken !== hash.digest('hex')) {
// not authenticated - token mismatch
ctx.throw(
401,
`API token did not match expected value, denying access to API, the request was made by ${email} from ${ctx.request.host}`,
{email}
)
}

return user
return ctx.req.user || null
}

function getEnabledAuthenticationTypesFromConfig(config) {
Expand All @@ -160,20 +76,26 @@ function getEnabledAuthenticationTypesFromConfig(config) {
return []
}

function isAuthenticationTypeEnabled(type) {
export function isAuthenticationTypeEnabled(type) {
return getEnabledAuthenticationTypesFromConfig(config).includes(type)
}

async function authenticateRequest(ctx) {
let user
// First attempt basic authentication if enabled
if (user == null && isAuthenticationTypeEnabled('basic')) {
user = await authenticateBasic(ctx)
let user = null

// First attempt local authentication if enabled
if (ctx.req.user) {
user = ctx.req.user
}
// Otherwise try token based authentication if enabled
if (user == null && isAuthenticationTypeEnabled('token')) {
// Otherwise try token based authentication if enabled (@deprecated)
if (user == null) {
user = await authenticateToken(ctx)
}
// Otherwise try basic based authentication if enabled
if (user == null) {
// Basic auth using middleware
user = await authenticateBasic(ctx)
}
// User could not be authenticated
if (user == null) {
const enabledTypes =
Expand All @@ -195,9 +117,50 @@ function handleAuditResponse(err) {
}

export async function authenticate(ctx, next) {
let user
try {
user = await authenticateRequest(ctx)
// Authenticate Request either by basic or local or token
const user = await authenticateRequest(ctx)

if (ctx.isAuthenticated()) {
// Set the user on the context for consumption by other middleware
ctx.authenticated = user

// Deal with paths exempt from audit
if (ctx.path === '/transactions') {
if (
!ctx.query.filterRepresentation ||
ctx.query.filterRepresentation !== 'full'
) {
// exempt from auditing success
return next()
}
} else {
for (const pathTest of auditingExemptPaths) {
if (pathTest.test(ctx.path)) {
// exempt from auditing success
return next()
}
}
}
// Send an auth success audit event
let audit = atna.construct.userLoginAudit(
atna.constants.OUTCOME_SUCCESS,
himSourceID,
os.hostname(),
ctx.authenticated.email,
ctx.authenticated.groups.join(','),
ctx.authenticated.groups.join(',')
)
audit = atna.construct.wrapInSyslog(audit)
auditing.sendAuditEvent(audit, handleAuditResponse)

return next()
} else {
ctx.throw(
401,
`Denying access for an API request from ${ctx.request.host}`
)
}
} catch (err) {
// Handle authentication errors
if (err.status === 401) {
Expand All @@ -210,7 +173,7 @@ export async function authenticate(ctx, next) {
atna.constants.OUTCOME_SERIOUS_FAILURE,
himSourceID,
os.hostname(),
err.email
`Unknown with ip ${ctx.request.ip}`
)
audit = atna.construct.wrapInSyslog(audit)
auditing.sendAuditEvent(audit, handleAuditResponse)
Expand All @@ -219,41 +182,6 @@ export async function authenticate(ctx, next) {
// Rethrow other errors
throw err
}

// Set the user on the context for consumption by other middleware
ctx.authenticated = user

// Deal with paths exempt from audit
if (ctx.path === '/transactions') {
if (
!ctx.query.filterRepresentation ||
ctx.query.filterRepresentation !== 'full'
) {
// exempt from auditing success
return next()
}
} else {
for (const pathTest of auditingExemptPaths) {
if (pathTest.test(ctx.path)) {
// exempt from auditing success
return next()
}
}
}

// Send an auth success audit event
let audit = atna.construct.userLoginAudit(
atna.constants.OUTCOME_SUCCESS,
himSourceID,
os.hostname(),
user.email,
user.groups.join(','),
user.groups.join(',')
)
audit = atna.construct.wrapInSyslog(audit)
auditing.sendAuditEvent(audit, handleAuditResponse)

return next()
}

export async function getEnabledAuthenticationTypes(ctx, next) {
Expand Down
Loading

0 comments on commit 927a69f

Please sign in to comment.