adaptorex
Version:
Connect all your live interactive storytelling devices and software
358 lines (318 loc) • 10.9 kB
JavaScript
/**
* API authentication types
* @requires lodash
*
* @module authentication
* @copyright Lasse Marburg 2021
* @license MIT
*/
const lodash = require("lodash")
class Auth {
constructor() {}
/**
* Move socket auth credentials to request object and forward to auth method
*
* @todo rewrite this to use cookie and/or sessionStorage and/or localStorage tokens from client instead of requiring username and password everytime
*
* @param {Object} socket - socket connection object
* @param {function} next - call next middleware
*/
authenticateSocket(socket, next) {
if (socket.handshake.auth) {
socket.request.originalUrl = socket.handshake.url
if (socket.handshake.auth.user && socket.handshake.auth.password) {
let user = socket.handshake.auth.user
let password = socket.handshake.auth.password
log.trace(
0,
`socket auth with user ${user} for namespace ${socket.nsp.name}`
)
// log.trace(0, "user:password",user, password, socket.request.headers);
socket.request.headers.authorization =
"Basic " + Buffer.from(user + ":" + password).toString("base64")
} else if (socket.handshake.auth.token) {
log.trace(0, `socket auth via token for namespace ${socket.nsp.name}`)
socket.request.headers.authorization =
"Basic " + socket.handshake.auth.token
}
}
socket.request["namespace"] = socket.nsp.name
this.authenticate(socket.request, {}, next)
}
/**
* Find a user by their login name and return a shallow copy of that user without their password.
* If the user is not found, throw an adaptor.NotFoundError.
* If any other user has a role or games and the user has no role defined, their role is set to "viewer".
* If any other user has games and the user has no games, their games is set to an empty array.
* If no users have a role or games, the users role is set to "admin"
*
* @param {Object} config - Configuration object
* @param {string} username - Username to search for
* @throws {adaptor.NotFoundError}
* @returns {Object} shallow copy of the user object without password
*/
getUser(config, username) {
if (config.authentication != "basic") {
return this.default_user
}
if (!config.hasOwnProperty("users") || !config.users.length) {
throw new adaptor.NotFoundError("No users configured.")
}
if (!username) {
throw new adaptor.NotFoundError("User required but could not be found.")
}
let user = lodash.cloneDeep(config.users.find((u) => u.login === username))
if (!user) {
throw new adaptor.NotFoundError("User could not be found.")
}
delete user.password
/**
* Is true if any other user has a role.
*/
let user_roles = config.users.some((u) => {
return u.hasOwnProperty("role")
})
/**
* Is true if any other user has games defined.
*/
let user_games = config.users.some((u) => {
return u.hasOwnProperty("games")
})
if (!user.role) {
if (user_roles || user_games) {
user.role = "viewer"
} else {
user.role = "admin"
}
}
if (user_games && !user.games) {
user.games = []
}
return user
}
}
class NoAuth extends Auth {
constructor(default_user) {
super()
this.default_user = default_user
log.info(0, "Authentication disabled")
}
authenticate(req, res, next) {
req.user = this.default_user
next()
}
}
class BasicAuth extends Auth {
/**
* @param {Array} users - list of authorized users
*/
constructor(users) {
super()
if (!Array.isArray(users) || (Array.isArray(users) && !users.length)) {
log.warn(
0,
"Basic Authentication is enabled but there are no users that are authorized to connect to adaptor."
)
this.users = []
} else {
this.users = users
}
log.info(0, "Basic Authentication enabled")
}
/**
* authentication middleware function for express routing.
*
* respond with www-authenticate necessity. Only pass if request header contains valid authentication
*
* parse login and password from headers
* match against users list
*
* Verify if login and password are set and match credentials of an existing user
*
* User example:
* @example
* "users": [
* {
* "login": "my_user",
* "password": "abcd1234"
* }
* ]
*
* Authentication can also fail if user does not have access to the requested resource.
* Either because users role does not match the requested resource or the user does not have access rights to the game.
* @see {@link https://gitlab.com/machina_ex/adaptor_ex/adaptor_ex_server/-/blob/main/README.md?ref_type=heads#secure-with-user-authentication}
*
* @todo add Set-Cookie with Session Auth or create Session Token or similar
*
* @param {Object} req - request Object
* @param {Object} res - response Object
* @param {function} next - express next function
* @returns {undefined} Returns prematurely if authentication failed and skips next request handlings
*/
authenticate(req, res, next) {
const b64auth = (req.headers.authorization || "").split(" ")[1] || ""
const [login, password] = Buffer.from(b64auth, "base64")
.toString()
.split(":")
if (!login || !password) {
log.warn(
0,
`Authentication failed for ${req.method} request to ${req.originalUrl}. Missing login credentials. See trace log for details.`
)
log.trace("Request headers", req.headers)
//res.set('WWW-Authenticate', 'Basic realm="401"')
if (typeof res.status === "function") {
res.status(401).send({
name: "AuthenticationError",
message:
"Authentication enabled but credentials are missing. See Readme on how to configure authentication for your adaptor server."
})
}
return
}
const user = this.users.find((u) => {
return u.login == login
})
if (!user || login !== user.login || password !== user.password) {
log.warn(
0,
`Authentication failed for ${req.method} request to ${req.originalUrl}, user: '${login}'. See trace log for details.`
)
let headers = { ...req.headers }
headers.authorization = "hidden"
log.trace("Request headers", headers)
//res.set('WWW-Authenticate', 'Basic realm="401"')
if (typeof res.status === "function") {
res.status(401).send({
name: "AuthenticationError",
message:
"Authentication failed. If credentials are correct, see Readme on how to configure authentication for your adaptor server."
})
}
return
}
let user_roles = this.users.some((u) => {
return u.hasOwnProperty("role")
})
let user_games = this.users.some((u) => {
return u.hasOwnProperty("games")
})
if (user_roles || user_games) {
let security = []
let role = user.role
let game
if (!role) {
role = "viewer"
}
if (req.path) {
const docs = this.getDocs(adaptor.openapi.paths, req.path)
game = docs.game
if (docs.doc) {
if (docs.doc.hasOwnProperty(req.method.toLowerCase())) {
if (docs.doc[req.method.toLowerCase()].hasOwnProperty("security")) {
security = docs.doc[req.method.toLowerCase()].security
}
}
} else {
log.warn(
0,
`Could not match the request with any existing API specification`
)
}
} else if (req.namespace) {
const docs = this.getDocs(adaptor.asyncapi.channels, req.namespace)
game = docs.game
if (docs.doc) {
if (docs.doc.hasOwnProperty("security")) {
security = docs.doc.security
}
}
} else {
log.warn(0, `Incoming request without path or namespace property`)
}
if (game && user_games) {
if (!Array.isArray(user.games) && role != "admin") {
return this.accessDeniedError(req, res, user)
}
let user_game = user.games.find((g) => {
return g.name == game
})
if (user_game && user_game.role) {
role = user_game.role
} else if (role != "admin") {
return this.accessDeniedError(req, res, user)
}
}
for (const auth of security) {
if (auth.hasOwnProperty("basicAuth")) {
if (!auth.basicAuth.includes(role)) {
return this.accessDeniedError(req, res, user)
}
}
}
}
req.user = user
next()
}
accessDeniedError(req, res, user) {
log.warn(
0,
`Access denied for ${req.method} request to ${req.originalUrl}, user: '${user.login}'.`
)
if (typeof res.status === "function") {
res.status(403).send({
name: "AuthenticationError",
message: "The user does not have access to this resource."
})
}
}
/**
* Get documentation segment and (if exists) game name for a request path
*
* @param {array} api_docs
* @param {string} path
* @returns {{"doc":Object,"game":String|undefined}}
*/
getDocs(api_docs, path) {
path = path.split("/")
for (const doc_path in api_docs) {
const dps = doc_path.split("/")
let match
const filePathIndex = dps.indexOf("{path}")
if (filePathIndex === -1) {
match = path.every((p, i) => {
if (i < dps.length && (p == dps[i] || dps[i][0] == "{")) {
return true
}
})
} else {
// If there is {path} in the doc pattern nested file/dir names must match too
// Get segments after {path} in the doc pattern (e.g. ["_copy"])
const tail = dps.slice(filePathIndex + 1)
// Request must be long enough to cover prefix + at least one {path} segment + tail
const minLength = filePathIndex + 1 + tail.length
if (path.length >= minLength) {
// Check prefix (everything before {path})
const prefixMatch = dps.slice(0, filePathIndex).every((dp, i) => {
return path[i] === dp || dp[0] === "{"
})
// Check tail (everything after {path}) aligns with end of request path
const tailMatch = tail.every((dp, i) => {
return path[path.length - tail.length + i] === dp
})
match = prefixMatch && tailMatch
}
}
if (match) {
if (path.length > 1 && path[1] == "game") {
return { doc: api_docs[doc_path], game: path[2] }
}
return { doc: api_docs[doc_path] }
}
}
}
}
module.exports = {
BasicAuth: BasicAuth,
NoAuth: NoAuth
}