From 22542243f6ba85e1c28abdba1fcd24086d976d76 Mon Sep 17 00:00:00 2001 From: Azure Date: Wed, 2 Sep 2026 01:55:58 +0200 Subject: [PATCH] Add auth, JWT tokens, and bootstrap startup Introduce bootstrap entrypoint and prestartup to ping DB and generate/manage RSA keypair. Add security module for key IO. Implement JWT-based access and refresh tokens (tokensService) with refresh_tokens table and repo. Add auth, provider and user services, and refresh-token management. Update repositories (users, credentials, providers, servers) with SQL fixes and new helper methods. Move DDL execution to bootstrap (remove from server). Update package.json main and add dependencies (bcryptjs, jsonwebtoken). Update .env.example and .gitignore accordingly. --- .env.example | 10 ++- .gitignore | 8 +- bootstrap.js | 17 ++++ data/ddl/01_users.sql | 3 +- data/ddl/05_tokens.sql | 7 ++ modules/prestartup.js | 53 ++++++++++++ modules/security.js | 61 ++++++++++++++ package-lock.json | 132 +++++++++++++++++++++++++++++- package.json | 4 +- repositories/credentialsRepo.js | 16 ++-- repositories/identitiesRepo.js | 4 +- repositories/providersRepo.js | 17 +++- repositories/refreshTokensRepo.js | 71 ++++++++++++++++ repositories/serversRepo.js | 17 +++- repositories/usersRepo.js | 22 ++++- server.js | 3 - services/authService.js | 83 +++++++++++++++++++ services/providerService.js | 49 +++++++++++ services/tokensService.js | 103 +++++++++++++++++++++++ services/userService.js | 34 ++++++++ 20 files changed, 686 insertions(+), 28 deletions(-) create mode 100644 bootstrap.js create mode 100644 data/ddl/05_tokens.sql create mode 100644 modules/prestartup.js create mode 100644 modules/security.js create mode 100644 repositories/refreshTokensRepo.js create mode 100644 services/authService.js create mode 100644 services/providerService.js create mode 100644 services/tokensService.js create mode 100644 services/userService.js 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