From f475633e995adfa6591d1fb0e5dbb71731e8e0f2 Mon Sep 17 00:00:00 2001 From: "Zander M." Date: Fri, 29 Sep 2023 15:01:13 -0400 Subject: [PATCH] Added multi-API-key support and API rate-limiting. --- .env.example | 10 +++ README.md | Bin 26492 -> 27238 bytes app.js | 4 +- database.js | 101 +++++++++++++++++++++++++++-- package-lock.json | 115 +++++++++++++++++++++++++++++++++ package.json | 5 +- routes/api.js | 158 ++++++++++++++++++++++++++++++++-------------- 7 files changed, 338 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index e54ec33..c1e5e6c 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,13 @@ API_KEY="" # This is the name that will be pre-filled when you go to upload custom files for a modpack. # Unset behaviour: You will need to specify the author every time. AUTHOR_NAME="John Doe" + +# (Optional) API Rate-limit Requests +# The number of requests that can be made to the API by one IP address within the set window before it will block them. +# Default: 1000 +API_RATE_REQUESTS=1000 + +# (Optional) API Rate-limit Window +# The amount of time (in milliseconds) that the request limit is applied to. +# Default: 60000 +API_RATE_WINDOW=60000 diff --git a/README.md b/README.md index 283b8f502eb21eb9e88845c7f4086de7f638eab3..12970f462e6f1131e16f18f5d55ad18fe24771af 100644 GIT binary patch delta 443 zcmex!j`7(Q#tk=2CKuT8ZN6i|$2fU|Dc9y#rb3KB5ueEc7EY5J%xxyGG82&xVDMy6 zVDM&0WvFB*2C_65av4e)au`Y&G8qaOawb326c$!saAZgZ!hE1i9#E#3L36UBwd>?P zc4~~~lVjb)C)b!yf|`5*Zi9e>&EyA`K`8uBRxp0bWCeSv$t*Ugd@)-P|I}m+yP(Yl zb}|U(3OMwE4SL`JvLImcJsXk9TuwWnVs(=XoY*FJIQzi)Q(Qm>@oj$P0`z>28`oq$ zpr5VW>Nan2i{p@YWvBppt_bMqVz8%!7!rZrN@dVx@Bw-|ck;ww$;s#ZbSB&R@;T}P z)xl(QK(atw!l22(%fQ8;1q`rg2AFOIh;FdF0)sCwxbhg%8S*D@43M2H<*Oo(%^Y2z Mc~EtmS^di;0A!|xcmMzZ delta 225 zcmaEMh4Ifh#tk=2CM%e-Y<_0K$GG{QsSxAj36^$1>^6CWmC@u2=3JA%Sp-4FuE6E^ zY-}d$S;NHLCiB_!f#fFHHBC;l1q)BIgQy0YJz2s20$3-6T{n4`1F}w=$=4hq`~oM0 zo(awnJq1opllffOCL6f=fXx8$-Q0peZl2_!H2H+vo5>H{SSG))v4RNIZEo_2;@JEr HFiQdelq_0< diff --git a/app.js b/app.js index b10805f..7ed62e8 100644 --- a/app.js +++ b/app.js @@ -15,7 +15,7 @@ const storage = multer.diskStorage({ cb(null, uniquePrefix + '-' + file.originalname) } }) -const upload = multer({ storage: storage }) +const upload = multer({ storage }) // --- Routers --- const indexRouter = require('./routes/index') @@ -49,7 +49,7 @@ app.use(function (err, req, res, next) { // render the error page res.status(err.status || 500) - res.render('error', {title: `Error ${err.status}: ${err.message}`}) + res.render('error', { title: `Error ${err.status}: ${err.message}` }) }) module.exports.app = app diff --git a/database.js b/database.js index 22c19f5..0e34c59 100644 --- a/database.js +++ b/database.js @@ -16,6 +16,13 @@ const userSchema = new Schema({ login_history: [{ timestamp: Date, userAgent: String }] }) +const apiKeySchema = new Schema({ + owner: { type: SchemaTypes.ObjectId, ref: 'technicflux_users' }, + key: String, + name: String, + created_at: Date +}) + const modpackSchema = new Schema({ name: String, display_name: String, @@ -55,6 +62,7 @@ const buildSchema = new Schema({ // --- Models --- const User = mongoose.model('technicflux_users', userSchema) +const APIKey = mongoose.model('technicflux_keys', apiKeySchema) const Modpack = mongoose.model('technicflux_modpacks', modpackSchema) const Mod = mongoose.model('technicflux_mods', modSchema) const Build = mongoose.model('technicflux_builds', buildSchema) @@ -119,7 +127,7 @@ exports.getUserById = (uId) => { } /* - * Gets the user with the provided email. + * Gets the user with the provided username. */ exports.getUserByUsername = (uUsername) => { // Fetch information about the user with the given username @@ -155,7 +163,7 @@ exports.authUser = (uUsername, uPassword, logHistory = false, uAgent = 'Unknown' // Fetch information about the user with the given username return User.findOne({ username: uUsername }).exec().then((user) => { // User found. Check password. - return bcrypt.compare(uPassword, user.password).then((isMatch) => { + return bcrypt.compare(uPassword, user.password).then(async (isMatch) => { // Check if it is a match if (isMatch) { // Update the user's login history. @@ -163,7 +171,7 @@ exports.authUser = (uUsername, uPassword, logHistory = false, uAgent = 'Unknown' const newLogin = { timestamp: new Date(), userAgent: String(uAgent) } // Add the new login to the user's login history - return User.updateOne( + return await User.updateOne( { username: uUsername }, { $push: { login_history: newLogin } } ).exec().then(() => { @@ -229,6 +237,91 @@ exports.updateUser = (uUsername, object) => { }) } +// --- API-Key-Related Functions + +/* + * Creates a new api key with the given information. +*/ +exports.createAPIKey = (kOwner, kKey, kName) => { + // Encrypt the password using bcrypt and save user + return bcrypt.genSalt(10).then((Salt) => { + return bcrypt.hash(kKey, Salt).then((hash) => { + // Create an APIKey object + const newKey = new APIKey({ + owner: kOwner, + key: hash, + name: kName, + created_at: new Date() + }) + + // Commit it to the database + return newKey.save().then((key) => { + process.stdout.write(`Successfully added new API key '${kName}' for ${kOwner}\n`) + return key + }).catch((reason) => { + process.stdout.write(`ERROR (when saving new API key): ${reason}\n`) + return false + }) + }).catch((err) => { + // Issue creating hashed api key + process.stdout.write(`ERROR (while hashing key): ${err}`) + return false + }) + }).catch((err) => { + // Issue creating hashed object salt + process.stdout.write(`ERROR (while generating api key salt): ${err}`) + return false + }) +} + +/* + * Checks if the corresponding password matches that of the provided username. +*/ +exports.authAPIKey = (uKey) => { + // Fetch information about the user with the given username + return APIKey.find({ }).exec().then(async (keys) => { + // Check if any of the keys match. + let valid = [false, undefined] + for (const key of keys) { + await bcrypt.compare(uKey, key.key).then((isMatch) => { + // Check if it is a match + if (isMatch) { + // The key matched. + valid = [true, key] + } + }) + } + return valid + }).catch((reason) => { + console.log(reason) + // DB error + debug('ERROR (DB): Could not fetch API keys.') + return [false, undefined] + }) +} + +exports.getAPIKeysByOwner = (mOwner) => { + return APIKey.find({ owner: mOwner }).populate('owner').exec().then((keys) => { + // API Keys found. + return keys + }).catch((reason) => { + // No API keys found for this owner. + debug(`ERROR (DB): Failed to find any API keys with this owner because of: ${reason}`) + return false + }) +} + +exports.deleteAPIKey = (kId) => { + return APIKey.deleteOne({ _id: kId }).exec().then(() => { + // Successfully deleted this api key + return true + }).catch((reason) => { + // Failed to delete the api key + debug(`ERROR (DB): Failed to delete API Key '${kId}' because of: ${reason}`) + return false + }) +} + // --- Modpack-Related Functions --- exports.createModpack = (mSlug, mDisplayName, mOwner) => { // Create a modpack object @@ -335,7 +428,7 @@ exports.getModBySlug = (mSlug) => { author: mods[0].author, description: mods[0].description, link: mods[0].link, - versions: mods.map((modVersion) => modVersion['version']) + versions: mods.map((modVersion) => modVersion.version) } } }).catch((reason) => { diff --git a/package-lock.json b/package-lock.json index 344a36f..6869042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "mongoose": "^7.5.3", "morgan": "~1.9.1", "multer": "^1.4.5-lts.1", + "rate-limit-mongo": "^2.3.2", "standard": "^17.1.0" }, "devDependencies": { @@ -439,6 +440,15 @@ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -801,6 +811,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -3077,6 +3095,17 @@ "wrappy": "1" } }, + "node_modules/optional-require": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz", + "integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==", + "dependencies": { + "require-at": "^1.0.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -3367,6 +3396,62 @@ "node": ">= 0.6" } }, + "node_modules/rate-limit-mongo": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/rate-limit-mongo/-/rate-limit-mongo-2.3.2.tgz", + "integrity": "sha512-dLck0j5N/AX9ycVHn5lX9Ti2Wrrwi1LfbXitu/mMBZOo2nC26RgYKJVbcb2mYgb9VMaPI2IwJVzIa2hAQrMaDA==", + "dependencies": { + "mongodb": "^3.6.7", + "twostep": "0.4.2", + "underscore": "1.12.1" + } + }, + "node_modules/rate-limit-mongo/node_modules/bson": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/rate-limit-mongo/node_modules/mongodb": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", + "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", + "dependencies": { + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "optional-require": "^1.1.8", + "safe-buffer": "^5.1.2" + }, + "engines": { + "node": ">=4" + }, + "optionalDependencies": { + "saslprep": "^1.0.0" + }, + "peerDependenciesMeta": { + "aws4": { + "optional": true + }, + "bson-ext": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "mongodb-extjson": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, "node_modules/raw-body": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", @@ -3492,6 +3577,14 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/require-at": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", + "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve": { "version": "1.22.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", @@ -3609,6 +3702,18 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saslprep": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4105,6 +4210,11 @@ "strip-bom": "^3.0.0" } }, + "node_modules/twostep": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/twostep/-/twostep-0.4.2.tgz", + "integrity": "sha512-O/wdPYk9ey04qcCiw8AQN74DbvLFZLAgnryrNTpV7T/sxB4lcGkCMHynx5xCcA6fCh739ZAqp3HcGhy770X1qA==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4231,6 +4341,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", + "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 5dd24f4..9a790c2 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "version": "0.0.1", "private": true, "scripts": { - "start": "set DEBUG=technicflux:* && node ./bin/www" + "start": "set DEBUG=technicflux:* && node ./bin/www", + "lint": "standard", + "lint-and-fix": "standard --fix" }, "dependencies": { "@zip.js/zip.js": "^2.7.29", @@ -48,6 +50,7 @@ "mongoose": "^7.5.3", "morgan": "~1.9.1", "multer": "^1.4.5-lts.1", + "rate-limit-mongo": "^2.3.2", "standard": "^17.1.0" }, "devDependencies": { diff --git a/routes/api.js b/routes/api.js index 73977a8..5feffbe 100644 --- a/routes/api.js +++ b/routes/api.js @@ -1,9 +1,44 @@ const express = require('express') -const pckg = require('../package.json') -const branchName = require('current-git-branch')() || "release" +const rateLimit = require('express-rate-limit') +const MongoStore = require('rate-limit-mongo') const database = require('../database') +const branchName = require('current-git-branch')() || 'release' +const pckg = require('../package.json') const router = express.Router() +// --- Rate Limiter --- +const maxRequests = process.env.API_RATE_REQUESTS || 1000 +const rateWindow = process.env.API_RATE_WINDOW || 60000 + +const limiter = rateLimit({ + store: new MongoStore({ + uri: process.env.MONGODB_CONN_STRING, + collectionName: 'technicflux_ratelimit', + expireTimeMs: rateLimit, + errorHandler: console.error.bind(null, 'rate-limit-mongo') + }), + max: maxRequests, + windowMs: rateWindow, + legacyHeaders: true, + message: { error: 'Too many requests from this IP. You are being rate-limited.' } +}) +router.use(limiter) + +// --- Module Error Functions --- + +function error500 (res) { + return res.status(500).json({ + error: 'Internal Server Error' + }) +} + +// --- API Routes --- + +/** + * Base API Route : /api/ + * Possible Statuses: 200 + * Purpose: Displays information about the TechnicFlux Solder API. + */ router.get('/', (req, res) => { return res.json({ api: pckg.prettyName, @@ -12,27 +47,32 @@ router.get('/', (req, res) => { meta: { description: "TechnicFlux implementation of Technic's Solder API for retrieval of modpack and mod info.", license: `https://github.com/Zandercraft/TechnicFlux/blob/${branchName}/LICENSE.txt`, - repo: "https://github.com/Zandercraft/TechnicFlux", - documentation: "https://github.com/Zandercraft/TechnicFlux/wiki", + repo: 'https://github.com/Zandercraft/TechnicFlux', + documentation: 'https://github.com/Zandercraft/TechnicFlux/wiki', attribution: { - name: "Zandercraft", - github: "https://github.com/Zandercraft", - website: "https://zandercraft.ca", + name: 'Zandercraft', + github: 'https://github.com/Zandercraft', + website: 'https://zandercraft.ca' } } }) }) +/** + * Mod API Route : /api/mod/:modname/:modversion? + * Possible Statuses: 200, 404, 500 + * Purpose: Displays information about a mod or a specific version of that mod. + */ router.get('/mod/:modname/:modversion?', (req, res) => { - let modName = req.params.modname - let modVersion = req.params?.modversion + const modName = req.params.modname + const modVersion = req.params?.modversion return database.getModBySlug(`${modName}`).then((mod) => { // Check if a mod was found if (mod === null) { // Mod Not Found return res.status(404).json({ - "error": "Mod does not exist" + error: 'Mod does not exist' }) } else if (modVersion === undefined) { // Request is for the mod's info. @@ -43,7 +83,7 @@ router.get('/mod/:modname/:modversion?', (req, res) => { if (version === null) { // Version Not Found return res.status(404).json({ - "error": "Mod version does not exist" + error: 'Mod version does not exist' }) } else { // Found mod version @@ -56,46 +96,52 @@ router.get('/mod/:modname/:modversion?', (req, res) => { } }).catch(() => { // Issue with db request - return res.status(500).json({ - "error": "Internal Server Error" - }) + return error500(res) }) } }).catch(() => { // Issue with db request - return res.status(500).json({ - "error": "Internal Server Error" - }) + return error500(res) }) }) +/** + * Modpack Index API Route : /api/modpack/ + * Possible Statuses: 200, 500 + * Purpose: Displays a list of all modpacks that are on this TechnicFlux instance. + */ router.get('/modpack', (req, res) => { return database.getAllModpacks().then((modpacks) => { // Modpack Index return res.json({ - modpacks: (modpacks === null) ? {} : modpacks.reduce((obj, item) => { - obj[item['name']] = String(item['display_name']) - return obj - }, {}), + modpacks: (modpacks === null) + ? {} + : modpacks.reduce((obj, item) => { + obj[item.name] = String(item.display_name) + return obj + }, {}), mirror_url: `http://${process.env.HOST}/mods/` }) }).catch(() => { // Issue with db request - return res.status(500).json({ - "error": "Internal Server Error" - }) + return error500(res) }) }) +/** + * Modpack API Route : /api/modpack/:modpack/:build? + * Possible Statuses: 200, 404, 500 + * Purpose: Displays information about a modpack or a specific build of that modpack. + */ router.get('/modpack/:slug/:build?', (req, res) => { - let slug = req.params.slug - let build = req.params?.build + const slug = req.params.slug + const build = req.params?.build return database.getModpackBySlug(`${slug}`).then((modpack) => { if (modpack === null) { // Modpack Not Found return res.status(404).json({ - "error": "Modpack does not exist" + error: 'Modpack does not exist' }) } else if (build === undefined) { // Modpack info @@ -104,7 +150,7 @@ router.get('/modpack/:slug/:build?', (req, res) => { display_name: modpack.display_name, recommended: modpack.recommended, latest: modpack.latest, - builds: modpack.builds.map((build) => build['version']) + builds: modpack.builds.map((build) => build.version) }) } else { // Modpack build info @@ -133,38 +179,54 @@ router.get('/modpack/:slug/:build?', (req, res) => { } }).catch(() => { // Issue with db request - return res.status(500).json({ - "error": "Internal Server Error" - }) + return error500(res) }) }) +/** + * API Key Verification Route : /api/verify/:key? + * Possible Statuses: 200, 400, 403 + * Purpose: Checks if the given API key is valid with this instance of TechnicFlux + */ router.get('/verify/:key?', (req, res) => { - let api_key = req.params?.key + const apiKey = req.params?.key - if (api_key !== undefined) { - if (api_key === process.env?.API_KEY) - // Key validated - return res.json({ - valid: 'Key validated.', - name: 'API KEY', - created_at: 'A long time ago' - }) - else { - // Invalid key provided - return res.json({ - error: 'Invalid key provided.' - }) - } + if (apiKey !== undefined) { + database.authAPIKey(apiKey).then((key) => { + if (apiKey === process.env?.API_KEY) { + // Key validated + return res.json({ + valid: 'Key validated.', + name: 'API KEY', + created_at: 'A long time ago' + }) + } else if (key[0] === true) { + // Key validated + return res.json({ + valid: 'Key validated.', + name: key[1].name, + created_at: key[1].created_at + }) + } else { + // Invalid key provided + return res.status(403).json({ + error: 'Invalid key provided.' + }) + } + }) } else { // No key provided - return res.json({ + return res.status(400).json({ error: 'No API key provided.' }) } }) -/* Invalid API Routes */ +/** + * Invalid API Routes : /api/ + * Possible Statuses: 405 + * Purpose: Displays an error when an invalid route is called. + */ router.all('*', (req, res) => { return res.status(405).json({ code: res.statusCode,