diff --git a/package-lock.json b/package-lock.json index 628a29cc..18b97fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10822,4 +10822,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index d4a09b50..ec0cff54 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "start": "ts-node-dev src/index.ts", "dev": "ts-node-dev src/index.ts", "test": "cross-env NODE_ENV=test npm run deleteAllTables && cross-env NODE_ENV=test npm run createAllTables && cross-env NODE_ENV=test npm run createAllSeeders && nyc cross-env NODE_ENV=test mocha --require ts-node/register 'src/**/*.spec.ts' --timeout 600000 --exit", + "test-local": "cross-env NODE_ENV=development npm run deleteAllTables && cross-env NODE_ENV=development npm run createAllTables && cross-env NODE_ENV=development npm run createAllSeeders && nyc cross-env NODE_ENV=development mocha --require ts-node/register 'src/**/*.spec.ts' --timeout 600000 --exit", "coveralls": "nyc --reporter=lcov --reporter=text-lcov npm test | coveralls", "coverage": "cross-env NODE_ENV=test nyc mocha --require ts-node/register 'src/**/*.spec.ts' --timeout 600000 --exit", "lint": "eslint . --ext .ts", @@ -34,8 +35,7 @@ "exclude": [ "src/index.spec.ts", "src/databases/**/*.*", - "src/modules/**/test/*.spec.ts", - "src/middlewares/index.ts" + "src/modules/**/test/*.spec.ts" ], "reporter": [ "html", @@ -83,6 +83,7 @@ "sequelize-cli": "^6.6.2", "sequelize-typescript": "^2.1.6", "sinon": "^18.0.0", + "sinon-chai": "^3.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.2", @@ -108,4 +109,4 @@ "eslint-plugin-import": "^2.29.1", "lint-staged": "^15.2.2" } -} \ No newline at end of file +} diff --git a/src/databases/config/config.ts b/src/databases/config/config.ts index 63c30ace..33e59b5c 100644 --- a/src/databases/config/config.ts +++ b/src/databases/config/config.ts @@ -1,3 +1,4 @@ +/* eslint-disable comma-dangle */ import dotenv from "dotenv"; dotenv.config(); diff --git a/src/databases/config/db.config.ts b/src/databases/config/db.config.ts index a83cfa1b..0c54a72d 100644 --- a/src/databases/config/db.config.ts +++ b/src/databases/config/db.config.ts @@ -1,3 +1,4 @@ +/* eslint-disable comma-dangle */ import { config } from "dotenv"; import { Sequelize } from "sequelize"; diff --git a/src/index.spec.ts b/src/index.spec.ts index 9d29e1bf..e4c2d294 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,13 +1,19 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable comma-dangle */ -import chai, { expect } from "chai"; -import chaiHttp from "chai-http"; import app from "./index"; -import sinon from "sinon"; -import jwt from "jsonwebtoken"; +import chai from "chai"; +import chaiHttp from "chai-http"; +const { expect } = require("chai"); +const sinon = require("sinon"); +const sinonChai = require("sinon-chai"); +const { userAuthorization } = require("./middlewares/authorization"); +const httpStatus = require("http-status"); +import * as helpers from "./helpers/index"; import authRepositories from "./modules/auth/repository/authRepositories"; -import { protect } from "./middlewares"; chai.use(chaiHttp); +chai.use(sinonChai); +// const router = () => chai.request(app); describe("Initial configuration", () => { @@ -26,109 +32,116 @@ describe("Initial configuration", () => { }); }); -describe("protect middleware", () => { - let req, res, next, sandbox; +describe("userAuthorization middleware", () => { + let req, res, next, roles; beforeEach(() => { - sandbox = sinon.createSandbox(); + roles = ["admin", "user"]; req = { headers: {}, + user: null, + session: null, }; res = { status: sinon.stub().returnsThis(), json: sinon.stub().returnsThis(), }; - next = sinon.stub(); + next = sinon.spy(); }); afterEach(() => { - sandbox.restore(); + sinon.restore(); }); - it("should call next() if token is valid and user exists", async () => { - const user = { id: "123", name: "John Doe" }; - const token = jwt.sign({ id: user.id }, "SECRET"); - - sandbox.stub(jwt, "verify").resolves({ id: user.id }); - sandbox.stub(authRepositories, "findUserByAttributes").resolves(user); - - req.headers.authorization = `Bearer ${token}`; - - await protect(req, res, next); + it("should respond with 401 if no authorization header", async () => { + const middleware = userAuthorization(roles); + await middleware(req, res, next); - expect(next.calledOnce).to.be.true; - expect(req.user).to.deep.equal(user); + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); }); - it("should return 401 if no token is provided", async () => { - req.headers.authorization = ""; + it("should respond with 401 if no session found", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves(null); - await protect(req, res, next); + const middleware = userAuthorization(roles); + await middleware(req, res, next); - expect(res.status.calledWith(401)).to.be.true; - expect( - res.json.calledWith({ - ok: false, - status: "fail", - message: "Login to get access to this resource", - }) - ).to.be.true; + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); }); - it("should return 401 if token is invalid", async () => { - req.headers.authorization = "Bearer invalidtoken"; + it("should respond with 401 if no user found", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves({}); + sinon.stub(authRepositories, "findUserByAttributes").resolves(null); - sandbox - .stub(jwt, "verify") - .throws(new jwt.JsonWebTokenError("invalid token")); + const middleware = userAuthorization(roles); + await middleware(req, res, next); - await protect(req, res, next); - - expect(res.status.calledWith(401)).to.be.true; - expect( - res.json.calledWith({ - ok: false, - status: "fail", - message: "Invalid token. Log in again to get a new one", - }) - ).to.be.true; + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); }); - it("should return 401 if user does not exist", async () => { - const token = jwt.sign({ id: "123" }, "SECRET"); - - sandbox.stub(jwt, "verify").resolves({ id: "123" }); - sandbox.stub(authRepositories, "findUserByAttributes").resolves(null); + it("should respond with 401 if user role is not authorized", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves({}); + sinon + .stub(authRepositories, "findUserByAttributes") + .resolves({ role: "guest" }); + + const middleware = userAuthorization(roles); + await middleware(req, res, next); + + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); + }); - req.headers.authorization = `Bearer ${token}`; + it("should call next if user is authorized", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves({}); + sinon + .stub(authRepositories, "findUserByAttributes") + .resolves({ role: "admin" }); - await protect(req, res, next); + const middleware = userAuthorization(roles); + await middleware(req, res, next); - expect(res.status.calledWith(401)).to.be.true; - expect( - res.json.calledWith({ - ok: false, - status: "fail", - message: "User belonging to this token does not exist", - }) - ).to.be.true; + expect(next).to.have.been.calledOnce; + expect(req.user).to.deep.equal({ role: "admin" }); + expect(req.session).to.deep.equal({}); }); - it("should handle jwt token expiration errors", async () => { - req.headers.authorization = "Bearer expiredtoken"; - - const error = new jwt.TokenExpiredError("jwt expired", new Date()); - sandbox.stub(jwt, "verify").throws(error); + it("should respond with 500 if an unexpected error occurs", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").rejects(new Error("Unexpected error")); - await protect(req, res, next); + const middleware = userAuthorization(roles); + await middleware(req, res, next); - expect(res.status.calledWith(401)).to.be.true; - expect( - res.json.calledWith({ - ok: false, - status: "fail", - message: "Invalid token. Log in again to get a new one", - }) - ).to.be.true; + expect(res.status).to.have.been.calledWith( + httpStatus.INTERNAL_SERVER_ERROR + ); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: "Unexpected error", + }); }); }); diff --git a/src/middlewares/authorization.ts b/src/middlewares/authorization.ts new file mode 100644 index 00000000..e863db1d --- /dev/null +++ b/src/middlewares/authorization.ts @@ -0,0 +1,64 @@ +/* eslint-disable comma-dangle */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Request, Response, NextFunction } from "express"; +import { UsersAttributes } from "../databases/models/users"; +import authRepository from "../modules/auth/repository/authRepositories"; +import httpStatus from "http-status"; +import { decodeToken } from "../helpers"; +import Session from "../databases/models/session"; + +interface ExtendedRequest extends Request { + user: UsersAttributes; + session: Session; +} + +export const userAuthorization = function (roles: string[]) { + return async (req: ExtendedRequest, res: Response, next: NextFunction) => { + try { + let token: string; + if (req.headers.authorization?.startsWith("Bearer")) { + token = req.headers.authorization.split(" ").at(-1); + } + + if (!token) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + const decoded: any = await decodeToken(token); + + const session: Session = await authRepository.findSessionByUserIdAndToken( + decoded.id, token + ); + if (!session) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + const user = await authRepository.findUserByAttributes("id", decoded.id); + + if (!user) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + if (!roles.includes(user.role)) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + req.user = user; + req.session = session; + next(); + } catch (error: any) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: error.message, + }); + } + }; +}; diff --git a/src/modules/auth/repository/authRepositories.ts b/src/modules/auth/repository/authRepositories.ts index 4eb75488..9f4504fb 100644 --- a/src/modules/auth/repository/authRepositories.ts +++ b/src/modules/auth/repository/authRepositories.ts @@ -1,31 +1,52 @@ +/* eslint-disable comma-dangle */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import Users from "../../../databases/models/users" -import Session from "../../../databases/models/session" +import Users from "../../../databases/models/users"; +import Session from "../../../databases/models/session"; + +const createUser = async (body: any) => { + return await Users.create(body); +}; + +const findUserByAttributes = async (key: string, value: any) => { + return await Users.findOne({ where: { [key]: value } }); +}; + +const updateUserByAttributes = async ( + updatedKey: string, + updatedValue: any, + whereKey: string, + whereValue: any +) => { + await Users.update( + { [updatedKey]: updatedValue }, + { where: { [whereKey]: whereValue } } + ); + return await findUserByAttributes(whereKey, whereValue); +}; -const createUser = async (body:any) =>{ - return await Users.create(body) -} +const createSession = async (body: any) => { + return await Session.create(body); +}; +const findSessionByUserId = async (userId: number) => { + return await Session.findOne({ where: { userId } }); +}; -const findUserByAttributes = async (key:string, value:any) =>{ - return await Users.findOne({ where: { [key]: value} }) +const findSessionByUserIdAndToken = async (userId: number, token: string) => { + return await Session.findOne({where: {token, userId}}) } -const updateUserByAttributes = async (updatedKey:string, updatedValue:any, whereKey:string, whereValue:any) =>{ - await Users.update({ [updatedKey]: updatedValue }, { where: { [whereKey]: whereValue} }); - return await findUserByAttributes(whereKey, whereValue) -} -const createSession = async (body: any) => { - return await Session.create(body); -} - -const findSessionByUserId = async( userId:number ) => { - return await Session.findOne({ where: { userId } }); -} - -const destroySession = async (userId: number, token:string) =>{ - return await Session.destroy({ where: {userId, token } }); -} +const destroySession = async (userId: number, token: string) => { + return await Session.destroy({ where: { userId, token } }); +}; -export default { createUser, createSession, findUserByAttributes, destroySession, updateUserByAttributes, findSessionByUserId } \ No newline at end of file +export default { + createUser, + createSession, + findUserByAttributes, + destroySession, + updateUserByAttributes, + findSessionByUserId, + findSessionByUserIdAndToken +};