From 6d2f6242427bdb68478a03ea9fa0d82ae0fc3d40 Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Sun, 8 Oct 2023 05:33:08 +0800 Subject: [PATCH] Update auth and skeleton of wrapper --- server/auth.js | 102 +++++++++++++++++++++++------- server/routers/api-router.js | 117 ++++++++++++++++++++++++++++++++++- test/api.http | 11 ++++ 3 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 test/api.http diff --git a/server/auth.js b/server/auth.js index 106182ddc..9f87de378 100644 --- a/server/auth.js +++ b/server/auth.js @@ -36,20 +36,32 @@ exports.login = async function (username, password) { return null; }; +/** + * uk prefix + key ID is before _ + * @param {string} key API Key + * @returns {{clear: string, index: string}} Parsed API key + */ +exports.parseAPIKey = function (key) { + let index = key.substring(2, key.indexOf("_")); + let clear = key.substring(key.indexOf("_") + 1, key.length); + + return { + index, + clear, + }; +}; + /** * Validate a provided API key * @param {string} key API key to verify - * @returns {boolean} API is ok? + * @returns {Promise} API is ok? */ async function verifyAPIKey(key) { if (typeof key !== "string") { return false; } - // uk prefix + key ID is before _ - let index = key.substring(2, key.indexOf("_")); - let clear = key.substring(key.indexOf("_") + 1, key.length); - + const { index, clear } = exports.parseAPIKey(key); let hash = await R.findOne("api_key", " id=? ", [ index ]); if (hash === null) { @@ -65,6 +77,28 @@ async function verifyAPIKey(key) { return hash && passwordHash.verify(clear, hash.key); } +/** + * @param {string} key API key to verify + * @returns {Promise} + * @throws {Error} If API key is invalid or rate limit exceeded + */ +async function verifyAPIKeyWithRateLimit(key) { + const pass = await apiRateLimiter.pass(null, 0); + if (pass) { + await apiRateLimiter.removeTokens(1); + const valid = await verifyAPIKey(key); + if (!valid) { + const errMsg = "Failed API auth attempt: invalid API Key"; + log.warn("api-auth", errMsg); + throw new Error(errMsg); + } + } else { + const errMsg = "Failed API auth attempt: rate limit exceeded"; + log.warn("api-auth", errMsg); + throw new Error(errMsg); + } +} + /** * Callback for basic auth authorizers * @callback authCallback @@ -80,22 +114,10 @@ async function verifyAPIKey(key) { * @returns {void} */ function apiAuthorizer(username, password, callback) { - // API Rate Limit - apiRateLimiter.pass(null, 0).then((pass) => { - if (pass) { - verifyAPIKey(password).then((valid) => { - if (!valid) { - log.warn("api-auth", "Failed API auth attempt: invalid API Key"); - } - callback(null, valid); - // Only allow a set number of api requests per minute - // (currently set to 60) - apiRateLimiter.removeTokens(1); - }); - } else { - log.warn("api-auth", "Failed API auth attempt: rate limit exceeded"); - callback(null, false); - } + verifyAPIKeyWithRateLimit(password).then(() => { + callback(null, true); + }).catch(() => { + callback(null, false); }); } @@ -163,3 +185,41 @@ exports.basicAuthMiddleware = async function (req, res, next) { }); middleware(req, res, next); }; + +// Get the API key from the header Authorization and verify it +exports.headerAuthMiddleware = async function (req, res, next) { + const authorizationHeader = req.header("Authorization"); + + let key = null; + + if (authorizationHeader && typeof authorizationHeader === "string") { + const arr = authorizationHeader.split(" "); + if (arr.length === 2) { + const type = arr[0]; + if (type === "Bearer") { + key = arr[1]; + } + } + } + + if (key) { + try { + await verifyAPIKeyWithRateLimit(key); + res.locals.apiKeyID = exports.parseAPIKey(key).index; + next(); + } catch (e) { + res.status(401); + res.json({ + ok: false, + msg: e.message, + }); + } + } else { + await apiRateLimiter.removeTokens(1); + res.status(401); + res.json({ + ok: false, + msg: "No API Key provided, please provide an API Key in the \"Authorization\" header", + }); + } +}; diff --git a/server/routers/api-router.js b/server/routers/api-router.js index d1445d30c..605fe72ed 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -1,4 +1,4 @@ -let express = require("express"); +const express = require("express"); const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, sendHttpError } = require("../util-server"); const { R } = require("redbean-node"); const apicache = require("../modules/apicache"); @@ -12,6 +12,10 @@ const { badgeConstants } = require("../config"); const { Prometheus } = require("../prometheus"); const Database = require("../database"); const { UptimeCalculator } = require("../uptime-calculator"); +const ioClient = require("socket.io-client").io; +const Socket = require("socket.io-client").Socket; +const { headerAuthMiddleware } = require("../auth"); +const jwt = require("jsonwebtoken"); let router = express.Router(); @@ -109,6 +113,117 @@ router.get("/api/push/:pushToken", async (request, response) => { } }); +/* + * Map Socket.io API to REST API + */ +router.post("/api", headerAuthMiddleware, async (request, response) => { + allowDevAllOrigin(response); + // TODO: Allow whitelist of origins + + // Generate a JWT for logging in to the socket.io server + const apiKeyID = response.locals.apiKeyID; + const userID = await R.getCell("SELECT user_id FROM api_key WHERE id = ?", [ apiKeyID ]); + const username = await R.getCell("SELECT username FROM user WHERE id = ?", [ userID ]); + const token = jwt.sign({ + username, + }, server.jwtSecret); + + const requestData = request.body; + + console.log(requestData); + + // TODO: should not hard coded + let wsURL = "ws://localhost:3001"; + + const socket = ioClient(wsURL, { + transports: [ "websocket" ], + reconnection: false, + }); + + try { + let result = await socketClientHandler(socket, token, requestData); + let status = 404; + if (result.status) { + status = result.status; + } else if (result.ok) { + status = 200; + } + response.status(status).json(result); + } catch (e) { + response.status(e.status).json(e); + } + + console.log("Close socket"); + socket.disconnect(); +}); + +/** + * @param {Socket} socket + * @param {string} token JWT + * @param {object} requestData Request Data + */ +function socketClientHandler(socket, token, requestData) { + const action = requestData.action; + const params = requestData.params; + + return new Promise((resolve, reject) => { + socket.on("connect", () => { + socket.emit("loginByToken", token, (res) => { + if (res.ok) { + + if (action === "getPushExample") { + if (params.length <= 0) { + reject({ + status: 400, + ok: false, + msg: "Missing required parameter(s)", + }); + } else { + socket.emit("getPushExample", params[0], (res) => { + resolve(res); + }); + } + + } else { + reject({ + status: 404, + ok: false, + msg: "Event not found" + }); + } + } else { + reject({ + status: 401, + ok: false, + msg: "Login failed?????" + }); + } + }); + }); + + socket.on("connect_error", (error) => { + reject({ + status: 500, + ok: false, + msg: error.message + }); + }); + + socket.on("error", (error) => { + reject({ + status: 500, + ok: false, + msg: error.message + }); + }); + }); + +} + +/* + * Badge API + */ + router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response) => { allowAllOrigin(response); diff --git a/test/api.http b/test/api.http new file mode 100644 index 000000000..d76cee083 --- /dev/null +++ b/test/api.http @@ -0,0 +1,11 @@ +POST http://localhost:3001/api +Authorization: Bearer uk1_1HaQRETls-E5KlhB6yCtf8WJRW57KwFMuKkya-Tj +Content-Type: application/json + +{ + "action": "getPushExample", + "params": [ + "javascript-fetch" + ] +} +###