diff --git a/packages/server/package.json b/packages/server/package.json index 7b85d5f8..b703c5c9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,5 +1,5 @@ { - "name": "@ragestudio/server", + "name": "@comty/server", "version": "0.15.0", "main": "dist/index.js", "scripts": { @@ -7,22 +7,22 @@ }, "license": "MIT", "dependencies": { - "@corenode/utils": "^0.28.26", - "axios": "^0.24.0", - "bcrypt": "^5.0.1", - "connect-mongo": "^4.6.0", - "corenode": "^0.28.26", - "dicebar_lib": "^1.0.1", - "jsonwebtoken": "^8.5.1", - "linebridge": "^0.8.4", - "moment": "^2.29.1", - "mongoose": "^5.12.14", - "nanoid": "^3.1.23", - "passport": "^0.5.0", - "passport-jwt": "^4.0.0", - "passport-local": "^1.0.0", - "path-to-regexp": "^6.2.0", - "socket.io": "^4.2.0" + "@corenode/utils": "0.28.26", + "@nanoexpress/middleware-file-upload": "^1.0.6", + "axios": "0.25.0", + "bcrypt": "5.0.1", + "connect-mongo": "4.6.0", + "corenode": "0.28.26", + "dicebar_lib": "1.0.1", + "jsonwebtoken": "8.5.1", + "linebridge": "0.10.13", + "moment": "2.29.1", + "mongoose": "6.1.9", + "nanoid": "3.2.0", + "passport": "0.5.2", + "passport-jwt": "4.0.0", + "passport-local": "1.0.0", + "path-to-regexp": "6.2.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/packages/server/src/controllers/ConfigController/index.js b/packages/server/src/controllers/ConfigController/index.js new file mode 100644 index 00000000..7599d9f5 --- /dev/null +++ b/packages/server/src/controllers/ConfigController/index.js @@ -0,0 +1,12 @@ +import { ComplexController } from "linebridge/dist/classes" + +export default class ConfigController extends ComplexController { + static refName = "ConfigController" + static useMiddlewares = ["withAuthentication", "onlyAdmin"] + + post = { + "/update_config": async (req, res) => { + + }, + } +} \ No newline at end of file diff --git a/packages/server/src/controllers/FilesController/index.js b/packages/server/src/controllers/FilesController/index.js new file mode 100644 index 00000000..5b9f5c21 --- /dev/null +++ b/packages/server/src/controllers/FilesController/index.js @@ -0,0 +1,65 @@ +import { ComplexController } from "linebridge/dist/classes" +import path from "path" +import fs from "fs" +import stream from "stream" + +function resolveToUrl(filepath) { + return `${global.globalPublicURI}/uploads/${filepath}` +} + +export default class FilesController extends ComplexController { + static refName = "FilesController" + + get = { + "/uploads/:id": (req, res) => { + const filePath = path.join(global.uploadPath, req.params?.id) + + const readStream = fs.createReadStream(filePath) + const passTrough = new stream.PassThrough() + + stream.pipeline(readStream, passTrough, (err) => { + if (err) { + return res.status(400) + } + }) + + return passTrough.pipe(res) + } + } + + post = { + "/upload": { + middlewares: ["withAuthentication", "fileUpload"], + fn: async (req, res) => { + const urls = [] + const failed = [] + + if (!fs.existsSync(global.uploadPath)) { + await fs.promises.mkdir(global.uploadPath, { recursive: true }) + } + + if (req.files) { + for await (let file of req.files) { + try { + const filename = `${req.decodedToken.user_id}-${new Date().getTime()}-${file.filename}` + + const diskPath = path.join(global.uploadPath, filename) + + await fs.promises.writeFile(diskPath, file.data) + + urls.push(resolveToUrl(filename)) + } catch (error) { + console.log(error) + failed.push(file.filename) + } + } + } + + return res.json({ + urls: urls, + failed: failed, + }) + } + } + } +} \ No newline at end of file diff --git a/packages/server/src/controllers/PublicController/index.js b/packages/server/src/controllers/PublicController/index.js new file mode 100644 index 00000000..664e431c --- /dev/null +++ b/packages/server/src/controllers/PublicController/index.js @@ -0,0 +1,17 @@ +import { ComplexController } from "linebridge/dist/classes" + +export default class PublicController extends ComplexController { + static refName = "PublicController" + + post = { + "/only_managers_test": { + middlewares: ["withAuthentication", "permissions"], + fn: (req, res) => { + return res.json({ + message: "Congrats!, Only managers can access this route (or you are an admin)", + assertedPermissions: req.assertedPermissions + }) + }, + } + } +} \ No newline at end of file diff --git a/packages/server/src/controllers/RolesController/index.js b/packages/server/src/controllers/RolesController/index.js index 171008e0..d00dbed7 100644 --- a/packages/server/src/controllers/RolesController/index.js +++ b/packages/server/src/controllers/RolesController/index.js @@ -1,36 +1,113 @@ -import { Role, User } from '../../models' +import { ComplexController } from "linebridge/dist/classes" +import { Role, User } from "../../models" import { Schematized } from "../../lib" -export default { - get: Schematized({ - select: ["user_id", "username"], - }, async (req, res) => { - const { user_id, username } = req.selection +export default class RolesController extends ComplexController { + static refName = "RolesController" + static useMiddlewares = ["roles"] - if (typeof user_id !== "undefined" || typeof username !== "undefined") { - const user = await User.findOne(req.selection) - if (!user) { - return res.status(404).json({ error: "No user founded" }) - } - return res.json(user.roles) - } + get = { + "/roles": Schematized({ + select: ["user_id", "username"], + }, async (req, res) => { + const roles = await Role.find() - const roles = await Role.find({}) + return res.json(roles) + }), + "/user_roles": { + middlewares: ["withAuthentication"], + fn: Schematized({ + select: ["username"], + }, async (req, res) => { + const user = await User.findOne(req.selection) - return res.json(roles) - }), - set: (req, res, next) => { - const { name, description } = req.body - Role.findOne({ name }).then((data) => { - if (data) { - return res.status(409).json("This role is already created") - } - let document = new Role({ - name, - description + if (!user) { + return res.status(404).json({ error: "No user founded" }) + } + + return res.json(user.roles) }) - document.save() - return res.json(true) - }) + }, + } + + post = { + "/role": { + middlewares: ["withAuthentication"], + fn: Schematized({ + required: ["name"], + select: ["name", "description"], + }, async (req, res) => { + await Role.findOne(req.selection).then((data) => { + if (data) { + return res.status(409).json("This role is already created") + } + + let role = new Role({ + name: req.selection.name, + description: req.selection.description, + }) + + role.save() + + return res.json(role) + }) + }) + }, + "/update_user_roles": { + middlewares: ["withAuthentication"], + fn: Schematized({ + required: ["update"], + select: ["update"], + }, async (req, res) => { + // check if issuer user is admin + if (!req.isAdmin()) { + return res.status(403).send("You do not have administrator permission") + } + + if (!Array.isArray(req.selection.update)) { + return res.status(400).send("Invalid update request") + } + + req.selection.update.forEach(async (update) => { + const user = await User.findById(update._id).catch(err => { + return false + }) + + console.log(update.roles) + + if (user) { + user.roles = update.roles + + await user.save() + } + }) + + return res.send("done") + }), + }, + } + + delete = { + "/role": { + middlewares: ["withAuthentication"], + fn: Schematized({ + required: ["name"], + select: ["name"], + }, async (req, res) => { + if (req.selection.name === "admin") { + return res.status(409).json("You can't delete admin role") + } + + await Role.findOne(req.selection).then((data) => { + if (!data) { + return res.status(404).json("This role is not found") + } + + data.remove() + + return res.json(data) + }) + }) + }, } } \ No newline at end of file diff --git a/packages/server/src/controllers/SessionController/index.js b/packages/server/src/controllers/SessionController/index.js index bd4dfa88..7cfee5b0 100644 --- a/packages/server/src/controllers/SessionController/index.js +++ b/packages/server/src/controllers/SessionController/index.js @@ -1,102 +1,110 @@ -import { Session } from '../../models' -import jwt from 'jsonwebtoken' -import { Token } from '../../lib' +import { ComplexController } from "linebridge/dist/classes" +import { Session } from "../../models" +import jwt from "jsonwebtoken" -export default { - regenerate: async (req, res) => { - jwt.verify(req.jwtToken, req.jwtStrategy.secretOrKey, async (err, decoded) => { - if (err && !decoded?.allowRegenerate) { - return res.status(403).send("This token is invalid and is not allowed to be regenerated") +export default class SessionController extends ComplexController { + static refName = "SessionController" + + get = { + "/sessions": { + middlewares: ["withAuthentication"], + fn: async (req, res) => { + // get current session _id + const { _id } = req.user + const sessions = await Session.find({ user_id: _id }, { token: 0 }) + + return res.json(sessions) + }, + }, + "/current_session": { + middlewares: ["withAuthentication"], + fn: async (req, res) => { + return res.json(req.currentSession) } + }, + } - const sessionToken = await Session.findOneAndDelete({ token: req.jwtToken, user_id: decoded.user_id }) - - if (sessionToken) { - delete decoded["iat"] - delete decoded["exp"] - delete decoded["date"] + post = { + "/validate_session": { + middlewares: ["useJwtStrategy"], + fn: async (req, res) => { + const token = req.body.session - const token = await Token.signNew({ - ...decoded, - }, req.jwtStrategy) - - return res.json({ token }) - } - }) - }, - deleteAll: async (req, res) => { - const { user_id } = req.body - - if (typeof user_id === "undefined") { - return res.status(400).send("No user_id provided") - } - - const allSessions = await Session.deleteMany({ user_id }) - if (allSessions) { - return res.send("done") - } - - return res.status(404).send("not found") - }, - delete: async (req, res) => { - const { token, user_id } = req.body - - if (typeof user_id === "undefined") { - return res.status(400).send("No user_id provided") - } - if (typeof token === "undefined") { - return res.status(400).send("No token provided") - } - - const session = await Session.findOneAndDelete({ user_id, token }) - if (session) { - return res.send("done") - } - - return res.status(404).send("not found") - }, - validate: async (req, res) => { - const token = req.body.session - let result = { - expired: false, - valid: true - } - - await jwt.verify(token, req.jwtStrategy.secretOrKey, async (err, decoded) => { - if (err) { - result.valid = false - result.error = err.message - - if (err.message === "jwt expired") { - result.expired = true + let result = { + expired: false, + valid: true } - return - } - result = { ...result, ...decoded } + await jwt.verify(token, req.jwtStrategy.secretOrKey, async (err, decoded) => { + if (err) { + result.valid = false + result.error = err.message - const sessions = await Session.find({ user_id: result.user_id }) - const sessionsTokens = sessions.map((session) => { - if (session.user_id === result.user_id) { - return session.token + if (err.message === "jwt expired") { + result.expired = true + } + return + } + + result = { ...result, ...decoded } + + const sessions = await Session.find({ user_id: result.user_id }) + const sessionsTokens = sessions.map((session) => { + if (session.user_id === result.user_id) { + return session.token + } + }) + + if (!sessionsTokens.includes(token)) { + result.valid = false + result.error = "Session token not found" + } else { + result.valid = true + } + }) + + return res.json(result) + }, + }, + } + + delete = { + "/session": { + middlewares: ["withAuthentication"], + fn: async (req, res) => { + const { token, user_id } = req.body + + if (typeof user_id === "undefined") { + return res.status(400).send("No user_id provided") + } + if (typeof token === "undefined") { + return res.status(400).send("No token provided") } - }) - if (!sessionsTokens.includes(token)) { - result.valid = false - result.error = "Session token not found" - } else { - result.valid = true + const session = await Session.findOneAndDelete({ user_id, token }) + if (session) { + return res.send("done") + } + + return res.status(404).send("not found") + }, + }, + "/sessions": { + middlewares: ["withAuthentication"], + fn: async (req, res) => { + const { user_id } = req.body + + if (typeof user_id === "undefined") { + return res.status(400).send("No user_id provided") + } + + const allSessions = await Session.deleteMany({ user_id }) + if (allSessions) { + return res.send("done") + } + + return res.status(404).send("not found") } - }) - - return res.json(result) - }, - get: async (req, res) => { - // get current session _id - const { _id } = req.user - const sessions = await Session.find({ user_id: _id }, { token: 0 }) - - return res.json(sessions) - }, + }, + } } \ No newline at end of file diff --git a/packages/server/src/controllers/UserController/index.js b/packages/server/src/controllers/UserController/index.js index c7dc57cc..b3075a28 100644 --- a/packages/server/src/controllers/UserController/index.js +++ b/packages/server/src/controllers/UserController/index.js @@ -1,263 +1,195 @@ -import passport from 'passport' -import bcrypt from 'bcrypt' +import { ComplexController } from "linebridge/dist/classes" +import passport from "passport" +import bcrypt from "bcrypt" -import { User } from '../../models' -import SessionController from '../SessionController' -import { Token, Schematized } from '../../lib' -import AvatarController from 'dicebar_lib' +import { User } from "../../models" +import SessionController from "../SessionController" +import { Token, Schematized } from "../../lib" +import AvatarController from "dicebar_lib" +import _ from "lodash" -import _ from 'lodash' +const AllowedUserUpdateKeys = [ + "username", + "email", + "fullName", +] -export default { - isAuth: (req, res) => { - return res.json(`You look nice today 😎`) - }, - getSelf: (req, res) => { - return res.json(req.user) - }, - get: Schematized({ - select: ["_id", "username"], - }, async (req, res) => { - let result = [] - let selectQueryKeys = [] +export default class UserController extends ComplexController { + static refName = "UserController" - if (Array.isArray(req.selection._id)) { - for await (let _id of req.selection._id) { - const user = await User.findById(_id).catch(err => { + get = { + "/self": { + middlewares: ["withAuthentication"], + fn: async (req, res) => { + return res.json(req.user) + }, + }, + "/user": { + middlewares: ["withAuthentication"], + fn: Schematized({ + select: ["_id", "username"], + }, async (req, res) => { + const user = await User.findOne(req.selection) + + if (!user) { + return res.status(404).json({ error: "User not exists" }) + } + + return res.json(user) + }), + }, + "/users": { + middlewares: ["withAuthentication"], + fn: Schematized({ + select: ["_id", "username"], + }, async (req, res) => { + let result = [] + let selectQueryKeys = [] + + if (Array.isArray(req.selection._id)) { + for await (let _id of req.selection._id) { + const user = await User.findById(_id).catch(err => { + return false + }) + if (user) { + result.push(user) + } + } + } else { + result = await User.find(req.selection, { username: 1, fullName: 1, _id: 1, roles: 1, avatar: 1 }) + } + + if (req.query?.select) { + try { + req.query.select = JSON.parse(req.query.select) + } catch (error) { + req.query.select = {} + } + + selectQueryKeys = Object.keys(req.query.select) + } + + if (selectQueryKeys.length > 0) { + result = result.filter(user => { + let pass = false + const selectFilter = req.query.select + + selectQueryKeys.forEach(key => { + if (Array.isArray(selectFilter[key]) && Array.isArray(user[key])) { + // check if arrays includes any of the values + pass = selectFilter[key].some(val => user[key].includes(val)) + } else if (typeof selectFilter[key] === "object" && typeof user[key] === "object") { + // check if objects includes any of the values + Object.keys(selectFilter[key]).forEach(objKey => { + pass = user[key][objKey] === selectFilter[key][objKey] + }) + } + + // check if strings includes any of the values + if (typeof selectFilter[key] === "string" && typeof user[key] === "string") { + pass = selectFilter[key].split(",").some(val => user[key].includes(val)) + } + }) + + return pass + }) + } + + if (!result) { + return res.status(404).json({ error: "Users not found" }) + } + + return res.json(result) + }) + }, + } + + post = { + "/login": async (req, res) => { + passport.authenticate("local", { session: false }, async (error, user, options) => { + if (error) { + return res.status(500).json(`Error validating user > ${error.message}`) + } + + if (!user) { + return res.status(401).json("Invalid credentials") + } + + const token = await Token.createNewAuthToken(user, options) + + return res.json({ token: token }) + })(req, res) + }, + "/logout": { + middlewares: ["withAuthentication"], + fn: async (req, res, next) => { + req.body = { + user_id: req.decodedToken.user_id, + token: req.jwtToken + } + + return SessionController.delete(req, res, next) + }, + }, + "/register": async (req, res) => { + User.findOne({ username: req.body.username }) + .then((data) => { + if (data) { + return res.status(409).json("Username is already exists") + } + + const avatar = AvatarController.generate({ seed: req.body.username, type: "initials" }) + const hash = bcrypt.hashSync(req.body.password, parseInt(process.env.BCRYPT_ROUNDS)) + + let document = new User({ + username: req.body.username, + fullName: req.body.fullName, + avatar: avatar.uri, + email: req.body.email, + password: hash + }) + + return document.save() + }) + .then(data => { + return res.send(data) + }) + .catch(err => { + return res.json(err) + }) + }, + "/update_user": { + middlewares: ["withAuthentication", "roles"], + fn: Schematized({ + required: ["_id", "update"], + select: ["_id", "update"], + }, async (req, res) => { + let user = await User.findById(req.selection._id).catch(() => { return false }) - if (user) { - result.push(user) + + if (!user) { + return res.status(404).json({ error: "User not exists" }) } - } - } else { - result = await User.find(req.selection, { username: 1, fullName: 1, _id: 1, roles: 1, avatar: 1 }) - } - if (req.query.select) { - try { - req.query.select = JSON.parse(req.query.select) - } catch (error) { - req.query.select = {} - } + if ((user._id.toString() !== req.user._id.toString()) && (req.hasRole("admin") === false)) { + return res.status(403).json({ error: "You are not allowed to update this user" }) + } - selectQueryKeys = Object.keys(req.query.select) - } - - if (selectQueryKeys.length > 0) { - result = result.filter(user => { - let pass = false - const selectFilter = req.query.select - - selectQueryKeys.forEach(key => { - if (Array.isArray(selectFilter[key]) && Array.isArray(user[key])) { - // check if arrays includes any of the values - pass = selectFilter[key].some(val => user[key].includes(val)) - } else if (typeof selectFilter[key] === 'object' && typeof user[key] === 'object') { - // check if objects includes any of the values - Object.keys(selectFilter[key]).forEach(objKey => { - pass = user[key][objKey] === selectFilter[key][objKey] - }) - } - - // check if strings includes any of the values - if (typeof selectFilter[key] === 'string' && typeof user[key] === 'string') { - pass = selectFilter[key].split(',').some(val => user[key].includes(val)) + AllowedUserUpdateKeys.forEach((key) => { + if (typeof req.selection.update[key] !== "undefined") { + user[key] = req.selection.update[key] } }) - return pass - }) - } - - if (!result) { - return res.status(404).json({ error: "Users not found" }) - } - - return res.json(result) - }), - getOne: Schematized({ - select: ["_id", "username"], - }, async (req, res) => { - const user = await User.findOne(req.selection) - - if (!user) { - return res.status(404).json({ error: "User not exists" }) - } - - return res.json(user) - }), - register: (req, res, next) => { - User.findOne({ username: req.body.username }) - .then((data) => { - if (data) { - return res.status(409).json("Username is already exists") - } - - const avatar = AvatarController.generate({ seed: req.body.username, type: "initials" }) - const hash = bcrypt.hashSync(req.body.password, parseInt(process.env.BCRYPT_ROUNDS)) - - let document = new User({ - username: req.body.username, - fullName: req.body.fullName, - avatar: avatar.uri, - email: req.body.email, - roles: ["registered"], - password: hash - }) - - return document.save() - }) - .then(data => { - return res.send(data) - }) - .catch(err => { - return next(err) - }) - }, - denyRole: async (req, res) => { - // check if issuer user is admin - if (!req.isAdmin()) { - return res.status(403).send("You do not have administrator permission") - } - - let { user_id, username, roles } = req.body - const userQuery = { - username: username, - user_id: user_id, - } - - // parse requested roles - if (typeof roles === "string") { - roles = roles.split(",").map(role => role.trim()) - } else { - return res.send("No effect") - } - - // get current user roles - const user = await User.findOne({ ...userQuery }) - if (typeof user === "undefined") { - return res.status(404).send(`[${username}] User not found`) - } - - // query all roles mutation - let queryRoles = [] - if (Array.isArray(roles)) { - queryRoles = roles - } else if (typeof roles === 'string') { - queryRoles.push(roles) - } - - // mutate all roles - if (queryRoles.length > 0 && Array.isArray(user.roles)) { - queryRoles.forEach(role => { - user.roles = user.roles.filter(_role => _role !== role) - }) - } - - // update user roles - await user.save() - return res.send("done") - }, - grantRole: async (req, res) => { - // check if issuer user is admin - if (!req.isAdmin()) { - return res.status(403).send("You do not have administrator permission") - } - - let { user_id, username, roles } = req.body - const userQuery = { - username: username, - user_id: user_id, - } - - // parse requested roles - if (typeof roles === "string") { - roles = roles.split(",").map(role => role.trim()) - } else { - return res.send("No effect") - } - - // get current user roles - const user = await User.findOne({ ...userQuery }) - if (typeof user === "undefined") { - return res.status(404).send(`[${username}] User not found`) - } - - // query all roles mutation - let queryRoles = [] - if (Array.isArray(roles)) { - queryRoles = roles - } else if (typeof roles === 'string') { - queryRoles.push(roles) - } - - - // mutate all roles - if (queryRoles.length > 0 && Array.isArray(user.roles)) { - queryRoles.forEach(role => { - if (!user.roles.includes(role)) { - user.roles.push(role) - } - }) - } - - // update user roles - await user.save() - return res.send("done") - }, - updatePassword: async (req, res) => { - //TODO - }, - updateSelf: async (req, res) => { - Object.keys(req.body).forEach(key => { - req.user[key] = req.body[key] - }) - - User.findOneAndUpdate({ _id: req.user._id }, req.user) - .then(() => { - return res.send(req.user) - }) - .catch((err) => { - return res.send(500).send(err) - }) - }, - update: async (req, res) => { - // TODO - }, - login: async (req, res) => { - passport.authenticate("local", { session: false }, async (error, user, options) => { - if (error) { - return res.status(500).json(`Error validating user > ${error.message}`) - } - - if (!user) { - return res.status(401).json("Invalid credentials") - } - - const payload = { - user_id: user._id, - username: user.username, - email: user.email - } - - if (req.body.allowRegenerate) { - payload.allowRegenerate = true - } - - // generate token - const token = Token.signNew(payload, options) - - // send result - res.json({ token: token }) - })(req, res) - }, - logout: async (req, res, next) => { - req.body = { - user_id: req.decodedToken.user_id, - token: req.jwtToken - } - - return SessionController.delete(req, res, next) - }, + user.save() + .then(() => { + return res.send(user) + }) + .catch((err) => { + return res.send(500).send(err) + }) + }), + }, + } } \ No newline at end of file diff --git a/packages/server/src/controllers/index.js b/packages/server/src/controllers/index.js index da7be5c9..b69f67b7 100644 --- a/packages/server/src/controllers/index.js +++ b/packages/server/src/controllers/index.js @@ -1,3 +1,15 @@ -export { default as RolesController } from './RolesController' -export { default as SessionController } from './SessionController' -export { default as UserController } from './UserController' \ No newline at end of file +import { default as ConfigController } from "./ConfigController" +import { default as RolesController } from "./RolesController" +import { default as SessionController } from "./SessionController" +import { default as UserController } from "./UserController" +import { default as FilesController } from "./FilesController" +import { default as PublicController } from "./PublicController" + +export default [ + ConfigController, + PublicController, + RolesController, + SessionController, + UserController, + FilesController, +] \ No newline at end of file diff --git a/packages/server/src/endpoints/index.js b/packages/server/src/endpoints/index.js deleted file mode 100644 index 88d3b973..00000000 --- a/packages/server/src/endpoints/index.js +++ /dev/null @@ -1,109 +0,0 @@ -module.exports = [ - { - route: "/regenerate", - method: "POST", - middleware: ["ensureAuthenticated", "useJwtStrategy"], - fn: "SessionController.regenerate" - }, - { - route: "/role", - method: 'PUT', - middleware: ["ensureAuthenticated", "roles"], - fn: "UserController.grantRole" - }, - { - route: "/role", - method: 'DELETE', - middleware: ["ensureAuthenticated", "roles"], - fn: "UserController.denyRole" - }, - { - route: "/roles", - method: "GET", - fn: "RolesController.get", - }, - { - route: "/session", - method: 'DELETE', - middleware: "ensureAuthenticated", - fn: "SessionController.delete", - }, - { - route: "/sessions", - method: 'DELETE', - middleware: "ensureAuthenticated", - fn: "SessionController.deleteAll", - }, - { - route: "/validate_session", - method: "POST", - middleware: "useJwtStrategy", - fn: "SessionController.validate", - }, - { - route: "/sessions", - method: "GET", - middleware: "ensureAuthenticated", - fn: "SessionController.get", - }, - { - route: "/has_permissions", - method: "POST", - middleware: [ - "ensureAuthenticated", - "hasPermissions" - ] - }, - { - route: "/self", - method: "GET", - middleware: "ensureAuthenticated", - fn: "UserController.getSelf", - }, - { - route: "/users", - method: "GET", - middleware: "ensureAuthenticated", - fn: "UserController.get", - }, - { - route: "/user", - method: "GET", - middleware: "ensureAuthenticated", - fn: "UserController.getOne", - }, - { - route: "/self_user", - method: "PUT", - middleware: "ensureAuthenticated", - fn: "UserController.updateSelf", - }, - { - route: "/user", - method: "PUT", - middleware: ["ensureAuthenticated", "privileged"], - fn: "UserController.update", - }, - { - route: "/login", - method: "POST", - fn: "UserController.login", - }, - { - route: "/logout", - method: "POST", - middleware: ["ensureAuthenticated"], - fn: "UserController.logout", - }, - { - route: "/register", - method: "POST", - fn: "UserController.register", - }, - { - route: "/is_auth", - method: "POST", - middleware: "ensureAuthenticated", - fn: "UserController.isAuth", - } -] \ No newline at end of file diff --git a/packages/server/src/index.js b/packages/server/src/index.js index ea845d1f..d2411c2d 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -1,94 +1,107 @@ -import LinebridgeServer from 'linebridge/server' -import bcrypt from 'bcrypt' -import mongoose from 'mongoose' -import passport from 'passport' -import { User } from './models' -import socketIo from 'socket.io' +Array.prototype.updateFromObjectKeys = function (obj) { + this.forEach((value, index) => { + if (obj[value] !== undefined) { + this[index] = obj[value] + } + }) -const b64Decode = global.b64Decode = (data) => { - return Buffer.from(data, 'base64').toString('utf-8') + return this } -const b64Encode = global.b64Encode = (data) => { - return Buffer.from(data, 'utf-8').toString('base64') -} +import path from "path" +import LinebridgeServer from "linebridge/dist/server" +import bcrypt from "bcrypt" +import mongoose from "mongoose" +import passport from "passport" +import { User, Session } from "./models" +import socketIo from "socket.io" +import jwt from "jsonwebtoken" -const JwtStrategy = require('passport-jwt').Strategy -const ExtractJwt = require('passport-jwt').ExtractJwt -const LocalStrategy = require('passport-local').Strategy const { Buffer } = require("buffer") +const b64Decode = global.b64Decode = (data) => { + return Buffer.from(data, "base64").toString("utf-8") +} +const b64Encode = global.b64Encode = (data) => { + return Buffer.from(data, "utf-8").toString("base64") +} + +const ExtractJwt = require("passport-jwt").ExtractJwt +const LocalStrategy = require("passport-local").Strategy + +function parseConnectionString(obj) { + const { db_user, db_driver, db_name, db_pwd, db_hostname, db_port } = obj + return `${db_driver}://${db_user}:${db_pwd}@${db_hostname}:${db_port}/${db_name}` +} class Server { constructor() { - this.env = _env + this.env = process.env this.listenPort = this.env.listenPort ?? 3000 + this.wsListenPort = this.env.wsListenPort ?? 3001 + this.controllers = require("./controllers").default this.middlewares = require("./middlewares") - this.controllers = require("./controllers") - this.endpoints = require("./endpoints") this.instance = new LinebridgeServer({ - listen: "0.0.0.0", - middlewares: this.middlewares, - controllers: this.controllers, - endpoints: this.endpoints, - port: this.listenPort - }) + port: this.listenPort, + wsPort: this.wsListenPort, + headers: { + "Access-Control-Expose-Headers": "regenerated_token", + }, + onWSClientConnection: this.onWSClientConnection, + onWSClientDisconnection: this.onWSClientDisconnection, + }, this.controllers, this.middlewares) - this.server = this.instance.httpServer - this.io = new socketIo.Server(3001, { - maxHttpBufferSize: 100000000, - connectTimeout: 5000, - transports: ['websocket', 'polling'], - pingInterval: 25 * 1000, - pingTimeout: 5000, - allowEIO3: true, - cors: { - origin: "http://localhost:8000", - methods: ["GET", "POST"], - } - }).of("/main") + this.server = this.instance.httpInterface this.options = { jwtStrategy: { sessionLocationSign: this.instance.id, jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: this.instance.oskid, - algorithms: ['sha1', 'RS256', 'HS256'], - expiresIn: "1h" + algorithms: ["sha1", "RS256", "HS256"], + expiresIn: this.env.signLifetime ?? "1h", } } + this.instance.wsInterface["clients"] = [] + this.instance.wsInterface["findUserIdFromClientID"] = (searchClientId) => { + return this.instance.wsInterface.clients.find(client => client.id === searchClientId)?.userId ?? false + } + this.instance.wsInterface["getClientSockets"] = (userId) => { + return this.instance.wsInterface.clients.filter(client => client.userId === userId).map((client) => { + return client?.socket + }) + } + this.instance.wsInterface["broadcast"] = async (channel, ...args) => { + for await (const client of this.instance.wsInterface.clients) { + client.socket.emit(channel, ...args) + } + } + + global.wsInterface = this.instance.wsInterface + global.httpListenPort = this.listenPort + global.globalPublicURI = this.env.globalPublicURI + global.uploadPath = this.env.uploadPath ?? path.resolve(process.cwd(), "uploads") + global.jwtStrategy = this.options.jwtStrategy + global.signLocation = this.env.signLocation + this.initialize() } async initialize() { await this.connectToDB() await this.initPassport() + await this.initWebsockets() - // register middlewares - this.instance.middlewares["useJwtStrategy"] = (req, res, next) => { - req.jwtStrategy = this.options.jwtStrategy - next() - } - - this.io.on("connection", (socket) => { - console.log(socket.id) - }) - - await this.instance.init() - } - - getDBConnectionString() { - const { db_user, db_driver, db_name, db_pwd, db_hostname, db_port } = _env - return `${db_driver}://${db_user}:${db_pwd}@${db_hostname}:${db_port}/${db_name}` + await this.instance.initialize() } connectToDB = () => { return new Promise((resolve, reject) => { try { console.log("🌐 Trying to connect to DB...") - mongoose.connect(this.getDBConnectionString(), { useNewUrlParser: true, useFindAndModify: false }) + mongoose.connect(parseConnectionString(this.env), { useNewUrlParser: true, useUnifiedTopology: true }) .then((res) => { return resolve(true) }) .catch((err) => { return reject(err) }) } catch (err) { @@ -105,26 +118,22 @@ class Server { }) } - setWebsocketRooms = () => { - this.ws.register("/test", { - onOpen: (socket) => { - console.log(socket) - setInterval(() => { - socket.send("Hello") - }, 1000) - } - }) - - this.ws.listen() - } - initPassport() { + this.instance.middlewares["useJwtStrategy"] = (req, res, next) => { + req.jwtStrategy = this.options.jwtStrategy + next() + } + this.instance.middlewares["useWS"] = (req, res, next) => { + req.ws = global.wsInterface + next() + } + passport.use(new LocalStrategy({ usernameField: "username", passwordField: "password", session: false }, (username, password, done) => { - User.findOne({ username: b64Decode(username) }).select('+password') + User.findOne({ username: b64Decode(username) }).select("+password") .then((data) => { if (data === null) { return done(null, false, this.options.jwtStrategy) @@ -138,22 +147,97 @@ class Server { .catch(err => done(err, null, this.options.jwtStrategy)) })) - passport.use(new JwtStrategy(this.options.jwtStrategy, (token, callback) => { - User.findOne({ _id: token.user_id }) - .then((data) => { - if (data === null) { - return callback(null, false) - } else { - return callback(null, data, token) - } - }) - .catch((err) => { - return callback(err, null) - }) - })) - this.server.use(passport.initialize()) } + + initWebsockets() { + const onAuthenticated = (socket, user_id) => { + this.attachClientSocket(socket, user_id) + socket.emit("authenticated") + } + + const onAuthenticatedFailed = (socket, error) => { + this.detachClientSocket(socket) + socket.emit("authenticateFailed", { + error, + }) + } + + this.instance.wsInterface.eventsChannels.push(["/main", "authenticate", async (socket, token) => { + const session = await Session.findOne({ token }).catch(err => { + return false + }) + + if (!session) { + return onAuthenticatedFailed(socket, "Session not found") + } + + this.verifyJwt(token, async (err, decoded) => { + if (err) { + return onAuthenticatedFailed(socket, err) + } else { + const user = await User.findById(decoded.user_id).catch(err => { + return false + }) + + if (!user) { + return onAuthenticatedFailed(socket, "User not found") + } + + return onAuthenticated(socket, user) + } + }) + }]) + } + + onWSClientConnection = async (socket) => { + console.log(`🌐 Client connected: ${socket.id}`) + } + + onWSClientDisconnection = async (socket) => { + console.log(`🌐 Client disconnected: ${socket.id}`) + this.detachClientSocket(socket) + } + + attachClientSocket = async (client, userData) => { + const socket = this.instance.wsInterface.clients.find(c => c.id === client.id) + + if (socket) { + socket.socket.disconnect() + } + + const clientObj = { + id: client.id, + socket: client, + userId: userData._id.toString(), + user: userData, + } + + this.instance.wsInterface.clients.push(clientObj) + + this.instance.wsInterface.io.emit("userConnected", userData) + } + + detachClientSocket = async (client) => { + const socket = this.instance.wsInterface.clients.find(c => c.id === client.id) + + if (socket) { + socket.socket.disconnect() + this.instance.wsInterface.clients = this.instance.wsInterface.clients.filter(c => c.id !== client.id) + } + + this.instance.wsInterface.io.emit("userDisconnect", client.id) + } + + verifyJwt = (token, callback) => { + jwt.verify(token, this.options.jwtStrategy.secretOrKey, async (err, decoded) => { + if (err) { + return callback(err) + } + + return callback(null, decoded) + }) + } } new Server() \ No newline at end of file diff --git a/packages/server/src/lib/essc/index.js b/packages/server/src/lib/essc/index.js new file mode 100644 index 00000000..aaf41591 --- /dev/null +++ b/packages/server/src/lib/essc/index.js @@ -0,0 +1,70 @@ +// random 5 digits number +const random5 = () => Math.floor(Math.random() * 90000) + 10000 + +// secure random 5 digits number +const random5Secure = () => { + const random = random5() + return random.toString().padStart(5, '0') +} + +// aa-bbbbb-cccc +//* a: type (2 digits) +//* b: serial (5 digits) +//* c: manufacturer (4 digits) + +const typesNumber = { + "computers-desktop": [1], + "computers-laptop": [2], + "computers-tablet": [3], + "computers-smartphone": [4], + "networking": [5], + "peripherals-printer": [6], + "peripherals-monitor": [7], +} + +export function genV1(params) { + let { type, serial, manufacturer } = params // please in that order + type = type.toLowerCase() + + let str = [] + + // Type parsing + let typeBuf = [] + + if (typeof typesNumber[type] === "undefined") { + typeBuf[0] = 0 + typeBuf[1] = "X" + } else { + typeBuf[0] = typesNumber[type][0] + typeBuf[1] = typesNumber[type][1] ?? "X" + } + + str.push(typeBuf.join("")) + + // Serial parsing + // if serial is not defined, generate a random 4 digits number + if (typeof serial === "undefined") { + str.push(random5().toString()) + } else { + // push last 5 digits of serial, if serial is not 5 digits, pad with 0 + let serialBuf = [] + + serialBuf[0] = serial.slice(-5, -4) ?? "0" + serialBuf[1] = serial.slice(-4, -3) ?? "0" + serialBuf[2] = serial.slice(-3, -2) ?? "0" + serialBuf[3] = serial.slice(-2, -1) ?? "0" + serialBuf[4] = serial.slice(-1) ?? "0" + + str.push(serialBuf.join("")) + } + + // Manufacturer parsing + // abreviate manufacturer name to 4 letters + if (typeof manufacturer === "undefined") { + str.push("GENR") + } else { + str.push(manufacturer.slice(0, 4).toUpperCase()) + } + + return str.join("-") +} \ No newline at end of file diff --git a/packages/server/src/lib/schematized/index.js b/packages/server/src/lib/schematized/index.js index 3137dcec..c121c32d 100644 --- a/packages/server/src/lib/schematized/index.js +++ b/packages/server/src/lib/schematized/index.js @@ -1,9 +1,12 @@ export default (schema = {}, fn) => { return async (req, res, next) => { - // if is nullish - req.body = req.body ?? {} - req.query = req.query ?? {} - + if (typeof req.body === "undefined") { + req.body = {} + } + if (typeof req.query === "undefined") { + req.query = {} + } + if (schema.required) { if (Array.isArray(schema.required)) { const missingKeys = [] diff --git a/packages/server/src/lib/token/index.js b/packages/server/src/lib/token/index.js index ee48fea7..91e7033f 100644 --- a/packages/server/src/lib/token/index.js +++ b/packages/server/src/lib/token/index.js @@ -1,29 +1,54 @@ -import jwt from 'jsonwebtoken' -import { nanoid } from 'nanoid' -import { Session } from '../../models' +import jwt from "jsonwebtoken" +import { nanoid } from "nanoid" +import { Session, User } from "../../models" -export function signNew(payload, options) { - const data = { - uuid: nanoid(), - allowRegenerate: false, - ...payload +export async function createNewAuthToken(user, options = {}) { + const payload = { + user_id: user._id, + username: user.username, + email: user.email, + refreshToken: nanoid(), + signLocation: global.signLocation, } - const token = jwt.sign(data, options.secretOrKey, { + await User.findByIdAndUpdate(user._id, { refreshToken: payload.refreshToken }) + + return await signNew(payload, options) +} + +export async function signNew(payload, options = {}) { + if (options.updateSession) { + const sessionData = await Session.findById(options.updateSession) + payload.session_uuid = sessionData.session_uuid + } else { + payload.session_uuid = nanoid() + } + + const token = jwt.sign(payload, options.secretOrKey, { expiresIn: options.expiresIn ?? "1h", algorithm: options.algorithm ?? "HS256" }) - let newSession = new Session({ - uuid: data.uuid, - user_id: data.user_id, - allowRegenerate: data.allowRegenerate, + const session = { token: token, + session_uuid: payload.session_uuid, + username: payload.username, + user_id: payload.user_id, date: new Date().getTime(), - location: options.sessionLocationSign - }) + location: payload.signLocation ?? "rs-auth", + } - newSession.save() + if (options.updateSession) { + await Session.findByIdAndUpdate(options.updateSession, { + token: session.token, + date: session.date, + location: session.location, + }) + } else { + let newSession = new Session(session) + + newSession.save() + } return token } \ No newline at end of file diff --git a/packages/server/src/middlewares/ensureAuthenticated/index.js b/packages/server/src/middlewares/ensureAuthenticated/index.js deleted file mode 100644 index 7fe37a09..00000000 --- a/packages/server/src/middlewares/ensureAuthenticated/index.js +++ /dev/null @@ -1,40 +0,0 @@ -import passport from 'passport' -import { Session } from '../../models' - -export default (req, res, next) => { - function unauthorized() { - console.log("Returning failed session") - return res.status(401).send({ error: 'Invalid session', }) - } - - const authHeader = req.headers?.authorization?.split(' ') - - if (authHeader && authHeader[0] === 'Bearer') { - const token = authHeader[1] - - passport.authenticate('jwt', { session: false }, async (err, user, decodedToken) => { - if (err) { - return res.status(500).send({ error: err.message }) - } - - if (!user) { - return res.status(404).send({ error: "No user data found" }) - } - - const sessions = await Session.find({ user_id: decodedToken.user_id }) - const sessionsTokens = sessions.map(session => session.token) - - if (!sessionsTokens.includes(token)) { - return unauthorized() - } - - req.user = user - req.jwtToken = token - req.decodedToken = decodedToken - - return next() - })(req, res, next) - } else { - return unauthorized() - } -} diff --git a/packages/server/src/middlewares/index.js b/packages/server/src/middlewares/index.js index e38789c7..f6bdcfcc 100644 --- a/packages/server/src/middlewares/index.js +++ b/packages/server/src/middlewares/index.js @@ -1,4 +1,10 @@ -export { default as ensureAuthenticated } from './ensureAuthenticated' -export { default as errorHandler } from './errorHandler' -export { default as hasPermissions } from './hasPermissions' -export { default as roles } from './roles' \ No newline at end of file +const fileUpload = require("@nanoexpress/middleware-file-upload/cjs")() + +export { default as withAuthentication } from "./withAuthentication" +export { default as errorHandler } from "./errorHandler" +export { default as hasPermissions } from "./hasPermissions" +export { default as roles } from "./roles" +export { default as onlyAdmin } from "./onlyAdmin" +export { default as permissions } from "./permissions" + +export { fileUpload as fileUpload } \ No newline at end of file diff --git a/packages/server/src/middlewares/privileged/index.js b/packages/server/src/middlewares/onlyAdmin/index.js similarity index 71% rename from packages/server/src/middlewares/privileged/index.js rename to packages/server/src/middlewares/onlyAdmin/index.js index 1dfc5e62..1bac1ead 100644 --- a/packages/server/src/middlewares/privileged/index.js +++ b/packages/server/src/middlewares/onlyAdmin/index.js @@ -1,6 +1,6 @@ export default (req, res, next) => { if (!req.user.roles.includes("admin")) { - return res.status(401).send({ error: "To make this request it is necessary to have administrator permissions" }) + return res.status(403).send({ error: "To make this request it is necessary to have administrator permissions" }) } next() diff --git a/packages/server/src/middlewares/permissions/index.js b/packages/server/src/middlewares/permissions/index.js new file mode 100644 index 00000000..bff9e765 --- /dev/null +++ b/packages/server/src/middlewares/permissions/index.js @@ -0,0 +1,39 @@ +import { Config } from "../../models" + +export default (req, res, next) => { + const requestedPath = `${req.method.toLowerCase()}${req.path.toLowerCase()}` + + Config.findOne({ key: "permissions" }, undefined, { + lean: true, + }).then(({ value }) => { + req.assertedPermissions = [] + + const pathRoles = value.pathRoles ?? {} + + if (typeof pathRoles[requestedPath] === "undefined") { + console.warn(`[Permissions] No permissions defined for path ${requestedPath}`) + return next() + } + + const requiredRoles = Array.isArray(pathRoles[requestedPath]) ? pathRoles[requestedPath] : [pathRoles[requestedPath]] + + requiredRoles.forEach((role) => { + if (req.user.roles.includes(role)) { + req.assertedPermissions.push(role) + } + }) + + if (req.user.roles.includes("admin")) { + req.assertedPermissions.push("admin") + } + + if (req.assertedPermissions.length === 0 && !req.user.roles.includes("admin")) { + return res.status(403).json({ + error: "forbidden", + message: "You don't have permission to access this resource", + }) + } + + next() + }) +} \ No newline at end of file diff --git a/packages/server/src/middlewares/roles/index.js b/packages/server/src/middlewares/roles/index.js index 9e1d58f4..16c9e3c3 100644 --- a/packages/server/src/middlewares/roles/index.js +++ b/packages/server/src/middlewares/roles/index.js @@ -7,5 +7,13 @@ export default (req, res, next) => { return false } + req.hasRole = (role) => { + if (req.user.roles.includes(role)) { + return true + } + + return false + } + next() } \ No newline at end of file diff --git a/packages/server/src/middlewares/withAuthentication/index.js b/packages/server/src/middlewares/withAuthentication/index.js new file mode 100644 index 00000000..71509706 --- /dev/null +++ b/packages/server/src/middlewares/withAuthentication/index.js @@ -0,0 +1,63 @@ +import { Session, User } from "../../models" +import { Token } from "../../lib" +import jwt from "jsonwebtoken" + +export default (req, res, next) => { + function reject(description) { + return res.status(401).send({ error: `${description ?? "Invalid session"}` }) + } + + const authHeader = req.headers?.authorization?.split(" ") + + if (authHeader && authHeader[0] === "Bearer") { + const token = authHeader[1] + let decoded = null + + try { + decoded = jwt.decode(token) + } catch (error) { + console.error(error) + } + + if (!decoded) { + return reject("Cannot decode token") + } + + jwt.verify(token, global.jwtStrategy.secretOrKey, async (err) => { + const sessions = await Session.find({ user_id: decoded.user_id }) + const currentSession = sessions.find((session) => session.token === token) + + if (!currentSession) { + return reject("Cannot find session") + } + + const userData = await User.findOne({ _id: currentSession.user_id }).select("+refreshToken") + + if (!userData) { + return res.status(404).send({ error: "No user data found" }) + } + + if (err) { + if (decoded.refreshToken === userData.refreshToken) { + const regeneratedToken = await Token.createNewAuthToken(userData, { + ...global.jwtStrategy, + updateSession: currentSession._id, + }) + + res.setHeader("regenerated_token", regeneratedToken) + } else { + return reject("Token expired, cannot refresh token either") + } + } + + req.user = userData + req.jwtToken = token + req.decodedToken = decoded + req.currentSession = currentSession + + return next() + }) + } else { + return reject("Missing token header") + } +} diff --git a/packages/server/src/models/index.js b/packages/server/src/models/index.js index a5fbcab0..1e8ad9de 100644 --- a/packages/server/src/models/index.js +++ b/packages/server/src/models/index.js @@ -1,11 +1,10 @@ -import mongoose from 'mongoose' -import { Schema } from 'mongoose' +import mongoose, { Schema } from "mongoose" function getSchemas() { const obj = Object() - + const _schemas = require("../schemas") - Object.keys(_schemas).forEach(key => { + Object.keys(_schemas).forEach((key) => { obj[key] = Schema(_schemas[key]) }) @@ -14,6 +13,7 @@ function getSchemas() { const schemas = getSchemas() -export const Role = mongoose.model('Role', schemas.Role, 'roles') -export const User = mongoose.model('User', schemas.User, "accounts") -export const Session = mongoose.model('Session', schemas.Session, "sessions") \ No newline at end of file +export const Config = mongoose.model("Config", schemas.Config, "config") +export const User = mongoose.model("User", schemas.User, "accounts") +export const Session = mongoose.model("Session", schemas.Session, "sessions") +export const Role = mongoose.model("Role", schemas.Role, "roles") \ No newline at end of file diff --git a/packages/server/src/schemas/config/index.js b/packages/server/src/schemas/config/index.js new file mode 100644 index 00000000..5cfd1af1 --- /dev/null +++ b/packages/server/src/schemas/config/index.js @@ -0,0 +1,3 @@ +export default { + key: { type: String, required: true }, +} \ No newline at end of file diff --git a/packages/server/src/schemas/index.js b/packages/server/src/schemas/index.js index 86928a02..b2b01250 100644 --- a/packages/server/src/schemas/index.js +++ b/packages/server/src/schemas/index.js @@ -1,3 +1,4 @@ -export { default as User } from './user' -export { default as Role } from './role' -export { default as Session } from './session' \ No newline at end of file +export { default as User } from "./user" +export { default as Role } from "./role" +export { default as Session } from "./session" +export { default as Config } from "./config" \ No newline at end of file diff --git a/packages/server/src/schemas/session/index.js b/packages/server/src/schemas/session/index.js index 654e1d77..03b8d87b 100644 --- a/packages/server/src/schemas/session/index.js +++ b/packages/server/src/schemas/session/index.js @@ -1,9 +1,8 @@ export default { - allowRegenerate: { type: Boolean, default: false }, - uuid: { type: String, required: true }, + session_uuid: { type: String, required: true }, token: { type: String, required: true }, + username: { type: String, required: true }, user_id: { type: String, required: true }, date: { type: Number, default: 0 }, location: { type: String, default: "Unknown" }, - geo: { type: String, default: "Unknown" }, } \ No newline at end of file diff --git a/packages/server/src/schemas/user/index.js b/packages/server/src/schemas/user/index.js index 2ef04b1c..a37cba62 100644 --- a/packages/server/src/schemas/user/index.js +++ b/packages/server/src/schemas/user/index.js @@ -1,10 +1,9 @@ export default { + refreshToken: { type: String, select: false }, username: { type: String, required: true }, password: { type: String, required: true, select: false }, fullName: String, avatar: { type: String }, email: String, - roles: [], - legal_id: Object, - phone: Number, + roles: { type: Array, default: [] }, } \ No newline at end of file