diff --git a/package.json b/package.json index 6b7ea8a..782939f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "express": "^4.18.2", "minimist": "^1.2.8", "morgan": "^1.10.0", + "set-cookie-parser": "^2.6.0", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/backends/backends.js b/src/backends/backends.js index 31a330e..d830394 100644 --- a/src/backends/backends.js +++ b/src/backends/backends.js @@ -35,17 +35,34 @@ export class BACKEND { /** * Opens a session with the backend and creates session tokens if needed * @param {Object} credentials object containing username and password fields - * @returns {Object[]} list of session token objects with token name and value + * @returns {Object} response like object with ok, status, and list of session token objects with token name and value */ - openSession (credentials) {} + openSession (credentials) { + return { + ok: true, + status: 200, + cookies: [] + }; + } + /** * Closes an opened session with the backend if needed - * @param {*} token list of session token objects with token name and value + * @param {Object[]} token list of session token objects with token name and value, may include irrelevant tokens for a specific backend * @returns {Boolean} true if session was closed successfully, false otherwise */ - closeSesssion (tokens) {} + closeSession (tokens) { + return { + ok: true, + status: 200 + }; + } } +/** + * Interface for proxmox api backends. + */ +export class PVE_BACKEND extends BACKEND {} + /** * Interface for user database backends. */ diff --git a/src/backends/localdb.js b/src/backends/localdb.js index 6f91fd4..52b46aa 100644 --- a/src/backends/localdb.js +++ b/src/backends/localdb.js @@ -35,14 +35,6 @@ export default class LocalDB extends DB_BACKEND { writeFileSync(this.#path, JSON.stringify(this.#data)); } - openSession (credentials) { - return []; - } - - closeSesssion (tokens) { - return true; - } - addUser (username, config = null) { config = config || this.#defaultuser; this.#data.users[username] = config; diff --git a/src/backends/paasldap.js b/src/backends/paasldap.js index c25b41c..534ae79 100644 --- a/src/backends/paasldap.js +++ b/src/backends/paasldap.js @@ -1,5 +1,6 @@ import axios from "axios"; import { AUTH_BACKEND } from "./backends.js"; +import * as setCookie from "set-cookie-parser"; export default class PAASLDAP extends AUTH_BACKEND { #url = null; @@ -29,12 +30,13 @@ export default class PAASLDAP extends AUTH_BACKEND { }; if (auth) { - content.data.binduser = auth.binduser; - content.data.bindpass = auth.bindpass; + content.headers.PAASLDAPAuthTicket = auth.PAASLDAPAuthTicket; } try { - return await axios.request(url, content); + const result = await axios.request(url, content); + result.ok = result.status === 200; + return result; } catch (error) { error.ok = false; @@ -46,8 +48,28 @@ export default class PAASLDAP extends AUTH_BACKEND { } } - async modUser (userid, attributes, params = null) { - const bind = { binduser: params.binduser, bindpass: params.bindpass }; - return await this.#request(`/users/${userid}`, "POST", bind, attributes); + async openSession (credentials) { + const userRealm = credentials.username.split("@").at(-1); + const uid = credentials.username.replace(`@${userRealm}`, ""); + const content = { uid, password: credentials.password }; + const result = await this.#request("/ticket", "POST", null, content); + if (result.ok) { + const cookies = setCookie.parse(result.headers["set-cookie"]); + cookies.forEach((e) => { + e.expiresMSFromNow = e.expires - Date.now(); + }); + return { + ok: true, + status: result.status, + cookies + }; + } + else { + return result; + } + } + + async modUser (userid, attributes, ticket) { + return await this.#request(`/users/${userid}`, "POST", ticket, attributes); } } diff --git a/src/backends/pve.js b/src/backends/pve.js index f07ae51..be34b02 100644 --- a/src/backends/pve.js +++ b/src/backends/pve.js @@ -1,16 +1,43 @@ import axios from "axios"; +import { PVE_BACKEND } from "./backends.js"; -export default class PVE { +export default class PVE extends PVE_BACKEND { #pveAPIURL = null; #pveAPIToken = null; #pveRoot = null; constructor (config) { + super(); this.#pveAPIURL = config.url; this.#pveAPIToken = config.token; this.#pveRoot = config.root; } + async openSession (credentials) { + const response = await global.pve.requestPVE("/access/ticket", "POST", null, credentials); + if (!(response.status === 200)) { + return response; + } + const ticket = response.data.data.ticket; + const csrftoken = response.data.data.CSRFPreventionToken; + return { + ok: true, + status: response.status, + cookies: [ + { + name: "PVEAuthCookie", + value: ticket, + expiresMSFromNow: 2 * 60 * 60 * 1000 + }, + { + name: "CSRFPreventionToken", + value: csrftoken, + expiresMSFromNow: 2 * 60 * 60 * 1000 + } + ] + }; + } + /** * Send HTTP request to proxmox API. Allows requests to be made with user cookie credentials or an API token for controlled priviledge elevation. * @param {string} path HTTP path, prepended with the proxmox API base url. diff --git a/src/routes/auth.js b/src/routes/auth.js index d8fca57..d0487da 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -17,6 +17,34 @@ router.get("/", async (req, res) => { res.status(200).send({ auth: true }); }); +/** + * Fetches and consumes cookies from backends and avoids duplicate cookies from repeat backends. Also helps handle errors. + */ +class CookieFetcher { + #fetchedBackends = []; + #cookies = []; + async fetchBackends (backends, credentials) { + for (const backend of backends) { + if (this.#fetchedBackends.indexOf(backend) === -1) { + const response = await backend.openSession(credentials); + if (!response.ok) { + return false; + } + this.#cookies = this.#cookies.concat(response.cookies); + this.#fetchedBackends.push(backend); + } + else { // assume that a repeat backends should not be requested + continue; + } + } + return true; + } + + exportCookies () { + return this.#cookies; + } +} + /** * POST - safer ticket generation using proxmox authentication but adding HttpOnly * request: @@ -27,22 +55,29 @@ router.get("/", async (req, res) => { * - 401: {auth: false} */ router.post("/ticket", async (req, res) => { - const body = JSON.parse(JSON.stringify(req.body)); - const response = await global.pve.requestPVE("/access/ticket", "POST", null, body); - if (!(response.status === 200)) { - res.status(response.status).send({ auth: false }); - res.end(); + const params = { + username: req.body.username, + password: req.body.password + }; + const domain = global.config.application.domain; + const userRealm = params.username.split("@").at(-1); + const backends = [global.pve, global.db]; + if (userRealm in global.auth) { + backends.push(global.auth[userRealm]); + } + const cm = new CookieFetcher(); + const success = await cm.fetchBackends(backends, params); + if (!success) { + res.status(401).send({ auth: false }); return; } - const domain = global.config.application.domain; - const ticket = response.data.data.ticket; - const csrftoken = response.data.data.CSRFPreventionToken; - const username = response.data.data.username; - const expire = new Date(Date.now() + (2 * 60 * 60 * 1000)); - res.cookie("PVEAuthCookie", ticket, { domain, path: "/", httpOnly: true, secure: true, expires: expire }); - res.cookie("CSRFPreventionToken", csrftoken, { domain, path: "/", httpOnly: true, secure: true, expires: expire }); - res.cookie("username", username, { domain, path: "/", secure: true, expires: expire }); - res.cookie("auth", 1, { domain, path: "/", secure: true, expires: expire }); + const cookies = cm.exportCookies(); + for (const cookie of cookies) { + const expiresDate = new Date(Date.now() + cookie.expiresMSFromNow); + res.cookie(cookie.name, cookie.value, { domain, path: "/", httpOnly: true, secure: true, expires: expiresDate }); + } + res.cookie("username", params.username, { domain, path: "/", secure: true }); + res.cookie("auth", 1, { domain, path: "/", secure: true }); res.status(200).send({ auth: true }); }); @@ -52,12 +87,17 @@ router.post("/ticket", async (req, res) => { * - 200: {auth: false} */ router.delete("/ticket", async (req, res) => { - const expire = new Date(0); + if (Object.keys(req.cookies).length === 0) { + res.status(200).send({ auth: false }); + return; + } const domain = global.config.application.domain; - res.cookie("PVEAuthCookie", "", { domain, path: "/", httpOnly: true, secure: true, expires: expire }); - res.cookie("CSRFPreventionToken", "", { domain, path: "/", httpOnly: true, secure: true, expires: expire }); - res.cookie("username", "", { domain, path: "/", httpOnly: true, secure: true, expires: expire }); - res.cookie("auth", 0, { domain, path: "/", expires: expire }); + const expire = new Date(0); + for (const cookie in req.cookies) { + res.cookie(cookie, "", { domain, path: "/", expires: expire }); + } + await global.pve.closeSession(req.cookies); + await global.db.closeSession(req.cookies); res.status(200).send({ auth: false }); }); @@ -80,13 +120,11 @@ router.post("/password", async (req, res) => { }; const userRealm = params.username.split("@").at(-1); - const domains = (await global.pve.requestPVE("/access/domains", "GET", { token: true })).data.data; - const realm = domains.find((e) => e.realm === userRealm); const authHandlers = global.config.handlers.auth; - if (realm.type in authHandlers) { - const handler = authHandlers[realm.type]; - const userID = params.username.replace(`@${realm.realm}`, ""); + if (userRealm in authHandlers) { + const handler = authHandlers[userRealm]; + const userID = params.username.replace(`@${userRealm}`, ""); const newAttributes = { userpassword: params.password }; @@ -103,6 +141,6 @@ router.post("/password", async (req, res) => { } } else { - res.status(501).send({ error: `Auth type ${realm.type} not implemented yet.` }); + res.status(501).send({ error: `Auth type ${userRealm} not implemented yet.` }); } });