diff --git a/data/ddl/01_users.sql b/data/ddl/01_users.sql index 25227ec..468070f 100644 --- a/data/ddl/01_users.sql +++ b/data/ddl/01_users.sql @@ -1,11 +1,13 @@ CREATE TABLE IF NOT EXISTS `users` ( `id` UUID PRIMARY KEY DEFAULT UUID(), + `role` VARCHAR(16) NOT NULL DEFAULT 'user', `serverId` UUID NULL, - `identifier` VARCHAR(255) NOT NULL UNIQUE, `remoteId` VARCHAR(255) NULL, - `displayName` VARCHAR(512) NOT NULL, `avatarUrl` TEXT NULL, `createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `identifier` VARCHAR(255) NOT NULL UNIQUE, + `isDisabled` BOOLEAN NOT NULL DEFAULT FALSE, + `displayName` VARCHAR(512) NOT NULL, CONSTRAINT `fk_users_serverId` FOREIGN KEY (`serverId`) REFERENCES `servers` (`id`) ON DELETE CASCADE, CONSTRAINT `uq_users_server_remote` UNIQUE (`serverId`, `remoteId`) diff --git a/data/ddl/04_credentials.sql b/data/ddl/04_credentials.sql index 16330f9..0cb1878 100644 --- a/data/ddl/04_credentials.sql +++ b/data/ddl/04_credentials.sql @@ -1,6 +1,7 @@ CREATE TABLE IF NOT EXISTS `credentials` ( `userId` UUID PRIMARY KEY, `passwordHash` TEXT NOT NULL, + `shouldReset` BOOLEAN NOT NULL DEFAULT FALSE, `updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT `fk_credentials_userId` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE diff --git a/data/ddl/06_bans.sql b/data/ddl/06_bans.sql new file mode 100644 index 0000000..69b733e --- /dev/null +++ b/data/ddl/06_bans.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `bans` ( + `id` UUID PRIMARY KEY DEFAULT UUID(), + `userId` UUID NOT NULL, + `message` TEXT NOT NULL, + `isActive` BOOLEAN NOT NULL DEFAULT TRUE, + `expiresAt` TIMESTAMP NOT NULL, + `createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT `fk_ban_userId` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE +) \ No newline at end of file diff --git a/modules/cache.js b/modules/cache.js new file mode 100644 index 0000000..756df84 --- /dev/null +++ b/modules/cache.js @@ -0,0 +1,21 @@ +const usersCache = new Map() + +function putUser(userId, userObject) { + return usersCache.set(userId, userObject) +} + +function getUser(userId) { + return usersCache.get(userId) +} + +function deleteUser(userId) { + return usersCache.delete(userId) +} + +module.exports = { + users: { + put: putUser, + get: getUser, + delete: deleteUser + } +} \ No newline at end of file diff --git a/repositories/banRepo.js b/repositories/banRepo.js new file mode 100644 index 0000000..e9dcac9 --- /dev/null +++ b/repositories/banRepo.js @@ -0,0 +1,49 @@ +const { pool } = require("../modules/database") +const { DefaultError } = require("../errors/errors") + +async function banUser(userId, message, expiresAt) { + try { + const sql = "INSERT INTO bans (userId, message, expiresAt) VALUES (?, ?, ?) RETURNING *" + const rows = await pool.query(sql, [userId, message, expiresAt]) + return rows[0] || null + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function isBanned(userId) { + try { + const sql = "SELECT * FROM bans WHERE userId = ? AND isActive = TRUE LIMIT 1" + const rows = await pool.query(sql, [userId]) + return rows[0] || null + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function unbanUser(banId) { + try { + const sql = "UPDATE bans SET isActive = FALSE WHERE id = ?" + const rows = await pool.query(sql, [banId]) + return rows.affectedRows > 0 + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +async function getBanHistory(userId) { + try { + const sql = "SELECT * FROM bans WHERE id = userId" + const rows = await pool.query(sql, [userId, limit]) + return rows.affectedRows > 0 + } catch (error) { + throw new DefaultError(500, "Internal Server Error", error) + } +} + +module.exports = { + banUser, + isBanned, + unbanUser, + getBanHistory +} \ No newline at end of file diff --git a/repositories/credentialsRepo.js b/repositories/credentialsRepo.js index 447099a..e9a849a 100644 --- a/repositories/credentialsRepo.js +++ b/repositories/credentialsRepo.js @@ -13,7 +13,7 @@ async function findByUserId(userId) { async function create(userId, passwordHash) { try { - const sql = "INSERT INTO credentials (userId, passwordHash) VALUES (?, ?)" + const sql = "INSERT INTO credentials (userId, passwordHash) VALUES (?, ?) RETURNING *" const rows = await pool.query(sql, [userId, passwordHash]) return rows || null } catch (error) { diff --git a/repositories/usersRepo.js b/repositories/usersRepo.js index 280d295..4b7cf79 100644 --- a/repositories/usersRepo.js +++ b/repositories/usersRepo.js @@ -58,27 +58,30 @@ async function isLocal(id) { async function updateProfile(id, displayName, avatarUrl) { try { const fields = [] - const params = { id } + const params = [] if (displayName !== undefined) { - fields.push('`displayName` = :displayName') - params.displayName = displayName + fields.push('`displayName` = ?') + params.push(displayName) } if (avatarUrl !== undefined) { - fields.push('`avatarUrl` = :avatarUrl') - params.avatarUrl = avatarUrl + fields.push('`avatarUrl` = ?') + params.push(avatarUrl) } if (fields.length === 0) { return findById(id) } - const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = :id` + params.push(id) + + const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = ?` await pool.query(sql, params) return findById(id) } catch (error) { + console.error(error) throw new DefaultError(500, "Internal Server Error", error) } } diff --git a/routes/api/v1/auth/login.js b/routes/api/v1/auth/login.js new file mode 100644 index 0000000..a78f9d4 --- /dev/null +++ b/routes/api/v1/auth/login.js @@ -0,0 +1,14 @@ +const express = require("express") +const authService = require("../../../../services/authService") +const router = express.Router() + +router.post("/", authService.isNotLogged, async (req, res) => { + if (process.env.LOGIN_ENABLED != "true") { + return res.status(401).json({ error: "login_disabled", error_description: "The administrator has disabled internal login." }) + } + const { identifier, password } = req.body + const user = await authService.loginLocal({ identifier, password }) + return res.status(201).json(user) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/api/v1/auth/logout.js b/routes/api/v1/auth/logout.js new file mode 100644 index 0000000..f1e0b55 --- /dev/null +++ b/routes/api/v1/auth/logout.js @@ -0,0 +1,12 @@ +const express = require("express") +const authService = require("../../../../services/authService") +const tokensService = require("../../../../services/tokensService") +const router = express.Router() + +router.post("/", authService.isLogged, async (req, res) => { + const { refresh_token } = req.body + await tokensService.revokeRefreshToken(refresh_token) + return res.status(200).json(user) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/api/v1/auth/refresh.js b/routes/api/v1/auth/refresh.js new file mode 100644 index 0000000..6682610 --- /dev/null +++ b/routes/api/v1/auth/refresh.js @@ -0,0 +1,11 @@ +const express = require("express") +const authService = require("../../../../services/authService") +const router = express.Router() + +router.post("/", authService.isLogged, async (req, res) => { + const { refresh_token } = req.body + const tokens = await authService.refreshAccessToken({ refreshToken: refresh_token }) + return res.status(200).json(tokens) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/api/v1/auth/register.js b/routes/api/v1/auth/register.js new file mode 100644 index 0000000..3ea5f36 --- /dev/null +++ b/routes/api/v1/auth/register.js @@ -0,0 +1,14 @@ +const express = require("express") +const authService = require("../../../../services/authService") +const router = express.Router() + +router.post("/", authService.isNotLogged, async (req, res) => { + if (process.env.REGISTER_ENABLED != "true") { + return res.status(401).json({ error: "registration_disabled", error_description: "The administrator has disabled internal registration." }) + } + const { identifier, password, avatarURL, displayName } = req.body + const user = await authService.registerLocal({ identifier, password, profile: { avatarURL, displayName } }) + return res.status(201).json(user) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/api/v1/user/index.js b/routes/api/v1/user/index.js new file mode 100644 index 0000000..251dccc --- /dev/null +++ b/routes/api/v1/user/index.js @@ -0,0 +1,7 @@ +const express = require("express") +const authService = require("../../../../services/authService") +const router = express.Router() + +router.use("", authService.isLogged) + +module.exports = router \ No newline at end of file diff --git a/routes/api/v1/user/me.js b/routes/api/v1/user/me.js new file mode 100644 index 0000000..3f78bc9 --- /dev/null +++ b/routes/api/v1/user/me.js @@ -0,0 +1,22 @@ +const express = require("express") +const userService = require("../../../../services/userService") +const router = express.Router() + +router.get("/", async (req, res) => { + const user = await userService.getProfile({ userId: req.user.id }) + return res.status(200).json(user) +}) + +router.patch("/avatar", async (req, res) => { + const { avatarURL } = req.body + const updatedProfile = await userService.updateProfile({ userId: req.user.id, profile: { avatarURL } }) + return res.status(200).json(updatedProfile) +}) + +router.patch("/displayname", async (req, res) => { + const { displayName } = req.body + const updatedProfile = await userService.updateProfile({ userId: req.user.id, profile: { displayName } }) + return res.status(200).json(updatedProfile) +}) + +module.exports = router \ No newline at end of file diff --git a/services/adminService.js b/services/adminService.js new file mode 100644 index 0000000..55e69bb --- /dev/null +++ b/services/adminService.js @@ -0,0 +1,38 @@ +const { DefaultError } = require("../errors/errors") +const banRepo = require("../repositories/banRepo") + +async function banUser(userId, message, expiresAt) { + const existingBan = await banRepo.isBanned(userId) + if (existingBan) { + throw new DefaultError(409, "Already has an active ban.", "Duplicate active ban.") + } + return await banRepo.banUser(userId, message, expiresAt) +} + +async function unbanUser(banId) { + const existingBan = await banRepo.isBanned(userId) + if (!existingBan) { + throw new DefaultError(404, "No active ban.", "No active ban found.") + } + return await banRepo.unbanUser(banId) +} + + +async function isUserBanned(userId) { + const ban = await banRepo.isBanned(userId) + if (!ban) { + return false + } + + if (new Date(ban.expiresAt) <= new Date()) { + await banRepo.deactivate(ban.id) + return false + } + return true +} + +module.exports = { + banUser, + unbanUser, + isUserBanned +} \ No newline at end of file diff --git a/services/authService.js b/services/authService.js index 206f9a6..afb60c2 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,5 +1,7 @@ +const cache = require("../modules/cache") const bcrypt = require("bcryptjs") const usersRepo = require("../repositories/usersRepo") +const adminService = require("./adminService") const tokensService = require("./tokensService") const identitiesRepo = require("../repositories/identitiesRepo") const credentialsRepo = require("../repositories/credentialsRepo") @@ -10,7 +12,7 @@ async function registerLocal({ identifier, password, profile = { displayName, av 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)) { + if (credentials.length == 0) { throw new DefaultError(500, "Internal Server Error", "UserRegistration") } return user @@ -41,13 +43,13 @@ async function loginLocal({ identifier, password }) { } } -async function refreshAccessToken({ userId, refreshToken }) { +async function refreshAccessToken({ refreshToken }) { try { - await tokensService.verifyRefreshToken(refreshToken) + const payload = await tokensService.verifyRefreshToken(refreshToken) await tokensService.revokeRefreshToken(refreshToken) - const user = await usersRepo.findById(userId) + const user = await usersRepo.findById(payload.userId) const newAccessToken = await tokensService.signToken(user) - const newRefreshToken = await tokensService.signRefreshToken(userId) + const newRefreshToken = await tokensService.signRefreshToken(user.id) return { accessToken: newAccessToken, refreshToken: newRefreshToken } } catch (error) { throw error @@ -74,10 +76,47 @@ async function resetPassword({ userId, newPassword }) { } } +async function isNotLogged(req, res, next) { + if (req.headers.authorization && req.headers.authorization.startsWith("Bearer")) { + throw new DefaultError(401, "You must be logged out to do this action", "Auth header") + } + + next() +} + +async function isLogged(req, res, next) { + if (!req.headers.authorization) { + throw new DefaultError(401, "You must be logged in to do this action", "Auth header") + } + if (!req.headers.authorization.startsWith("Bearer ")) { + throw new DefaultError(422, "Wrong auth header format", "WhereIsBearer") + } + + const tokenVerified = await tokensService.verifyAccessToken(req.headers.authorization.replace("Bearer ", "")) + const isBanned = await adminService.isUserBanned(tokenVerified.id) + if (isBanned) { + throw new DefaultError(403, "You're not allowed to do this action", "Banned") + } + + let user = cache.users.get(tokenVerified.id) + if (!user) { + user = await usersRepo.findById(tokenVerified.id) + if (!user) { + throw new DefaultError(404, "User not found", "UserNotFound") + } + cache.users.put(tokenVerified.id, user) + } + + req.user = user + next() +} + module.exports = { + isLogged, loginLocal, + isNotLogged, resetPassword, registerLocal, changePassword, - refreshAccessToken + refreshAccessToken, } \ No newline at end of file diff --git a/services/tokensService.js b/services/tokensService.js index cf3179b..5c4975d 100644 --- a/services/tokensService.js +++ b/services/tokensService.js @@ -1,4 +1,5 @@ const jwt = require("jsonwebtoken") +const crypto = require("node:crypto") const security = require("../modules/security") const tokenRepo = require("../repositories/refreshTokensRepo") const { DefaultError } = require("../errors/errors") @@ -12,7 +13,8 @@ async function signToken(user) { id: user.id, identifier: user.identifier, displayName: user.displayName, - avatarUrl: user.avatarUrl + avatarUrl: user.avatarUrl, + role: user.role } const token = await jwt.sign(payload, privateKey, { algorithm: "RS256", @@ -31,7 +33,6 @@ async function verifyAccessToken(token) { algorithms: ["RS256"], issuer: process.env.FQDN }) - console.log(result) return result } catch (error) { throw error @@ -56,7 +57,8 @@ async function signRefreshToken(userId) { expiresIn: expiresIn } ) - await tokenRepo.create(userId, token, expiresAtInMs) + const hash = crypto.createHash("sha256").update(token).digest("hex") + await tokenRepo.create(userId, hash, expiresAtInMs) return token } catch (error) { throw error @@ -80,11 +82,12 @@ async function verifyRefreshToken(token) { if (!result) { throw new DefaultError(500, "Internal Server Error") } - const refreshTokenInDb = await tokenRepo.findByTokenHash(token) + const hash = crypto.createHash("sha256").update(token).digest("hex") + const refreshTokenInDb = await tokenRepo.findByTokenHash(hash) if (refreshTokenInDb == null) { throw new DefaultError(403, "Bad refresh token", "Refresh token doesn't exists") } - return true + return result } catch (error) { throw error } diff --git a/services/userService.js b/services/userService.js index 16c5605..ba72481 100644 --- a/services/userService.js +++ b/services/userService.js @@ -1,10 +1,16 @@ const userRepo = require("../repositories/usersRepo") +const cache = require("../modules/cache") async function getProfile({ userId }) { + const cachedUser = cache.users.get(userId) + if (cachedUser) { + return cachedUser + } return await userRepo.findById(userId) } async function updateProfile({ userId, profile = { displayName, avatarURL } }) { + cache.users.delete(userId) return await userRepo.updateProfile(userId, profile.displayName, profile.avatarURL) }