diff --git a/.env.example b/.env.example index 75419ca..f26b81b 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,12 @@ IS_PROD=FALSE DATABASE_HOST="host" DATABASE_USER="changeme" DATABASE_PASSWORD="password" -DATABASE_NAME="tcjourney" \ No newline at end of file +DATABASE_NAME="tcjourney" + +#Security +BCRYPT_SALT="12" +ACCESS_TOKEN_EXPIRY="432000" #TIME IN SECONDES +REFRESH_TOKEN_EXPIRY="2592000" #TIME IN SECONDES + +#LocalAuth +SERVER_UUID="00000000-0000-0000-0000-000000000000" #DON'T CHANGE IT!!! diff --git a/.gitignore b/.gitignore index 7500a08..2576c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,10 @@ dist .pnp.* #logs -logs \ No newline at end of file +logs + +#secrets +secrets/ + +#tests +test.js \ No newline at end of file diff --git a/bootstrap.js b/bootstrap.js new file mode 100644 index 0000000..bbb6b76 --- /dev/null +++ b/bootstrap.js @@ -0,0 +1,17 @@ +const prestartup = require("./modules/prestartup") +const database = require("./modules/database") +const logger = require("./modules/logger") + +async function boot() { + try { + await prestartup.pingDb() + await database.runDDL() + await prestartup.setupKeys() + require("./server") + } catch (error) { + logger.error("Error while booting", ["STARTUP", "cyan"]) + logger.error(error.toString(), ["STARTUP", "cyan"]) + } +} + +boot() \ No newline at end of file diff --git a/data/ddl/01_users.sql b/data/ddl/01_users.sql index 70c7f7f..25227ec 100644 --- a/data/ddl/01_users.sql +++ b/data/ddl/01_users.sql @@ -1,8 +1,9 @@ CREATE TABLE IF NOT EXISTS `users` ( `id` UUID PRIMARY KEY DEFAULT UUID(), `serverId` UUID NULL, + `identifier` VARCHAR(255) NOT NULL UNIQUE, `remoteId` VARCHAR(255) NULL, - `displayName` VARCHAR(255) NOT NULL, + `displayName` VARCHAR(512) NOT NULL, `avatarUrl` TEXT NULL, `createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/data/ddl/05_tokens.sql b/data/ddl/05_tokens.sql new file mode 100644 index 0000000..9d500b8 --- /dev/null +++ b/data/ddl/05_tokens.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS `refresh_tokens` ( + `userId` UUID PRIMARY KEY, + `tokenHash` TEXT NOT NULL, + `expiresAt` TIMESTAMP NOT NULL, + + CONSTRAINT `fk_refreshTokens_userId` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE +) \ No newline at end of file diff --git a/modules/prestartup.js b/modules/prestartup.js new file mode 100644 index 0000000..55b27ee --- /dev/null +++ b/modules/prestartup.js @@ -0,0 +1,53 @@ +const security = require("./security") +const serversRepo = require("../repositories/serversRepo") +const logger = require("./logger") +const { pool } = require("./database") + +const LOCAL_SERVER_ID = process.env.SERVER_UUID + +async function setupKeys() { + const existing = await serversRepo.findById(LOCAL_SERVER_ID) + if (existing) { + checkKeys(existing.publicKey) + return existing + } + + const { privateKeyPem, publicKeyPem } = security.generateServerKeyPair() + security.saveKeyPairToDisk({ privateKeyPem, publicKeyPem }) + + logger.log("Local server keypair generated and stored in data/secrets", ["KEYS", "yellow"]) + + return await serversRepo.createAndAssignIdManually(LOCAL_SERVER_ID, "internal", publicKeyPem) +} + +function checkKeys(publicKeyFromDb) { + const { privateExists, publicExists } = security.keyFilesExist() + + if (!privateExists) { + throw new Error( + "Private key file missing for local server, but server row exists in DB.", + "Cannot regenerate without invalidating existing signatures/tokens." + ) + } + + if (!publicExists) { + security.restorePublicKeyFile(publicKeyFromDb) + logger.warn("Public key file was missing, restored from DB.", ["KEYS", "yellow"]) + } +} + +async function pingDb() { + try { + await pool.query("SELECT 1") + } catch (err) { + console.log(err) + logger.error("Could'nt connect to db", ["PRESTARTUP", "cyan"]) + process.exit(1) + } +} + +module.exports = { + pingDb, + checkKeys, + setupKeys, +} \ No newline at end of file diff --git a/modules/security.js b/modules/security.js new file mode 100644 index 0000000..9cb37f9 --- /dev/null +++ b/modules/security.js @@ -0,0 +1,61 @@ +const fs = require("node:fs") +const path = require("node:path") +const crypto = require("node:crypto") + +const SECRETS_DIR = path.join(process.cwd(), "data", "secrets") +const PRIVATE_KEY = path.join(SECRETS_DIR, "server.private.pem") +const PUBLIC_KEY = path.join(SECRETS_DIR, "server.public.pem") + +function generateServerKeyPair() { + const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }) + + return { + privateKeyPem: privateKey, + publicKeyPem: publicKey, + } +} + +function saveKeyPairToDisk({ publicKeyPem, privateKeyPem }) { + fs.mkdirSync(SECRETS_DIR, { recursive: true }) + fs.writeFileSync(PRIVATE_KEY, privateKeyPem, { mode: 0o600 }) + fs.writeFileSync(PUBLIC_KEY, publicKeyPem, { mode: 0o644 }) +} + +function keyFilesExist() { + return { + privateExists: fs.existsSync(PRIVATE_KEY), + publicExists: fs.existsSync(PUBLIC_KEY), + } +} + +function restorePublicKeyFile(publicKeyPem) { + fs.mkdirSync(SECRETS_DIR, { recursive: true }) + fs.writeFileSync(PUBLIC_KEY, publicKeyPem, { mode: 0o644 }) +} + +function readPrivateKey() { + return fs.readFileSync(PRIVATE_KEY, "utf8") +} + +function readPublicKey() { + return fs.readFileSync(PUBLIC_KEY, "utf8") +} + +module.exports = { + generateServerKeyPair, + saveKeyPairToDisk, + keyFilesExist, + restorePublicKeyFile, + readPrivateKey, + readPublicKey +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d771c2a..c02758c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.0.1-alpha", "license": "AGPL-3.0-only", "dependencies": { + "bcryptjs": "^3.0.3", "colors": "^1.4.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", "helmet": "^8.3.0", "hpp": "^0.2.3", + "jsonwebtoken": "^9.0.3", "mariadb": "^3.5.3", "path-to-regexp": "^8.4.2", "zod": "^4.4.3" @@ -333,6 +335,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -396,6 +407,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -608,6 +625,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1375,6 +1401,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1421,6 +1490,48 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -1830,6 +1941,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1840,7 +1971,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index e1d1571..d4b3f41 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "email": "gilleslazure04@gmail.com" }, "type": "commonjs", - "main": "server.js", + "main": "bootstrap.js", "scripts": { "start:dev": "nodemon .", "start": "node .", @@ -23,12 +23,14 @@ "homepage": "https://gitea.azures.fr/azures04/Base-REST-API", "readme": "https://gitea.azures.fr/azures04/Base-REST-API/src/branch/main/README.md", "dependencies": { + "bcryptjs": "^3.0.3", "colors": "^1.4.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", "helmet": "^8.3.0", "hpp": "^0.2.3", + "jsonwebtoken": "^9.0.3", "mariadb": "^3.5.3", "path-to-regexp": "^8.4.2", "zod": "^4.4.3" diff --git a/repositories/credentialsRepo.js b/repositories/credentialsRepo.js index 13739be..447099a 100644 --- a/repositories/credentialsRepo.js +++ b/repositories/credentialsRepo.js @@ -3,7 +3,7 @@ const { DefaultError } = require("../errors/errors") async function findByUserId(userId) { try { - const sql = "SELECT* FROM credentials WHERE userId = ?" + const sql = "SELECT * FROM credentials WHERE userId = ?" const rows = await pool.query(sql, [userId]) return rows[0] || null } catch (error) { @@ -11,20 +11,20 @@ async function findByUserId(userId) { } } -async function create(userId, hashedPassword) { +async function create(userId, passwordHash) { try { - const sql = "INSERT INTO credentials (userId, hashedPassword) VALUES (?, ?)" - const rows = await pool.query(sql, [userId, hashedPassword]) - return rows[0] || null + const sql = "INSERT INTO credentials (userId, passwordHash) VALUES (?, ?)" + const rows = await pool.query(sql, [userId, passwordHash]) + return rows || null } catch (error) { throw new DefaultError(500, "Internal Server Error", error) } } -async function updatePassword(userId, hashedPassword) { +async function updatePassword(userId, passwordHash) { try { - const sql = "UPDATE credentials SET hashedPassword = ? WHERE userId = ?" - const rows = await pool.query(sql, [hashedPassword, userId]) + const sql = "UPDATE credentials SET passwordHash = ? WHERE userId = ?" + const rows = await pool.query(sql, [passwordHash, userId]) return rows.affectedRows > 0 } catch (error) { throw new DefaultError(500, "Internal Server Error", error) diff --git a/repositories/identitiesRepo.js b/repositories/identitiesRepo.js index d36cfe2..2fccdd1 100644 --- a/repositories/identitiesRepo.js +++ b/repositories/identitiesRepo.js @@ -3,7 +3,7 @@ const { DefaultError } = require("../errors/errors") async function findByUserId(userId) { try { - const sql = "SELECT* FROM identities WHERE userId = ?" + const sql = "SELECT * FROM identities WHERE userId = ?" const rows = await pool.query(sql, [userId]) return rows || null } catch (error) { @@ -13,7 +13,7 @@ async function findByUserId(userId) { async function findByProviderAndSubject(providerId, providerUserId) { try { - const sql = "SELECT* FROM identities WHERE providerId = ? AND providerUserId = ?" + const sql = "SELECT * FROM identities WHERE providerId = ? AND providerUserId = ?" const rows = await pool.query(sql, [providerId, providerUserId]) return rows[0] || null } catch (error) { diff --git a/repositories/providersRepo.js b/repositories/providersRepo.js index f153242..f575247 100644 --- a/repositories/providersRepo.js +++ b/repositories/providersRepo.js @@ -3,7 +3,7 @@ const { DefaultError } = require("../errors/errors") async function findById(id) { try { - const sql = "SELECT* FROM providers WHERE id = ?" + const sql = "SELECT * FROM providers WHERE id = ?" const rows = await pool.query(sql, [id]) return rows[0] || null } catch (error) { @@ -13,7 +13,7 @@ async function findById(id) { async function findByName(id) { try { - const sql = "SELECT* FROM providers WHERE name = ?" + const sql = "SELECT * FROM providers WHERE name = ?" const rows = await pool.query(sql, [id]) return rows[0] || null } catch (error) { @@ -23,7 +23,7 @@ async function findByName(id) { async function listEnabled() { try { - const sql = "SELECT* FROM providers WHERE isEnabled = ?" + const sql = "SELECT * FROM providers WHERE isEnabled = ?" const rows = await pool.query(sql, [true]) return rows || [] } catch (error) { @@ -31,6 +31,16 @@ async function listEnabled() { } } +async function listAll() { + try { + const sql = "SELECT * FROM providers" + const rows = await pool.query(sql) + return rows || [] + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + async function setEnabled(id, isEnabled) { try { const sql = "UPDATE providers SET isEnabled = ? WHERE id = ?" @@ -97,6 +107,7 @@ async function remove(id) { module.exports = { remove, create, + listAll, findById, findByName, setEnabled, diff --git a/repositories/refreshTokensRepo.js b/repositories/refreshTokensRepo.js new file mode 100644 index 0000000..f0141e8 --- /dev/null +++ b/repositories/refreshTokensRepo.js @@ -0,0 +1,71 @@ +const { pool } = require("../modules/database") +const { DefaultError } = require("../errors/errors") + +async function create(userId, tokenHash, expiresAt) { + try { + const sql = "INSERT INTO refresh_tokens (userId, tokenHash, expiresAt) VALUES (?, ?, ?)" + const rows = await pool.query(sql, [userId, tokenHash, expiresAt]) + return rows.affectedRows > 0 + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function findByTokenHash(tokenHash) { + try { + const sql = "SELECT * FROM refresh_tokens WHERE tokenHash = ?" + const rows = await pool.query(sql, [tokenHash]) + return rows[0] || null + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function findByUserId(userId) { + try { + const sql = "SELECT * FROM refresh_tokens WHERE userId = ?" + const rows = await pool.query(sql, [userId]) + return rows[0] || [] + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function removeByTokenHash(tokenHash) { + try { + const sql = "DELETE FROM refresh_tokens WHERE tokenHash = ?" + const rows = await pool.query(sql, [tokenHash]) + return rows.affectedRows > 0 + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function removeByUserId(userId) { + try { + const sql = "DELETE FROM refresh_tokens WHERE userId = ?" + const rows = await pool.query(sql, [userId]) + return rows.affectedRows > 0 + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function removeExpired() { + try { + const sql = "DELETE FROM refresh_tokens WHERE expiresAt < NOW()" + const rows = await pool.query(sql) + return rows.affectedRows + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +module.exports = { + create, + findByTokenHash, + findByUserId, + removeByTokenHash, + removeByUserId, + removeExpired, +} \ No newline at end of file diff --git a/repositories/serversRepo.js b/repositories/serversRepo.js index 41506cb..9ebee0b 100644 --- a/repositories/serversRepo.js +++ b/repositories/serversRepo.js @@ -3,7 +3,7 @@ const { DefaultError } = require("../errors/errors") async function findById(id) { try { - const sql = "SELECT* FROM servers WHERE id = ?" + const sql = "SELECT * FROM servers WHERE id = ?" const rows = await pool.query(sql, [id]) return rows[0] || null } catch (error) { @@ -13,7 +13,7 @@ async function findById(id) { async function findByUrl(url) { try { - const sql = "SELECT* FROM servers WHERE url = ?" + const sql = "SELECT * FROM servers WHERE url = ?" const rows = await pool.query(sql, [url]) return rows[0] || null } catch (error) { @@ -31,6 +31,16 @@ async function create(url, publicKey) { } } +async function createAndAssignIdManually(id, url, publicKey) { + try { + const sql = "INSERT INTO servers (id, serverUrl, publicKey) VALUES (?, ?, ?)" + const rows = await pool.query(sql, [id, url, publicKey]) + return rows[0] || null + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + async function remove(id) { try { const sql = "DELETE FROM servers WHERE id = ?" @@ -74,5 +84,6 @@ module.exports = { remove, findById, findByUrl, - updateServer + updateServer, + createAndAssignIdManually } \ No newline at end of file diff --git a/repositories/usersRepo.js b/repositories/usersRepo.js index d1f779c..280d295 100644 --- a/repositories/usersRepo.js +++ b/repositories/usersRepo.js @@ -3,7 +3,7 @@ const { DefaultError } = require("../errors/errors") async function findById(id) { try { - const sql = "SELECT* FROM servers WHERE id = ?" + const sql = "SELECT * FROM users WHERE id = ?" const rows = await pool.query(sql, [id]) return rows[0] || null } catch (error) { @@ -11,16 +11,29 @@ async function findById(id) { } } -async function create(serverId, remoteId = null, displayName, avatarUrl) { +async function findByIdentifier(identifier) { try { - const sql = "INSERT INTO users (serverId, remoteId, displayName, avatarUrl) VALUES (?, ?, ?, ?)" - const rows = await pool.query(sql, [serverId, remoteId, displayName, avatarUrl]) + const sql = "SELECT * FROM users WHERE identifier = ?" + const rows = await pool.query(sql, [identifier]) return rows[0] || null } catch (error) { throw new DefaultError(500, "Internal Server Error", error) } } +async function create(identifier, serverId, remoteId = null, displayName, avatarUrl) { + try { + const sql = "INSERT INTO users (identifier, serverId, remoteId, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?) RETURNING *" + const rows = await pool.query(sql, [identifier, serverId, remoteId, displayName, avatarUrl]) + return rows[0] || null + } catch (error) { + if (error.code && error.code == "ER_DUP_ENTRY") { + throw new DefaultError(401, "Identifier already taken.", "Identifier already assigned.") + } + throw new DefaultError(500, "Internal Server Error", error) + } +} + async function remove(id) { try { const sql = "DELETE FROM users WHERE id = ?" @@ -86,5 +99,6 @@ module.exports = { isLocal, findById, updateProfile, + findByIdentifier, findByServerAndRemoteId } \ No newline at end of file diff --git a/server.js b/server.js index 5d21f39..261e2c6 100644 --- a/server.js +++ b/server.js @@ -25,9 +25,6 @@ app.use(express.urlencoded({ extended: true })) app.set("trust proxy", true) -database.runDDL() -.catch(err => logger.error(err)) - logger.log("Initializing routes", ["WEB", "yellow"]) for (const schemaFile of schemas) { diff --git a/services/authService.js b/services/authService.js new file mode 100644 index 0000000..206f9a6 --- /dev/null +++ b/services/authService.js @@ -0,0 +1,83 @@ +const bcrypt = require("bcryptjs") +const usersRepo = require("../repositories/usersRepo") +const tokensService = require("./tokensService") +const identitiesRepo = require("../repositories/identitiesRepo") +const credentialsRepo = require("../repositories/credentialsRepo") +const { DefaultError } = require("../errors/errors") + +async function registerLocal({ identifier, password, profile = { displayName, avatarURL } }) { + try { + const user = await usersRepo.create(identifier, process.env.SERVER_UUID, null, profile.displayName, profile.avatarURL) + const hashedPassword = await bcrypt.hash(password, Number(process.env.BCRYPT_SALT)) + const credentials = await credentialsRepo.create(user.id, hashedPassword) + if (Array.isArray(credentials)) { + throw new DefaultError(500, "Internal Server Error", "UserRegistration") + } + return user + } catch (error) { + throw error + } +} + +async function validatePassword({ password, passwordHash }) { + try { + await bcrypt.compare(password, passwordHash) + } catch (error) { + throw new DefaultError(401, "Bad credentials", "Wrong password") + } +} + +async function loginLocal({ identifier, password }) { + try { + const user = await usersRepo.findByIdentifier(identifier) + const credentials = await credentialsRepo.findByUserId(user.id) + await validatePassword({ password: password, passwordHash: credentials.passwordHash }) + const accessToken = await tokensService.signToken(user) + const refreshToken = await tokensService.signRefreshToken(user.id) + return { user, tokens: { accessToken, refreshToken } } + } catch (error) { + console.log(error) + throw new DefaultError(500, "Internal Server Error", error.toString()) + } +} + +async function refreshAccessToken({ userId, refreshToken }) { + try { + await tokensService.verifyRefreshToken(refreshToken) + await tokensService.revokeRefreshToken(refreshToken) + const user = await usersRepo.findById(userId) + const newAccessToken = await tokensService.signToken(user) + const newRefreshToken = await tokensService.signRefreshToken(userId) + return { accessToken: newAccessToken, refreshToken: newRefreshToken } + } catch (error) { + throw error + } +} + +async function changePassword({ userId, password, newPassword }) { + try { + const credentials = await credentialsRepo.findByUserId(userId) + await validatePassword({ password, passwordHash: credentials.passwordHash }) + await credentialsRepo.updatePassword(userId, newPassword) + return true + } catch (error) { + throw error + } +} + +async function resetPassword({ userId, newPassword }) { + try { + await credentialsRepo.updatePassword(userId, newPassword) + return true + } catch (error) { + throw error + } +} + +module.exports = { + loginLocal, + resetPassword, + registerLocal, + changePassword, + refreshAccessToken +} \ No newline at end of file diff --git a/services/providerService.js b/services/providerService.js new file mode 100644 index 0000000..b4a9223 --- /dev/null +++ b/services/providerService.js @@ -0,0 +1,49 @@ +const providersRepo = require("../repositories/providersRepo") + +async function getAvailableProviders() { + return await providersRepo.listEnabled() +} + +async function getProviders() { + return await providersRepo.listAll() +} + +async function enableProvider({ providerId }) { + return await providersRepo.setEnabled(providerId, true) +} + +async function disableProvider({ providerId }) { + return await providersRepo.setEnabled(providerId, false) +} + +async function createProvider({ name, issuerLogo, issuerURL }) { + return await providersRepo.create(name, issuerLogo, issuerURL) +} + +async function updateProvider({ id, name, issuerLogo = undefined, issuerURL = undefined }) { + return await providersRepo.updateProvider(id, name, issuerLogo, issuerURL) +} + +async function deleteProvider({ id }) { + return await providersRepo.remove(id) +} + +async function getProvider({ id = undefined, name = undefined }) { + if (typeof id != "undefined") { + return await providersRepo.findById(id) + } else if (typeof name != "undefined") { + return await providersRepo.findByName(name) + } + return undefined +} + +module.exports = { + getProvider, + getProviders, + enableProvider, + updateProvider, + createProvider, + deleteProvider, + disableProvider, + getAvailableProviders, +} \ No newline at end of file diff --git a/services/tokensService.js b/services/tokensService.js new file mode 100644 index 0000000..cf3179b --- /dev/null +++ b/services/tokensService.js @@ -0,0 +1,103 @@ +const jwt = require("jsonwebtoken") +const security = require("../modules/security") +const tokenRepo = require("../repositories/refreshTokensRepo") +const { DefaultError } = require("../errors/errors") + +const privateKey = security.readPrivateKey() +const publicKey = security.readPublicKey() + +async function signToken(user) { + try { + const payload = { + id: user.id, + identifier: user.identifier, + displayName: user.displayName, + avatarUrl: user.avatarUrl + } + const token = await jwt.sign(payload, privateKey, { + algorithm: "RS256", + issuer: process.env.FQDN, + expiresIn: parseInt(process.env.ACCESS_TOKEN_EXPIRY) + }) + return token + } catch (error) { + throw error + } +} + +async function verifyAccessToken(token) { + try { + const result = jwt.verify(token, publicKey, { + algorithms: ["RS256"], + issuer: process.env.FQDN + }) + console.log(result) + return result + } catch (error) { + throw error + } +} + +async function signRefreshToken(userId) { + try { + const isTokenExisting = await tokenRepo.findByUserId(userId) + if (!Array.isArray(isTokenExisting)) { + await tokenRepo.removeByUserId(userId) + } + const now = Date.now() + const expiresIn = parseInt(process.env.REFRESH_TOKEN_EXPIRY) + const expiresAtInMs = new Date(now + (expiresIn * 1000)) + const token = jwt.sign( + { userId }, + privateKey, + { + algorithm: "RS256", + issuer: process.env.FQDN, + expiresIn: expiresIn + } + ) + await tokenRepo.create(userId, token, expiresAtInMs) + return token + } catch (error) { + throw error + } +} + +async function revokeRefreshToken(token) { + try { + return await tokenRepo.removeByTokenHash(token) + } catch (error) { + throw error + } +} + +async function verifyRefreshToken(token) { + try { + const result = jwt.verify(token, publicKey, { + algorithms: ["RS256"], + issuer: process.env.FQDN + }) + if (!result) { + throw new DefaultError(500, "Internal Server Error") + } + const refreshTokenInDb = await tokenRepo.findByTokenHash(token) + if (refreshTokenInDb == null) { + throw new DefaultError(403, "Bad refresh token", "Refresh token doesn't exists") + } + return true + } catch (error) { + throw error + } +} + +async function verifyAccessTokenFromServer(token) { + // TO-DO: Integrate server federation +} + +module.exports = { + signToken, + signRefreshToken, + verifyAccessToken, + revokeRefreshToken, + verifyRefreshToken, +} \ No newline at end of file diff --git a/services/userService.js b/services/userService.js new file mode 100644 index 0000000..16c5605 --- /dev/null +++ b/services/userService.js @@ -0,0 +1,34 @@ +const userRepo = require("../repositories/usersRepo") + +async function getProfile({ userId }) { + return await userRepo.findById(userId) +} + +async function updateProfile({ userId, profile = { displayName, avatarURL } }) { + return await userRepo.updateProfile(userId, profile.displayName, profile.avatarURL) +} + +async function deleteAccount({ userId }) { + return await userRepo.remove(userId) +} + +async function isLocal({ userId }) { + return await userRepo.isLocal(userId) +} + +async function createUser({ identifier, serverId, remoteId = null, profile = { displayName, avatarUrl } }) { + return await userRepo.create(identifier, serverId, remoteId, profile.displayName, profile.avatarUrl) +} + +async function getShadowUser({ serverId, remoteId }) { + return await userRepo.findByServerAndRemoteId(serverId, remoteId) +} + +module.exports = { + isLocal, + getProfile, + createUser, + updateProfile, + deleteAccount, + getShadowUser +} \ No newline at end of file