diff --git a/src/utils/proxy/cookie-jar.js b/src/utils/proxy/cookie-jar.js index 3161d1d2f..66e00dca1 100644 --- a/src/utils/proxy/cookie-jar.js +++ b/src/utils/proxy/cookie-jar.js @@ -7,7 +7,10 @@ export function setCookieHeader(url, params) { const existingCookie = cookieJar.getCookieStringSync(url.toString()); if (existingCookie) { params.headers = params.headers ?? {}; - params.headers[params.cookieHeader ?? "Cookie"] = existingCookie; + const cookieHeader = params.cookieHeader ?? "Cookie"; + if (!params.headers[cookieHeader]) { + params.headers[cookieHeader] = existingCookie; + } } } diff --git a/src/utils/proxy/cookie-jar.test.js b/src/utils/proxy/cookie-jar.test.js index 29c4bee96..83e459b3c 100644 --- a/src/utils/proxy/cookie-jar.test.js +++ b/src/utils/proxy/cookie-jar.test.js @@ -42,4 +42,16 @@ describe("utils/proxy/cookie-jar", () => { expect(params.headers.Cookie).toContain("c=d"); }); + + it("does not overwrite an explicit cookie header", async () => { + const { addCookieToJar, setCookieHeader } = await import("./cookie-jar"); + + const url = new URL("http://example4.test/path"); + addCookieToJar(url, { "set-cookie": ["sid=1; Path=/"] }); + + const params = { headers: { Cookie: "manual=1" } }; + setCookieHeader(url, params); + + expect(params.headers.Cookie).toBe("manual=1"); + }); }); diff --git a/src/widgets/omada/proxy.js b/src/widgets/omada/proxy.js index 04d173de1..f6c063803 100644 --- a/src/widgets/omada/proxy.js +++ b/src/widgets/omada/proxy.js @@ -1,12 +1,30 @@ +import cache from "memory-cache"; + import getServiceWidget from "utils/config/service-helpers"; import createLogger from "utils/logger"; import { httpProxy } from "utils/proxy/http"; const proxyName = "omadaProxyHandler"; +const sessionCacheKey = `${proxyName}__session`; const logger = createLogger(proxyName); -async function login(loginUrl, username, password, controllerVersionMajor) { +function getSessionCacheId(service) { + return `${sessionCacheKey}.${service}`; +} + +function getCookieHeader(responseHeaders) { + const setCookie = responseHeaders?.["set-cookie"]; + if (!setCookie) return null; + + const cookies = (Array.isArray(setCookie) ? setCookie : [setCookie]) + .map((cookie) => cookie.split(";")[0]) + .filter(Boolean); + + return cookies.length > 0 ? cookies.join("; ") : null; +} + +async function login(loginUrl, username, password, controllerVersionMajor, service) { const params = { username, password, @@ -20,7 +38,7 @@ async function login(loginUrl, username, password, controllerVersionMajor) { }; } - const [status, contentType, data] = await httpProxy(loginUrl, { + const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, { method: "POST", body: JSON.stringify(params), headers: { @@ -28,7 +46,20 @@ async function login(loginUrl, username, password, controllerVersionMajor) { }, }); - return [status, JSON.parse(data.toString())]; + const loginResponseData = JSON.parse(data.toString()); + + if (status === 200 && loginResponseData.errorCode === 0) { + cache.put( + getSessionCacheId(service), + { + token: loginResponseData.result.token, + cookieHeader: getCookieHeader(responseHeaders), + }, + 55 * 60 * 1000, // Cache session for 55 minutes + ); + } + + return [status, loginResponseData]; } export default async function omadaProxyHandler(req, res) { @@ -86,25 +117,34 @@ export default async function omadaProxyHandler(req, res) { break; } - const [loginStatus, loginResponseData] = await login( - loginUrl, - widget.username, - widget.password, - controllerVersionMajor, - ); + let session = cache.get(getSessionCacheId(service)); + if (!session) { + const [loginStatus, loginResponseData] = await login( + loginUrl, + widget.username, + widget.password, + controllerVersionMajor, + service, + ); - if (loginStatus !== 200 || loginResponseData.errorCode > 0) { - return res - .status(loginStatus) - .json({ error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData } }); + if (loginStatus !== 200 || loginResponseData.errorCode > 0) { + return res.status(loginStatus).json({ + error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData }, + }); + } + + session = cache.get(getSessionCacheId(service)); } - const { token } = loginResponseData.result; + const { token, cookieHeader } = session; let sitesUrl; let body = {}; let params = { token }; let headers = { "Csrf-Token": token }; + if (cookieHeader) { + headers.Cookie = cookieHeader; + } let method = "GET"; switch (controllerVersionMajor) { @@ -116,6 +156,10 @@ export default async function omadaProxyHandler(req, res) { userName: widget.username, }, }; + headers = { "Content-Type": "application/json" }; + if (cookieHeader) { + headers.Cookie = cookieHeader; + } method = "POST"; break; case 4: @@ -174,6 +218,9 @@ export default async function omadaProxyHandler(req, res) { }, }; headers = { "Content-Type": "application/json" }; + if (cookieHeader) { + headers.Cookie = cookieHeader; + } params = { token }; [status, contentType, data] = await httpProxy(switchUrl, { @@ -216,9 +263,7 @@ export default async function omadaProxyHandler(req, res) { : `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`; [status, contentType, data] = await httpProxy(siteStatsUrl, { - headers: { - "Csrf-Token": token, - }, + headers, }); siteResponseData = JSON.parse(data); @@ -240,9 +285,7 @@ export default async function omadaProxyHandler(req, res) { : `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=1000`; [status, contentType, data] = await httpProxy(alertUrl, { - headers: { - "Csrf-Token": token, - }, + headers, }); const alertResponseData = JSON.parse(data); diff --git a/src/widgets/omada/proxy.test.js b/src/widgets/omada/proxy.test.js index 060ab9a35..46d25a4c4 100644 --- a/src/widgets/omada/proxy.test.js +++ b/src/widgets/omada/proxy.test.js @@ -2,14 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import createMockRes from "test-utils/create-mock-res"; -const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ - httpProxy: vi.fn(), - getServiceWidget: vi.fn(), - logger: { - debug: vi.fn(), - error: vi.fn(), - }, -})); +const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => { + const store = new Map(); + + return { + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + cache: { + get: vi.fn((k) => store.get(k)), + put: vi.fn((k, v) => store.set(k, v)), + del: vi.fn((k) => store.delete(k)), + _reset: () => store.clear(), + }, + logger: { + debug: vi.fn(), + error: vi.fn(), + }, + }; +}); vi.mock("utils/logger", () => ({ default: () => logger, @@ -20,15 +30,19 @@ vi.mock("utils/config/service-helpers", () => ({ vi.mock("utils/proxy/http", () => ({ httpProxy, })); +vi.mock("memory-cache", () => ({ + default: cache, + ...cache, +})); import omadaProxyHandler from "./proxy"; describe("widgets/omada/proxy", () => { beforeEach(() => { vi.clearAllMocks(); - // Clear one-off implementations between tests (some branches return early). httpProxy.mockReset(); getServiceWidget.mockReset(); + cache._reset(); }); it("fetches controller info, logs in, selects site, and returns overview stats (v4)", async () => { @@ -51,6 +65,7 @@ describe("widgets/omada/proxy", () => { 200, "application/json", Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, ]) // sites list .mockResolvedValueOnce([ @@ -91,6 +106,12 @@ describe("widgets/omada/proxy", () => { connectedSwitches: 3, }), ); + expect(httpProxy.mock.calls[2][1]).toMatchObject({ + headers: { + "Csrf-Token": "t", + Cookie: "TPOMADA_SESSIONID=sid", + }, + }); }); it("returns an error when controller info cannot be retrieved", async () => { @@ -169,6 +190,7 @@ describe("widgets/omada/proxy", () => { 200, "application/json", Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, ]) .mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 2, msg: "bad" })]); @@ -195,6 +217,7 @@ describe("widgets/omada/proxy", () => { 200, "application/json", Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, ]) .mockResolvedValueOnce([ 200, @@ -222,6 +245,7 @@ describe("widgets/omada/proxy", () => { 200, "application/json", Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, ]) // getUserSites .mockResolvedValueOnce([ @@ -271,6 +295,7 @@ describe("widgets/omada/proxy", () => { 200, "application/json", Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, ]) .mockResolvedValueOnce([ 200, @@ -301,6 +326,7 @@ describe("widgets/omada/proxy", () => { 200, "application/json", Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, ]) .mockResolvedValueOnce([ 200, @@ -324,4 +350,78 @@ describe("widgets/omada/proxy", () => { }, }); }); + + it("reuses a cached Omada session across polls", async () => { + getServiceWidget.mockResolvedValue({ + url: "http://omada", + username: "u", + password: "p", + site: "Default", + }); + + httpProxy + .mockResolvedValueOnce([ + 200, + "application/json", + JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }), + ]) + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), + { "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] }, + ]) + .mockResolvedValueOnce([ + 200, + "application/json", + JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }), + ]) + .mockResolvedValueOnce([ + 200, + "application/json", + JSON.stringify({ + errorCode: 0, + result: { + totalClientNum: 10, + connectedApNum: 2, + connectedGatewayNum: 1, + connectedSwitchNum: 3, + }, + }), + ]) + .mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]) + .mockResolvedValueOnce([ + 200, + "application/json", + JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }), + ]) + .mockResolvedValueOnce([ + 200, + "application/json", + JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }), + ]) + .mockResolvedValueOnce([ + 200, + "application/json", + JSON.stringify({ + errorCode: 0, + result: { + totalClientNum: 10, + connectedApNum: 2, + connectedGatewayNum: 1, + connectedSwitchNum: 3, + }, + }), + ]) + .mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + + await omadaProxyHandler(req, createMockRes()); + await omadaProxyHandler(req, createMockRes()); + + const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login")); + expect(loginCalls).toHaveLength(1); + expect(httpProxy.mock.calls[6][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid"); + }); });