Update auth and skeleton of wrapper

This commit is contained in:
Louis Lam 2023-10-08 05:33:08 +08:00
parent 5773eeb6df
commit 6d2f624242
3 changed files with 208 additions and 22 deletions

View file

@ -36,20 +36,32 @@ exports.login = async function (username, password) {
return null; 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 * Validate a provided API key
* @param {string} key API key to verify * @param {string} key API key to verify
* @returns {boolean} API is ok? * @returns {Promise<boolean>} API is ok?
*/ */
async function verifyAPIKey(key) { async function verifyAPIKey(key) {
if (typeof key !== "string") { if (typeof key !== "string") {
return false; return false;
} }
// uk prefix + key ID is before _ const { index, clear } = exports.parseAPIKey(key);
let index = key.substring(2, key.indexOf("_"));
let clear = key.substring(key.indexOf("_") + 1, key.length);
let hash = await R.findOne("api_key", " id=? ", [ index ]); let hash = await R.findOne("api_key", " id=? ", [ index ]);
if (hash === null) { if (hash === null) {
@ -65,6 +77,28 @@ async function verifyAPIKey(key) {
return hash && passwordHash.verify(clear, hash.key); return hash && passwordHash.verify(clear, hash.key);
} }
/**
* @param {string} key API key to verify
* @returns {Promise<void>}
* @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 for basic auth authorizers
* @callback authCallback * @callback authCallback
@ -80,22 +114,10 @@ async function verifyAPIKey(key) {
* @returns {void} * @returns {void}
*/ */
function apiAuthorizer(username, password, callback) { function apiAuthorizer(username, password, callback) {
// API Rate Limit verifyAPIKeyWithRateLimit(password).then(() => {
apiRateLimiter.pass(null, 0).then((pass) => { callback(null, true);
if (pass) { }).catch(() => {
verifyAPIKey(password).then((valid) => { callback(null, false);
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);
}
}); });
} }
@ -163,3 +185,41 @@ exports.basicAuthMiddleware = async function (req, res, next) {
}); });
middleware(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",
});
}
};

View file

@ -1,4 +1,4 @@
let express = require("express"); const express = require("express");
const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, sendHttpError } = require("../util-server"); const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const apicache = require("../modules/apicache"); const apicache = require("../modules/apicache");
@ -12,6 +12,10 @@ const { badgeConstants } = require("../config");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const Database = require("../database"); const Database = require("../database");
const { UptimeCalculator } = require("../uptime-calculator"); 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(); 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) => { router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response) => {
allowAllOrigin(response); allowAllOrigin(response);

11
test/api.http Normal file
View file

@ -0,0 +1,11 @@
POST http://localhost:3001/api
Authorization: Bearer uk1_1HaQRETls-E5KlhB6yCtf8WJRW57KwFMuKkya-Tj
Content-Type: application/json
{
"action": "getPushExample",
"params": [
"javascript-fetch"
]
}
###