Skip to content

Commit

Permalink
feat(rbac): implement role-based access control
Browse files Browse the repository at this point in the history
- assign role on user registration
- implement middleware to check role on protected routes
- write tests for roleCheck middleware

[Finishes #45]
  • Loading branch information
aimedivin committed May 5, 2024
1 parent ac06e84 commit fdef529
Show file tree
Hide file tree
Showing 11 changed files with 184 additions and 19 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"express-winston": "^4.2.0",
"highlight.js": "^11.9.0",
"jsend": "^1.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^6.9.13",
"nodemon": "^3.1.0",
Expand Down Expand Up @@ -57,11 +58,13 @@
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/jsend": "^1.0.32",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.7",
"@types/nodemailer": "^6.4.15",
"@types/reflect-metadata": "^0.1.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"@types/winston": "^2.4.4",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
Expand All @@ -76,6 +79,7 @@
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.7.1"
"typescript-eslint": "^7.7.1",
"uuid": "^9.0.1"
}
}
90 changes: 90 additions & 0 deletions src/__test__/roleCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Response, NextFunction, Request } from 'express';
import { User } from '../entities/User';
import { hasRole } from '../middlewares';
import { responseError } from '../utils/response.utils';
import { dbConnection } from '../startups/dbConnection';
import { v4 as uuid } from 'uuid';

let reqMock: Partial<Request>;
let resMock: Partial<Response>;
let nextMock: NextFunction;

const userId = uuid();

beforeAll(async () => {
// Connect to the test database
const connection = await dbConnection();

const userRepository = connection?.getRepository(User);

const user = new User();

user.id = userId;
user.firstName = 'John2';
user.lastName = 'Doe';
user.email = 'john2.doe@example.com';
user.password = 'password';
user.gender = 'Male';
user.phoneNumber = '1234';
user.userType = 'Buyer';
user.photoUrl = 'https://example.com/photo.jpg';

await userRepository?.save(user);
});

afterAll(async () => {

});

describe('hasRole MiddleWare Test', () => {

beforeEach(() => {
reqMock = {};
resMock = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
nextMock = jest.fn();
});

it('should return 401, if user is not authentication', async () => {
await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock);
expect(responseError).toHaveBeenCalled;
expect(resMock.status).toHaveBeenCalledWith(401);
});

it('should return 401 if user is not found', async () => {
reqMock = { user: { id: uuid() } };

await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock);

expect(responseError).toHaveBeenCalled;
expect(resMock.status).toHaveBeenCalledWith(401);
});

it('should return 403 if user does not have required role', async () => {
reqMock = { user: { id: userId } };

await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock);

expect(responseError).toHaveBeenCalled;
expect(resMock.status).toHaveBeenCalledWith(403);
});

it('should call next() if user has required role', async () => {
reqMock = { user: { id: userId } };

await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock);

expect(nextMock).toHaveBeenCalled();
});

it('should return 400 if user id is of invalid format', async () => {
reqMock = { user: { id: 'sample userId' } };

await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock);

expect(responseError).toHaveBeenCalled;
expect(resMock.status).toHaveBeenCalledWith(400);
});
});
4 changes: 2 additions & 2 deletions src/__test__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,15 @@ describe('POST /user/verify/:id', () => {
// Assert
expect(verifyRes.status).toBe(200);
expect(verifyRes.text).toEqual('<p>User verified successfully</p>');

// Check that the user's verified field is now true
const verifiedUser = await userRepository.findOne({ where: { email: newUser.email } });
if (verifiedUser){
expect(verifiedUser.verified).toBe(true);
}

}

if (user) {
await userRepository.remove(user);
}
Expand Down
9 changes: 2 additions & 7 deletions src/controllers/authController.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { Request, Response } from 'express';
import { User } from '../entities/User';
import bcrypt from 'bcrypt';
import { getRepository } from 'typeorm';
import { responseError, responseServerError, responseSuccess } from '../utils/response.utils';
import { validate } from 'class-validator';
import { userVerificationService, userRegistrationService } from '../services';

export const userRegistration = async (req: Request, res: Response) => {

Check warning on line 4 in src/controllers/authController.ts

View workflow job for this annotation

GitHub Actions / build-lint-test-coverage

Missing return type on function

Check warning on line 4 in src/controllers/authController.ts

View workflow job for this annotation

GitHub Actions / build-lint-test-coverage

Missing return type on function
await userRegistrationService(req, res);
}
};
export const userVerification = async (req: Request, res: Response) => {

Check warning on line 7 in src/controllers/authController.ts

View workflow job for this annotation

GitHub Actions / build-lint-test-coverage

Missing return type on function

Check warning on line 7 in src/controllers/authController.ts

View workflow job for this annotation

GitHub Actions / build-lint-test-coverage

Missing return type on function
await userVerificationService(req, res);
}
};

28 changes: 27 additions & 1 deletion src/entities/User.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { Entity, PrimaryGeneratedColumn, Column, Unique, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, Unique, CreateDateColumn, UpdateDateColumn, BeforeInsert } from 'typeorm';
import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator';
import { roles } from '../utils/roles';

export interface UserInterface {
id: string;
firstName: string;
lastName: string;
email: string;
password: string;
gender: string;
phoneNumber: string;
photoUrl?: string;
verified: boolean;
status: 'active' | 'suspended';
userType: 'Admin' | 'Buyer' | 'Vendor';
role: string;
createdAt: Date;
updatedAt: Date;
}

@Entity()
@Unique(['email'])
Expand Down Expand Up @@ -54,9 +72,17 @@ export class User {
@IsIn(['Admin', 'Buyer', 'Vendor'])
userType!: 'Admin' | 'Buyer' | 'Vendor';

@Column()
role!: string;

@CreateDateColumn()
createdAt!: Date;

@UpdateDateColumn()
updatedAt!: Date;

@BeforeInsert()
setRole (): void {
this.role = this.userType === 'Vendor' ? roles.vendor : roles.buyer;
}
}
2 changes: 2 additions & 0 deletions src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
// export all middlewares

export * from './roleCheck';
43 changes: 43 additions & 0 deletions src/middlewares/roleCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextFunction, Request, Response } from "express";
import { User, UserInterface } from "../entities/User";
import { getRepository } from "typeorm";
import { responseError } from "../utils/response.utils";


/**
* Middleware to check user role before granting access to protectered routes.
* @param {("ADMIN" | "VENDOR" | "BUYER")} role - The role required to access the route.
* @returns {function} Helper function for making responses.
*/

declare module 'express' {
interface Request {
user?: Partial<UserInterface>;
}
}

export const hasRole = (role: "ADMIN" | "VENDOR" | "BUYER") => async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.user) {
return responseError(res, 401, 'Authentication required');
}

const userId = req.user.id;

const userRepository = getRepository(User);

const user = await userRepository.findOne({ where: { id: userId } });
if (!user) {
return responseError(res, 401, 'User not found');
}
if (user.role !== role) {
return responseError(res, 403, 'Unauthorized action');
}

next();
} catch (error) {
responseError(res, 400, (error as Error).message);
}
};


2 changes: 1 addition & 1 deletion src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response, Router } from 'express';
import userRoutes from './UserRoutes';
import { responseSuccess } from '../utils/response.utils';
import userRoutes from './UserRoutes';

const router = Router();

Expand Down
8 changes: 4 additions & 4 deletions src/services/userServices/userRegistrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ export const userRegistrationService = async (req: Request, res: Response) => {
text: `Welcome to the app, ${firstName} ${lastName}!`,
lastName: lastName,
firstName: firstName,
}
const link = `http://localhost:${process.env.PORT}/user/verify/${user.id}`
};
const link = `http://localhost:${process.env.PORT}/user/verify/${user.id}`;

sendMail(process.env.AUTH_EMAIL, process.env.AUTH_PASSWORD, message, link);


} else {
// return res.status(500).json({ error: 'Email or password for mail server not configured' });
return responseError(res, 500 , 'Email or password for mail server not configured');
return responseError(res, 500 , 'Email or password for mail server not configured');
}

return responseSuccess(res, 201, 'User registered successfully');
Expand Down
6 changes: 3 additions & 3 deletions src/services/userServices/userValidationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getRepository } from 'typeorm';

export const userVerificationService = async (req: Request, res: Response) => {
const { id } = req.params;

// Validate user input
if (!id) {
return res.status(400).json({ error: 'Missing user ID' });
Expand All @@ -22,7 +22,7 @@ export const userVerificationService = async (req: Request, res: Response) => {
user.verified = true;

await userRepository.save(user);

return res.status(200).send('<p>User verified successfully</p>');

}
};
5 changes: 5 additions & 0 deletions src/utils/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const roles = {
admin: "ADMIN",
vendor: "VENDOR",
buyer: "BUYER"
};

0 comments on commit fdef529

Please sign in to comment.