UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

358 lines (318 loc) 10.9 kB
/** * 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 }