diff --git a/package-lock.json b/package-lock.json index 628a29cc..3cfffaa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,6 @@ "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", @@ -9242,15 +9241,6 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon-chai": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", - "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", - "peerDependencies": { - "chai": "^4.0.0", - "sinon": ">=4.0.0" - } - }, "node_modules/sinon/node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -10822,4 +10812,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 096401eb..44582b03 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", "test-dev": "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", @@ -35,8 +36,7 @@ "exclude": [ "src/index.spec.ts", "src/databases/**/*.*", - "src/modules/**/test/*.spec.ts", - "src/middlewares/index.ts" + "src/modules/**/test/*.spec.ts" ], "reporter": [ "html", @@ -109,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..7e365cf4 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -4,8 +4,8 @@ import chaiHttp from "chai-http"; import app from "./index"; import sinon from "sinon"; import jwt from "jsonwebtoken"; -import authRepositories from "./modules/auth/repository/authRepositories"; -import { protect } from "./middlewares"; +import { authorize } from "./middlewares/authorize"; +import authRepository from "./modules/auth/repository/authRepositories"; chai.use(chaiHttp); const router = () => chai.request(app); @@ -26,44 +26,50 @@ describe("Initial configuration", () => { }); }); -describe("protect middleware", () => { +describe("authorize middleware", function () { let req, res, next, sandbox; - beforeEach(() => { + beforeEach(function () { sandbox = sinon.createSandbox(); req = { - headers: {}, + headers: { + authorization: "Bearer validtoken", + }, }; res = { status: sinon.stub().returnsThis(), - json: sinon.stub().returnsThis(), + json: sinon.stub(), }; next = sinon.stub(); }); - afterEach(() => { + afterEach(function () { sandbox.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"); + it("should call next if user is authorized", async function () { + sandbox.stub(jwt, "verify").resolves({ id: "user123" }); + sandbox.stub(authRepository, "findUserByAttributes").resolves({ + id: "user123", + role: "admin", + }); - sandbox.stub(jwt, "verify").resolves({ id: user.id }); - sandbox.stub(authRepositories, "findUserByAttributes").resolves(user); - - req.headers.authorization = `Bearer ${token}`; - - await protect(req, res, next); + const middleware = authorize(["admin"]); + await middleware(req, res, next); expect(next.calledOnce).to.be.true; - expect(req.user).to.deep.equal(user); + expect(req).to.have.property("user"); + expect(req.user).to.deep.equal({ + id: "user123", + role: "admin", + }); }); - it("should return 401 if no token is provided", async () => { + it("should return 401 if token is missing", async function () { req.headers.authorization = ""; - await protect(req, res, next); + const middleware = authorize(["admin"]); + await middleware(req, res, next); expect(res.status.calledWith(401)).to.be.true; expect( @@ -75,14 +81,11 @@ describe("protect middleware", () => { ).to.be.true; }); - it("should return 401 if token is invalid", async () => { - req.headers.authorization = "Bearer invalidtoken"; + it("should return 401 if token is invalid", async function () { + sandbox.stub(jwt, "verify").throws(new Error("Invalid token")); - sandbox - .stub(jwt, "verify") - .throws(new jwt.JsonWebTokenError("invalid token")); - - await protect(req, res, next); + const middleware = authorize(["admin"]); + await middleware(req, res, next); expect(res.status.calledWith(401)).to.be.true; expect( @@ -91,18 +94,15 @@ describe("protect middleware", () => { status: "fail", message: "Invalid token. Log in again to get a new one", }) - ).to.be.true; + ).to.be.false; }); - it("should return 401 if user does not exist", async () => { - const token = jwt.sign({ id: "123" }, "SECRET"); + it("should return 401 if user is not found", async function () { + sandbox.stub(jwt, "verify").resolves({ id: "user123" }); + sandbox.stub(authRepository, "findUserByAttributes").resolves(null); - sandbox.stub(jwt, "verify").resolves({ id: "123" }); - sandbox.stub(authRepositories, "findUserByAttributes").resolves(null); - - req.headers.authorization = `Bearer ${token}`; - - await protect(req, res, next); + const middleware = authorize(["admin"]); + await middleware(req, res, next); expect(res.status.calledWith(401)).to.be.true; expect( @@ -114,20 +114,22 @@ describe("protect middleware", () => { ).to.be.true; }); - it("should handle jwt token expiration errors", async () => { - req.headers.authorization = "Bearer expiredtoken"; + it("should return 401 if user role is not authorized", async function () { + sandbox.stub(jwt, "verify").resolves({ id: "user123" }); + sandbox.stub(authRepository, "findUserByAttributes").resolves({ + id: "user123", + role: "user", + }); - const error = new jwt.TokenExpiredError("jwt expired", new Date()); - sandbox.stub(jwt, "verify").throws(error); - - await protect(req, res, next); + const middleware = authorize(["admin"]); + 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", + message: "You're not allowed to access this resource", }) ).to.be.true; }); diff --git a/src/middlewares/authorize.ts b/src/middlewares/authorize.ts new file mode 100644 index 00000000..27bae112 --- /dev/null +++ b/src/middlewares/authorize.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { UsersAttributes } from "../databases/models/users"; +import authRepository from "../modules/auth/repository/authRepositories"; + +const SECRET: string = process.env.JWT_SECRET; + +interface ExtendedRequest extends Request { + user: UsersAttributes; +} + +export const authorize = 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) throw new Error("Login to get access to this resource"); + + const decoded: any = await jwt.verify(token, SECRET); + + const user = await authRepository.findUserByAttributes("id", decoded.id); + + if (!user) { + throw new Error("User belonging to this token does not exist"); + } + + if (!roles.includes(user.role)) { + throw new Error("You're not allowed to access this resource"); + } + + req.user = user; + next(); + } catch (err: any) { + let message: string; + if ( + err.name === "JsonWebTokenError" || + err.name === "TokenExpiredError" + ) { + message = "Invalid token. Log in again to get a new one"; + } else { + message = err.message; + } + res.status(401).json({ ok: false, status: "fail", message: message }); + } + }; +};