diff --git a/.gitignore b/.gitignore index b20441f..75543b3 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,5 @@ dist #Modun Globals logs tests -data/keys \ No newline at end of file +data/keys +tests \ No newline at end of file diff --git a/modules/databaseGlobals.js b/modules/databaseGlobals.js index d55e327..27f93af 100644 --- a/modules/databaseGlobals.js +++ b/modules/databaseGlobals.js @@ -45,7 +45,7 @@ async function setupDatabase() { name VARCHAR(256) NOT NULL, value VARCHAR(512) NOT NULL, uuid VARCHAR(36) NOT NULL, - UNIQUE KEY unique_property (uuid, name), + UNIQUE KEY uniqueProperty (uuid, name), FOREIGN KEY (uuid) REFERENCES players(uuid) ON DELETE CASCADE ) `) @@ -142,7 +142,7 @@ async function setupDatabase() { await conn.query(` CREATE TABLE IF NOT EXISTS banReasons ( id INTEGER PRIMARY KEY AUTO_INCREMENT, - reason_key VARCHAR(512) UNIQUE NOT NULL + reasonKey VARCHAR(512) UNIQUE NOT NULL ) `) logger.log(`${"banReasons".bold} table ready`, ["MariaDB", "yellow"]) @@ -347,32 +347,42 @@ async function setupDatabase() { logger.log(`${"clean_expired_certificates".bold} event ready`, ["MariaDB", "yellow"]) await conn.query(` - CREATE TABLE IF NOT EXISTS api_administrators ( + CREATE TABLE IF NOT EXISTS apiAdministrators ( id INTEGER PRIMARY KEY AUTO_INCREMENT, username VARCHAR(255) UNIQUE NOT NULL, password TEXT NOT NULL, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP ) `) - logger.log(`${"api_administrators".bold} table ready`, ["MariaDB", "yellow"]) + logger.log(`${"apiAdministrators".bold} table ready`, ["MariaDB", "yellow"]) await conn.query(` - CREATE TABLE IF NOT EXISTS api_administrators_permissions_list ( - permission_key VARCHAR(64) PRIMARY KEY + CREATE TABLE IF NOT EXISTS apiAdministratorsPermissionsList ( + permissionKey VARCHAR(64) PRIMARY KEY ) `) - logger.log(`${"api_administrators_permissions_list".bold} table ready`, ["MariaDB", "yellow"]) + logger.log(`${"apiAdministratorsPermissionsList".bold} table ready`, ["MariaDB", "yellow"]) await conn.query(` - CREATE TABLE IF NOT EXISTS api_administrators_permissions ( - administrator_id INTEGER NOT NULL, - permission_key VARCHAR(64) NOT NULL, - PRIMARY KEY (administrator_id, permission_key), - FOREIGN KEY (administrator_id) REFERENCES api_administrators(id) ON DELETE CASCADE, - FOREIGN KEY (permission_key) REFERENCES api_administrators_permissions_list(permission_key) ON DELETE CASCADE + CREATE TABLE IF NOT EXISTS apiAdministratorsPermissions ( + administratorId INTEGER NOT NULL, + permissionKey VARCHAR(64) NOT NULL, + PRIMARY KEY (administratorId, permissionKey), + FOREIGN KEY (administratorId) REFERENCES apiAdministrators(id) ON DELETE CASCADE, + FOREIGN KEY (permissionkey) REFERENCES apiAdministratorsPermissionsList(permissionKey) ON DELETE CASCADE ) `) - logger.log(`${"api_administrators_permissions".bold} table ready`, ["MariaDB", "yellow"]) + logger.log(`${"apiAdministratorsPermissions".bold} table ready`, ["MariaDB", "yellow"]) + + await conn.query(` + CREATE TABLE IF NOT EXISTS oaauth2LinkAttempts ( + OAuth2LinkId VARCHAR(255) NOT NULL, + playerUuid VARCHAR(255) NOT NULL, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (playerUuid) REFERENCES players(uuid) ON DELETE CASCADE + ) + `) + logger.log(`${"oaauth2LinkAttempts".bold} table ready`, ["MariaDB", "yellow"]) logger.log("MariaDB database successfully initialised!", ["MariaDB", "yellow"]) diff --git a/modules/utils.js b/modules/utils.js index 40a0860..353cc69 100644 --- a/modules/utils.js +++ b/modules/utils.js @@ -55,7 +55,13 @@ function isTrueFromDotEnv(key) { return (process.env[key] || "").trim().toLowerCase() === "true" } +function getUrlParam(url, param) { + const urlParams = new URLSearchParams(url) + return urlParams.get(param) +} + module.exports = { + getUrlParam, signProfileData, addDashesToUUID, isTrueFromDotEnv, diff --git a/package-lock.json b/package-lock.json index 9a2d1b8..8b7524b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "base-rest-api", + "name": "yggdrasil", "version": "0.0.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "base-rest-api", + "name": "yggdrasil", "version": "0.0.1-alpha", "license": "AGPL-3.0-only", "dependencies": { + "@mgalacyber/discord-oauth2": "^1.9.6", "bcryptjs": "^3.0.3", "colors": "^1.4.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", @@ -240,6 +242,36 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@mgalacyber/discord-oauth2": { + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/@mgalacyber/discord-oauth2/-/discord-oauth2-1.9.6.tgz", + "integrity": "sha512-sSzi5aikr+32WnVvkfDpSu0SDtBh0anwvWRB0WfsjUrd4/WqfXPXTu0G2kqQOmcvXEQzws1/8wFbDymklUELJw==", + "license": "Apache-2.0", + "dependencies": { + "@mgalacyber/package-notifier": "^1.0.1", + "undici": "^6.16.0" + } + }, + "node_modules/@mgalacyber/package-notifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mgalacyber/package-notifier/-/package-notifier-1.0.1.tgz", + "integrity": "sha512-s/QgExSx4RRbVV73hMbBBZvkHjQ6WzOAc7tYJ/82JHMEabwbiQYoIdRJAH6y3MmAKo11+wKFCovHaM6oCPnpYA==", + "license": "Apache-2.0", + "dependencies": { + "@mgalacyber/termbox": "^1.0.0", + "axios": "^1.6.7", + "string-width": "^2.1.1" + } + }, + "node_modules/@mgalacyber/termbox": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mgalacyber/termbox/-/termbox-1.0.0.tgz", + "integrity": "sha512-0tQL5lJw9NW1cND5loLgj5hrvJmIIY2vBWVsXsIz4pQEZR3KasMUKBcOxAKclMaoy19Y5sgqge018M67AgqkhA==", + "license": "Apache-2.0", + "dependencies": { + "string-width": "^2.1.1" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -322,6 +354,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -365,6 +406,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -607,6 +665,18 @@ "node": ">=0.1.90" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -660,6 +730,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -721,6 +810,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -819,6 +917,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1181,6 +1294,63 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1320,6 +1490,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1524,6 +1709,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2157,6 +2351,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -2498,6 +2698,31 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2596,6 +2821,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 2ef1169..2ffc696 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "base-rest-api", + "name": "yggdrasil", "version": "0.0.1-alpha", "description": "", "repository": { @@ -23,8 +23,10 @@ "homepage": "https://gitea.azures.fr/azures04/Yggdrasil", "readme": "https://gitea.azures.fr/azures04/Yggdrasil/src/branch/main/README.md", "dependencies": { + "@mgalacyber/discord-oauth2": "^1.9.6", "bcryptjs": "^3.0.3", "colors": "^1.4.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", diff --git a/repositories/adminRepository.js b/repositories/adminRepository.js index ed0d1ca..296226e 100644 --- a/repositories/adminRepository.js +++ b/repositories/adminRepository.js @@ -4,7 +4,7 @@ const { DefaultError } = require("../errors/errors") async function getAdminById(id) { try { - const sql = "SELECT id, username, createdAt FROM api_administrators WHERE id = ?" + const sql = "SELECT id, username, createdAt FROM apiAdministrators WHERE id = ?" const rows = await database.query(sql, [id]) return rows[0] || null } catch (error) { @@ -15,7 +15,7 @@ async function getAdminById(id) { async function createAdmin(username, hashedPassword) { try { - const sql = "INSERT INTO api_administrators (username, password) VALUES (?, ?)" + const sql = "INSERT INTO apiAdministrators (username, password) VALUES (?, ?)" const result = await database.query(sql, [username, hashedPassword]) if (result.affectedRows > 0) { @@ -36,7 +36,7 @@ async function hasPermission(adminId, permissionKey) { try { const sql = ` SELECT COUNT(*) as count - FROM api_administrators_permissions + FROM apiAdministrators_permissions WHERE administrator_id = ? AND permission_key = ? ` const rows = await database.query(sql, [adminId, permissionKey]) @@ -49,7 +49,7 @@ async function hasPermission(adminId, permissionKey) { async function assignPermission(adminId, permissionKey) { try { - const sql = "INSERT INTO api_administrators_permissions (administrator_id, permission_key) VALUES (?, ?)" + const sql = "INSERT INTO apiAdministrators_permissions (administrator_id, permission_key) VALUES (?, ?)" const result = await database.query(sql, [adminId, permissionKey]) return result.affectedRows > 0 @@ -62,7 +62,7 @@ async function assignPermission(adminId, permissionKey) { async function revokePermission(adminId, permissionKey) { try { - const sql = "DELETE FROM api_administrators_permissions WHERE administrator_id = ? AND permission_key = ?" + const sql = "DELETE FROM apiAdministrators_permissions WHERE administrator_id = ? AND permission_key = ?" const result = await database.query(sql, [adminId, permissionKey]) return result.affectedRows > 0 @@ -76,7 +76,7 @@ async function getAdminPermissions(adminId) { try { const sql = ` SELECT permission_key - FROM api_administrators_permissions + FROM apiAdministrators_permissions WHERE administrator_id = ? ` const rows = await database.query(sql, [adminId]) @@ -89,7 +89,7 @@ async function getAdminPermissions(adminId) { async function updateAdminPassword(adminId, newHashedPassword) { try { - const sql = "UPDATE api_administrators SET password = ? WHERE id = ?" + const sql = "UPDATE apiAdministrators SET password = ? WHERE id = ?" const result = await database.query(sql, [newHashedPassword, adminId]) if (result.affectedRows > 0) { @@ -109,7 +109,7 @@ async function updateAdminPassword(adminId, newHashedPassword) { async function getAdminByUsername(username) { try { - const sql = "SELECT id, username, password, createdAt FROM api_administrators WHERE username = ?" + const sql = "SELECT id, username, password, createdAt FROM apiAdministrators WHERE username = ?" const rows = await database.query(sql, [username]) return rows[0] || null diff --git a/repositories/oauth2Repository.js b/repositories/oauth2Repository.js new file mode 100644 index 0000000..d069385 --- /dev/null +++ b/repositories/oauth2Repository.js @@ -0,0 +1,55 @@ +const logger = require("../modules/logger") +const database = require("../modules/database") +const { DefaultError } = require("../errors/errors") + +async function createLinkAttempt(OAuth2LinkId, playerUuid) { + try { + const sql = ` + INSERT INTO oaauth2LinkAttempts (OAuth2LinkId, playerUuid) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE playerUuid = VALUES(playerUuid), createdAt = NOW() + ` + const result = await database.query(sql, [OAuth2LinkId, playerUuid]) + return result.affectedRows > 0 + } catch (error) { + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function popLinkAttempt(OAuth2LinkId) { + try { + const selectSql = "SELECT playerUuid FROM oaauth2LinkAttempts WHERE OAuth2LinkId = ?" + const rows = await database.query(selectSql, [OAuth2LinkId]) + + if (rows.length === 0) return null + + const playerUuid = rows[0].playerUuid + + const deleteSql = "DELETE FROM oaauth2LinkAttempts WHERE OAuth2LinkId = ?" + await database.query(deleteSql, [OAuth2LinkId]) + + return playerUuid + } catch (error) { + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function unlinkProviderAccount(provider, playerUuid) { + try { + const sql = `DELETE FROM playersProperties WHERE name = '${provider}Id' AND uuid = ?` + const result = await database.query(sql, [playerUuid]) + + return result.affectedRows > 0 + } catch (error) { + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +module.exports = { + popLinkAttempt, + createLinkAttempt, + unlinkProviderAccount +} \ No newline at end of file diff --git a/repositories/userRepository.js b/repositories/userRepository.js index e6917eb..8e2d631 100644 --- a/repositories/userRepository.js +++ b/repositories/userRepository.js @@ -90,6 +90,31 @@ async function getPlayerProperty(key, uuid) { } } +async function getPlayerPropertyByValue(key, value) { + try { + const sql = `SELECT * FROM playersProperties WHERE name = ? AND value = ?` + const rows = await database.query(sql, [key, value]) + const property = rows[0] + + if (!property) { + throw new DefaultError(404, "No property found with this value for the specified key") + } + + return { + code: 200, + property: { + name: property.name, + value: property.value, + uuid: property.uuid + } + } + } catch (error) { + if (error instanceof DefaultError) throw error + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Please contact an administrator.", "InternalServerError") + } +} + async function getPlayerSettingsSchema() { const RAW_SCHEMA_CACHE = { privileges: {}, @@ -213,11 +238,11 @@ async function banUser(uuid, { reasonKey, reasonMessage, expires = null }) { } let reasonId - const reasonRows = await database.query("SELECT id FROM banReasons WHERE reason_key = ?", [reasonKey]) + const reasonRows = await database.query("SELECT id FROM banReasons WHERE reasonKey = ?", [reasonKey]) if (reasonRows.length > 0) { reasonId = reasonRows[0].id } else { - const insertReason = await database.query("INSERT INTO banReasons (reason_key) VALUES (?)", [reasonKey]) + const insertReason = await database.query("INSERT INTO banReasons (reasonKey) VALUES (?)", [reasonKey]) reasonId = insertReason.insertId } @@ -279,7 +304,7 @@ async function getPlayerBans(uuid) { b.banId, b.expires, b.reasonMessage, - r.reason_key as reason + r.reasonKey as reason FROM bans b JOIN banReasons r ON b.reason = r.id WHERE b.uuid = ? @@ -770,5 +795,6 @@ module.exports = { updatePropertyToPlayer, getPlayerSettingsSchema, updatePlayerPreferences, + getPlayerPropertyByValue, deleteExpiredCertificates, } \ No newline at end of file diff --git a/routes/auth/provider/discord.js b/routes/auth/provider/discord.js new file mode 100644 index 0000000..698aea3 --- /dev/null +++ b/routes/auth/provider/discord.js @@ -0,0 +1,15 @@ +const express = require("express") +const router = express.Router() +const oauth2Service = require("../../../services/oauth2Service") + +router.get("/login", async (req, res) => { + const redirectObject = await oauth2Service.generateLoginDiscordURL() + return res.status(200).redirect(redirectObject.url) +}) + +router.get("/login/callback", async (req, res) => { + const result = await oauth2Service.handleLoginCallback("discord", req.query.code, req.query.requestUser) + return res.status(result.code).json(result.response) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/authserver/authenticate.js b/routes/authserver/authenticate.js index 56ee8ed..e703138 100644 --- a/routes/authserver/authenticate.js +++ b/routes/authserver/authenticate.js @@ -1,6 +1,6 @@ const express = require("express") const router = express.Router() -const { YggdrasilError } = require("../../errors/errors") +const { YggdrasilError, DefaultError } = require("../../errors/errors") const rateLimit = require("express-rate-limit") const authService = require("../../services/authService") const logger = require("../../modules/logger") diff --git a/routes/link/discord.js b/routes/link/discord.js new file mode 100644 index 0000000..d5d3383 --- /dev/null +++ b/routes/link/discord.js @@ -0,0 +1,26 @@ +const express = require("express") +const router = express.Router() +const authService = require("../../services/authService") +const oauth2Service = require("../../services/oauth2Service") + +router.get("/redirect", async (req, res) => { + const accessToken = req.headers.authorization.replace("Bearer ", "") + const player = await authService.verifyAccessToken({ accessToken }) + const redirectObject = await oauth2Service.generateAssociationDiscordURL(player.user.uuid) + return res.json({ url: redirectObject.url }) +}) + +router.get("/link", async (req, res) => { + const { code, state } = req.query + const result = await oauth2Service.handleAssociationCallback("discord", code, state) + return res.status(200).json(result) +}) + +router.delete("/link", async (req, res) => { + const accessToken = req.headers.authorization.replace("Bearer ", "") + const player = await authService.verifyAccessToken({ accessToken }) + const result = await oauth2Service.unlinkAccount("discord", player.user.uuid) + return res.status(result.code).json(result) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/register.js b/routes/register.js index 5ca62fa..c46046f 100644 --- a/routes/register.js +++ b/routes/register.js @@ -5,7 +5,7 @@ const logger = require("../modules/logger") const authService = require("../services/authService") const adminService = require("../services/adminService") -if (utils.isTrueFromDotEnv("SUPPORT_REGISTER")) { +if (!utils.isTrueFromDotEnv("SUPPORT_REGISTER")) { router.post("/", adminService.hasPermission("REGISTER_USER"), async (req, res) => { const { username, password, email, registrationCountry, preferredLanguage } = req.body const clientIp = req.headers["x-forwarded-for"] || req.connection.remoteAddress diff --git a/schemas/link/discord/link.js b/schemas/link/discord/link.js new file mode 100644 index 0000000..f846b0b --- /dev/null +++ b/schemas/link/discord/link.js @@ -0,0 +1,24 @@ +const z = require("zod") + +module.exports = { + GET: { + query: z.object({ + code: z.string({ required_error: "Authorisation code required" }), + state: z.string({ required_error: "The state parameter is required." }) + }), + error: { + code: 400, + message: "Invalid Discord callback settings" + } + }, + DELETE: { + headers: z.object({ + authorization: z.string({ required_error: "The authentication token is required." }) + .regex(/^Bearer\s.+/, { message: "Invalid Authorization header format (Bearer token expected)" }) + }), + error: { + code: 401, + message: "Authentication required for disassociation" + } + } +} \ No newline at end of file diff --git a/schemas/link/discord/redirect.js b/schemas/link/discord/redirect.js new file mode 100644 index 0000000..e1dfb70 --- /dev/null +++ b/schemas/link/discord/redirect.js @@ -0,0 +1,14 @@ +const z = require("zod") + +module.exports = { + GET: { + headers: z.object({ + authorization: z.string({ required_error: "The authentication token is required." }) + .regex(/^Bearer\s.+/, { message: "Invalid Authorization header format (Bearer token expected)" }) + }), + error: { + code: 401, + message: "Authentication required to generate the link URL" + } + } +} \ No newline at end of file diff --git a/server.js b/server.js index 1274077..84add59 100644 --- a/server.js +++ b/server.js @@ -26,6 +26,7 @@ app.use(cors({ origin: "*" })) app.use(express.json()) app.use(express.urlencoded({ extended: true })) +// app.use(cookieParser()) app.set("trust proxy", true) diff --git a/services/adminService.js b/services/adminService.js index 3e8dbce..355d03b 100644 --- a/services/adminService.js +++ b/services/adminService.js @@ -139,13 +139,13 @@ async function logPlayerAction(playerUuid, actionCode) { module.exports = { loginAdmin, uploadCape, + deleteCape, registerAdmin, hasPermission, getAdminProfile, grantPermission, + logPlayerAction, revokePermission, checkAdminAccess, - deleteCape, - logPlayerAction, - changeAdminPassword, + changeAdminPassword } \ No newline at end of file diff --git a/services/authService.js b/services/authService.js index 61e5966..45161d6 100644 --- a/services/authService.js +++ b/services/authService.js @@ -61,6 +61,7 @@ async function authenticate({ identifier, password, clientToken, requireUser }) uuid: userResult.user.uuid, username: userResult.user.username, clientToken: $clientToken, + type: "Minecraft" }, keys.authenticationKeys.private, { subject: userResult.user.uuid, issuer: "LentiaYggdrasil", @@ -105,6 +106,70 @@ async function authenticate({ identifier, password, clientToken, requireUser }) } } +async function authenticateWithoutPassword({ identifier, requireUser }) { + let userResult + try { + userResult = await authRepository.getUser(identifier, true) + } catch (error) { + if (error.code === 404) { + throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException") + } + throw error + } + + delete userResult.user.password + + const $clientToken = crypto.randomUUID() + const accessToken = jwt.sign({ + uuid: userResult.user.uuid, + username: userResult.user.username, + clientToken: $clientToken, + type: "Minecraft_OAuth2" + }, keys.authenticationKeys.private, { + subject: userResult.user.uuid, + issuer: "LentiaYggdrasil", + expiresIn: "1d", + algorithm: "RS256" + }) + + const clientSessionProcess = await authRepository.insertClientSession(accessToken, $clientToken, userResult.user.uuid) + + const userObject = { + clientToken: clientSessionProcess.clientToken, + accessToken: clientSessionProcess.accessToken, + availableProfiles: [{ + name: userResult.user.username, + id: userResult.user.uuid, + }], + selectedProfile: { + name: userResult.user.username, + id: userResult.user.uuid, + } + } + + if (requireUser) { + try { + const propertiesRequest = await authRepository.getPlayerProperties(userResult.user.uuid) + userObject.user = { + username: userResult.user.username, + properties: propertiesRequest.properties + } + } catch (error) { + if (error.code !== 404) throw error + userObject.user = { + username: userResult.user.username, + properties: [] + } + } + } + + return { + code: 200, + response: userObject + } +} + + async function refreshToken({ previousAccessToken, clientToken, requireUser }) { let sessionCheck try { @@ -125,6 +190,7 @@ async function refreshToken({ previousAccessToken, clientToken, requireUser }) { uuid: userResult.user.uuid, username: userResult.user.username, clientToken: $clientToken, + type: "Minecraft" }, keys.authenticationKeys.private, { subject: userResult.user.uuid, issuer: "LentiaYggdrasil", @@ -281,9 +347,10 @@ module.exports = { signout, validate, invalidate, - registerUser, authenticate, refreshToken, + registerUser, verifyAccessToken, - checkUsernameAvailability + checkUsernameAvailability, + authenticateWithoutPassword, } \ No newline at end of file diff --git a/services/oauth2Service.js b/services/oauth2Service.js new file mode 100644 index 0000000..1568790 --- /dev/null +++ b/services/oauth2Service.js @@ -0,0 +1,134 @@ +const { DiscordOAuth2 } = require("@mgalacyber/discord-oauth2") +const oauth2Repository = require("../repositories/oauth2Repository") +const userService = require("./userService") +const authService = require("./authService") +const { StateTypes, Scopes, PromptTypes, ResponseCodeTypes } = require("@mgalacyber/discord-oauth2") +const { DefaultError } = require("../errors/errors") + +const oauth2_association = new DiscordOAuth2({ + clientId: process.env.DISCORD_CLIENT_ID, + clientSecret: process.env.DISCORD_CLIENT_SECRET, + redirectUri: process.env.DISCORD_ASSOCIATION_REDIRECT_URL +}) + +const oauth2_login = new DiscordOAuth2({ + clientId: process.env.DISCORD_CLIENT_ID, + clientSecret: process.env.DISCORD_CLIENT_SECRET, + redirectUri: process.env.DISCORD_LOGIN_REDIRECT_URL +}) + +async function generateAssociationDiscordURL(playerUuid) { + const redirectObject = await oauth2_association.GenerateOAuth2Url({ + state: StateTypes.UserAuth, + scope: [ + Scopes.Identify + ], + prompt: PromptTypes.Consent, + responseCode: ResponseCodeTypes.Code, + }) + await oauth2Repository.createLinkAttempt(redirectObject.state, playerUuid) + return redirectObject +} + +async function handleAssociationCallback(provider, code, state) { + const playerUuid = await oauth2Repository.popLinkAttempt(state) + if (!playerUuid) { + throw new DefaultError(400, "Invalid or expired session state.", "InvalidStateError") + } + + let isProviderAlreadyLinked = false + + try { + await userService.getPlayerProperty(playerUuid, `${provider}Id`) + isProviderAlreadyLinked = true + } catch (error) { + if (error.code !== 404) throw error + } + + if (isProviderAlreadyLinked) { + throw new DefaultError(409, `Account from ${provider} already linked to that player`, "AlreadyLinkedException") + } + + try { + const tokenResponse = await oauth2_association.GetAccessToken(code) + const userProfile = await oauth2_association.UserDataSchema.GetUserProfile(tokenResponse.accessToken) + if (!userProfile || !userProfile.id) { + throw new DefaultError(500, `Failed to retrieve ${provider} profile.`) + } + + await userService.addPlayerProperty(playerUuid, `${provider}Id`, userProfile.id) + return { + code: 200, + message: "Account linked successfully", + provider: { + id: userProfile.id, + username: userProfile.username + } + } + } catch (error) { + if (error instanceof DefaultError) throw error + throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`) + } +} + +async function unlinkAccount(provider, playerUuid) { + try { + const property = await userService.getPlayerProperty(playerUuid, `${provider}Id`).catch(() => null) + if (!property) { + throw new DefaultError(404, `No ${provider} account linked to this player.`, "NotLinkedError") + } + + const success = await oauth2Repository.unlinkProviderAccount(provider, playerUuid) + if (!success) { + throw new DefaultError(500, "Failed to unlink the account. Please try again.") + } + + return { + code: 200, + message: `${provider} account successfully unlinked.` + } + } catch (error) { + if (error instanceof DefaultError) throw error; + throw new DefaultError(500, "An error occurred during unlinking: " + error.message) + } +} + +async function generateLoginDiscordURL() { + const redirectObject = await oauth2_login.GenerateOAuth2Url({ + state: StateTypes.UserAuth, + scope: [ + Scopes.Identify + ], + prompt: PromptTypes.Consent, + responseCode: ResponseCodeTypes.Code, + }) + return redirectObject +} + +async function handleLoginCallback(provider, code, requestUser) { + try { + const tokenResponse = await oauth2_login.GetAccessToken(code) + const userProfile = await oauth2_login.UserDataSchema.GetUserProfile(tokenResponse.accessToken) + if (!userProfile || !userProfile.id) { + throw new DefaultError(500, `Failed to retrieve ${provider} profile.`) + } + + const propertyObject = await userService.getPlayerPropertyByValue(`${provider}Id`, userProfile.id) + return await authService.authenticateWithoutPassword({ identifier: propertyObject.property.uuid, requireUser: requestUser || true }) + + } catch (error) { + if (error.code == 404) { + throw new DefaultError(404, `No ${provider} account linked to any player.`, "NotLinkedError") + } + if (error instanceof DefaultError) throw error + throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`) + } +} + +module.exports = { + unlinkAccount, + handleLoginCallback, + generateLoginDiscordURL, + handleAssociationCallback, + generateAssociationDiscordURL +} \ No newline at end of file diff --git a/services/userService.js b/services/userService.js index 770b1ae..7b31ca6 100644 --- a/services/userService.js +++ b/services/userService.js @@ -29,6 +29,10 @@ async function getPlayerProperty(uuid, key) { return await userRepository.getPlayerProperty(key, uuid) } +async function getPlayerPropertyByValue(key, value) { + return await userRepository.getPlayerPropertyByValue(key, value) +} + async function addPlayerProperty(uuid, key, value) { return await userRepository.addPropertyToPlayer(key, value, uuid) } @@ -561,6 +565,7 @@ module.exports = { getPlayerCertificate, savePlayerCertificate, clearAllPlayerActions, + getPlayerPropertyByValue, getPlayerNameChangeStatus, getPlayerUsernamesHistory, deleteExpiredCertificates,