From 6ec9486a28f07a4dbf6088c66109961caa22abcc Mon Sep 17 00:00:00 2001 From: kakiba <97882386+kotto5@users.noreply.github.com> Date: Sun, 10 Dec 2023 21:43:40 +0900 Subject: [PATCH] Feat/backend/room/add role guard (#127) add RoomRolesGuard --- backend/src/room/room-member.guard.ts | 56 +++++++ backend/src/room/room.controller.ts | 35 ++-- backend/src/room/room.service.ts | 170 ++++++++++--------- backend/test/room.e2e-spec.ts | 225 ++++++++++++++++++++++++-- 4 files changed, 379 insertions(+), 107 deletions(-) create mode 100644 backend/src/room/room-member.guard.ts diff --git a/backend/src/room/room-member.guard.ts b/backend/src/room/room-member.guard.ts new file mode 100644 index 00000000..ce811042 --- /dev/null +++ b/backend/src/room/room-member.guard.ts @@ -0,0 +1,56 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Reflector } from '@nestjs/core'; +import { RoomService } from './room.service'; +import { Role } from '@prisma/client'; + +interface User { + id: number; + name: string; +} + +@Injectable() +export class RoomRolesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private roomService: RoomService, + ) {} + + canActivate( + context: ExecutionContext, + ): Promise | boolean | Observable { + const request = context.switchToHttp().getRequest(); + const user = request['user']; + const roomId = request.params.id; + return this.getUserRole(user, roomId) + .then((userRole) => { + request['userRole'] = userRole; + return true; + }) + .catch(() => { + return false; + }); + } + + private getUserRole(user: User, roomId: string): Promise { + return this.roomService + .findUserOnRoom(Number(roomId), user.id) + .then((userOnRoomEntity) => userOnRoomEntity.role) + .catch(() => Promise.reject()); + } + + private meetRequirement(need: Role, userRole: Role): boolean { + return this.roleToNum(userRole) >= this.roleToNum(need); + } + + private roleToNum(role: Role): number { + switch (role) { + case Role.MEMBER: + return 0; + case Role.ADMINISTRATOR: + return 1; + case Role.OWNER: + return 2; + } + } +} diff --git a/backend/src/room/room.controller.ts b/backend/src/room/room.controller.ts index 9976a684..130f0eeb 100644 --- a/backend/src/room/room.controller.ts +++ b/backend/src/room/room.controller.ts @@ -25,6 +25,7 @@ import { RoomEntity } from './entities/room.entity'; import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; import { UserOnRoomEntity } from './entities/UserOnRoom.entity'; import { UpdateUserOnRoomDto } from './dto/update-UserOnRoom.dto'; +import { RoomRolesGuard } from './room-member.guard'; @Controller('room') @ApiTags('room') @@ -45,15 +46,15 @@ export class RoomController { } @Get(':id') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RoomRolesGuard) @ApiBearerAuth() @ApiOkResponse({ type: RoomEntity }) - findOne(@Param('id', ParseIntPipe) id: number, @Req() request: Request) { - return this.roomService.findRoom(id, request['user']); + findOne(@Param('id', ParseIntPipe) id: number) { + return this.roomService.findRoom(id); } @Patch(':id') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RoomRolesGuard) @ApiBearerAuth() @ApiOkResponse({ type: RoomEntity }) update( @@ -61,19 +62,16 @@ export class RoomController { @Body() updateRoomDto: UpdateRoomDto, @Req() request: Request, ) { - return this.roomService.updateRoom(id, updateRoomDto, request['user']); + return this.roomService.updateRoom(id, updateRoomDto, request['userRole']); } @Delete(':id') @HttpCode(204) - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RoomRolesGuard) @ApiBearerAuth() @ApiNoContentResponse() - async removeRoom( - @Param('id', ParseIntPipe) id: number, - @Req() request: Request, - ) { - await this.roomService.removeRoom(id, request['user']); + removeRoom(@Param('id', ParseIntPipe) id: number, @Req() request: Request) { + return this.roomService.removeRoom(id, request['userRole']); } @Post(':id') @@ -88,35 +86,36 @@ export class RoomController { } @Get(':id/:userId') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RoomRolesGuard) @ApiBearerAuth() @ApiOkResponse({ type: UserOnRoomEntity }) getUserOnRoom( @Param('id', ParseIntPipe) id: number, @Param('userId', ParseIntPipe) userId: number, - @Req() request: Request, ) { - return this.roomService.findUserOnRoom(id, request['user'], userId); + return this.roomService.findUserOnRoom(id, userId); } @Delete(':id/:userId') @HttpCode(204) - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RoomRolesGuard) @ApiBearerAuth() @ApiNoContentResponse() async deleteUserOnRoom( @Param('id', ParseIntPipe) id: number, @Param('userId', ParseIntPipe) userId: number, + @Req() request: Request, ) { await this.roomService.removeUserOnRoom( id, - { id: userId, name: 'test' }, + request['userRole'], userId, + request['user'], ); } @Patch(':id/:userId') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RoomRolesGuard) @ApiBearerAuth() @ApiOkResponse({ type: UserOnRoomEntity }) updateUserOnRoom( @@ -127,7 +126,7 @@ export class RoomController { ) { return this.roomService.updateUserOnRoom( id, - request['user'], + request['userRole'], userId, updateUserOnRoomDto, ); diff --git a/backend/src/room/room.service.ts b/backend/src/room/room.service.ts index 55707886..ddf0aa3d 100644 --- a/backend/src/room/room.service.ts +++ b/backend/src/room/room.service.ts @@ -42,60 +42,35 @@ export class RoomService { return this.prisma.room.findMany(); } - findRoom(id: number, user: User): Promise { - return this.prisma.room - .findUniqueOrThrow({ - where: { id }, - include: { - users: true, - }, - }) - .then((roomEntity) => { - const userOnRoomEntity = roomEntity.users.find( - (userOnRoomEntity) => userOnRoomEntity.userId === user.id, - ); - if (userOnRoomEntity === undefined) { - throw new HttpException('Forbidden', 403); - } else { - return roomEntity; - } - }); + findRoom(id: number): Promise { + return this.prisma.room.findUniqueOrThrow({ + where: { id }, + include: { + users: true, + }, + }); } - updateRoom( + updateRoom = ( id: number, updateRoomDto: UpdateRoomDto, - user: User, - ): Promise { - return this.findUserOnRoom(id, user, user.id).then((userOnRoomEntity) => { - if (userOnRoomEntity.role !== Role.OWNER) { - throw new HttpException('Forbidden', 403); - } else { - return this.prisma.room.update({ + role: Role, + ): Promise => + role !== Role.OWNER + ? Promise.reject(new HttpException('Forbidden', 403)) + : this.prisma.room.update({ where: { id }, data: updateRoomDto, }); - } - }); - } - removeRoom(id: number, user: User): Promise { - return this.findUserOnRoom(id, user, user.id) - .catch(() => { - throw new HttpException('Forbidden', 403); - }) - .then((userOnRoomEntity) => { - if (userOnRoomEntity.role !== Role.OWNER) { - throw new HttpException('Forbidden', 403); - } else { - return this.removeAllUserOnRoom(id).then(() => - this.prisma.room.delete({ - where: { id }, - }), - ); - } - }); - } + removeRoom = (id: number, role: Role): Promise => + role !== Role.OWNER + ? Promise.reject(new HttpException('Forbidden', 403)) + : this.removeAllUserOnRoom(id).then(() => + this.prisma.room.delete({ + where: { id }, + }), + ); // UserOnRoom CRUD @@ -117,7 +92,6 @@ export class RoomService { findUserOnRoom = ( roomId: number, - client: User, userId: number, ): Promise => { return this.prisma.userOnRoom.findUniqueOrThrow({ @@ -130,46 +104,71 @@ export class RoomService { }); }; - updateUserOnRoom( + updateUserOnRoom = ( roomId: number, - client: User, + role: Role, userId: number, - updateUserOnRoom: UpdateUserOnRoomDto, - ): Promise { - return this.prisma.userOnRoom.update({ - where: { - userId_roomId_unique: { - roomId: roomId, - userId: userId, - }, - }, - data: updateUserOnRoom, - }); - } + dto: UpdateUserOnRoomDto, + ): Promise => { + if (role === Role.MEMBER) + return Promise.reject(new HttpException('Forbidden', 403)); + const validateRole = + (changerRole: Role) => + (targetRole: Role): boolean => + this.roleToNum(changerRole) >= this.roleToNum(targetRole); + const validateRoleBy = validateRole(role); + + if (this.roleToNum(dto.role) !== -1 && validateRoleBy(dto.role) === false) + return Promise.reject(new HttpException('Forbidden', 403)); + else + return this.findUserOnRoom(roomId, userId) + .then((userOnRoomEntity) => + validateRoleBy(userOnRoomEntity.role) === false + ? Promise.reject(new HttpException('Forbidden', 403)) + : this.prisma.userOnRoom.update({ + where: { + userId_roomId_unique: { + roomId: roomId, + userId: userId, + }, + }, + data: dto, + }), + ) + .catch((err) => { + throw err; + }); + }; removeUserOnRoom( roomId: number, - client: User, + role: Role, userId: number, + client: User, ): Promise { - return this.findUserOnRoom(roomId, client, client.id) - .then((userOnRoomEntity) => { - if (userOnRoomEntity.role === Role.OWNER || client.id === userId) { - return this.prisma.userOnRoom.delete({ - where: { - userId_roomId_unique: { - roomId: roomId, - userId: userId, - }, + if (client.id != userId && role === Role.MEMBER) + return Promise.reject(new HttpException('Forbidden', 403)); + const validateRole = + (changerRole: Role) => + (targetRole: Role): boolean => { + return this.roleToNum(changerRole) >= this.roleToNum(targetRole); + }; + const validateRoleBy = validateRole(role); + + return this.findUserOnRoom(roomId, userId).then((userOnRoomEntity) => { + if (validateRoleBy(userOnRoomEntity.role)) { + return this.prisma.userOnRoom.delete({ + where: { + userId_roomId_unique: { + roomId: roomId, + userId: userId, }, - }); - } else { - throw 404; - } - }) - .catch((err) => { - throw err; - }); + }, + }); + } else { + return Promise.reject(new HttpException('Forbidden', 403)); + } + }); } removeAllUserOnRoom(roomId: number): Promise { @@ -177,4 +176,17 @@ export class RoomService { where: { roomId }, }); } + + private roleToNum(role: Role): number { + switch (role) { + case Role.MEMBER: + return 0; + case Role.ADMINISTRATOR: + return 1; + case Role.OWNER: + return 2; + default: + return -1; + } + } } diff --git a/backend/test/room.e2e-spec.ts b/backend/test/room.e2e-spec.ts index b6b20742..7abc05c2 100644 --- a/backend/test/room.e2e-spec.ts +++ b/backend/test/room.e2e-spec.ts @@ -12,6 +12,8 @@ import { expectUserOnRoom, } from './utils/matcher'; import { constants } from './constants'; +import { Role } from '@prisma/client'; +import { UpdateUserOnRoomDto } from 'src/room/dto/update-UserOnRoom.dto'; describe('RoomController (e2e)', () => { let app: INestApplication; @@ -54,6 +56,11 @@ describe('RoomController (e2e)', () => { .delete(`/room/${roomId}/${getUserIdFromAccessToken(accessToken)}`) .set('Authorization', `Bearer ${accessToken}`); + const kickFromRoom = (roomId: number, userId: number, accessToken: string) => + request(app.getHttpServer()) + .delete(`/room/${roomId}/${userId}`) + .set('Authorization', `Bearer ${accessToken}`); + const getRoom = (id: number, accessToken: string) => request(app.getHttpServer()) .get(`/room/${id}`) @@ -81,6 +88,17 @@ describe('RoomController (e2e)', () => { .delete(`/user/${userId}`) .set('Authorization', `Bearer ${accessToken}`); + const updateUserOnRoom = ( + roomId: number, + userId: number, + dto: UpdateUserOnRoomDto, + accessToken: string, + ) => + request(app.getHttpServer()) + .patch(`/room/${roomId}/${userId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + /* Auth API */ const login = (dto: LoginDto) => request(app.getHttpServer()).post('/auth/login').send(dto); @@ -127,6 +145,12 @@ describe('RoomController (e2e)', () => { await createUser(constants.user.admin); const accessToken = await getAccessToken(constants.user.admin); await enterRoom(accessToken, room); + await updateUserOnRoom( + room.id, + getUserIdFromAccessToken(accessToken), + { role: Role.ADMINISTRATOR }, + await getAccessToken(constants.user.owner), + ); } // Not Member { @@ -200,9 +224,7 @@ describe('RoomController (e2e)', () => { const dto: UpdateRoomDto = { name: 'new_name' }; for (const user of usersExceptOwner) { const accessToken = await getAccessToken(user); - await expect(updateRoom(room.id, accessToken, dto)).not.toBe(200); - // TODO : expect 403 - // TODO : add Guard to controller + await updateRoom(room.id, accessToken, dto).expect(403); } }); @@ -286,18 +308,201 @@ describe('RoomController (e2e)', () => { await getUserOnRoom(room.id, idOfNotMember, accessToken).expect(404); }); // TODO : add Guard to controller - // it('from notMember: should return 403 Forbidden', async () => { - // const accessToken = await getAccessToken(constants.user.notMember); - // await getUserOnRoom(room.id, 1, accessToken).expect(403); - // }); + it('from notMember: should return 403 Forbidden', async () => { + const targetId = getUserIdFromAccessToken( + await getAccessToken(constants.user.member), + ); + const accessToken = await getAccessToken(constants.user.notMember); + await getUserOnRoom(room.id, targetId, accessToken).expect(403); + }); it('from Unauthorized User: should return 401 Unauthorized', async () => { - await getUserOnRoom(room.id, 1, 'invalid_access_token').expect(401); + const targetId = getUserIdFromAccessToken( + await getAccessToken(constants.user.member), + ); + await getUserOnRoom(room.id, targetId, 'invalid_access_token').expect( + 401, + ); }); }); + const testRoomSetup = async (): Promise => { + const ownerToken = await getAccessToken(constants.user.owner); + const res = await createRoom(ownerToken, constants.room.test); + const roomId = res.body.id; + for (const member of usersExceptOwner) { + if (member === constants.user.owner) continue; + const accessToken = await getAccessToken(member); + await enterRoom(accessToken, res.body); + + if (member === constants.user.admin) { + await updateUserOnRoom( + roomId, + getUserIdFromAccessToken(accessToken), + { role: Role.ADMINISTRATOR }, + ownerToken, + ); + } + } + return roomId; + }; + describe('DELETE /room/:id/:userId (Delete user in Room)', () => { - /* TODO */ + let testRoomId: number; + + beforeEach(async () => { + testRoomId = await testRoomSetup(); + }); + afterEach(async () => { + const accessToken = await getAccessToken(constants.user.owner); + await deleteRoom(testRoomId, accessToken); + }); + const notOwnerFilter = (user: LoginDto) => + user.email !== constants.user.owner.email; + + it('from member: clientRole >= targetRole: should return 204 No Content (owner)', async () => { + const accessToken = await getAccessToken(constants.user.owner); + for (const member of usersExceptOwner.filter(notOwnerFilter)) { + const targetId = getUserIdFromAccessToken(await getAccessToken(member)); + await kickFromRoom(testRoomId, targetId, accessToken).expect(204); + } + }); + it('from member: clientRole >= targetRole: should return 204 No Content (admin)', async () => { + const accessToken = await getAccessToken(constants.user.admin); + const memberId = getUserIdFromAccessToken( + await getAccessToken(constants.user.member), + ); + const adminId = getUserIdFromAccessToken(accessToken); + await kickFromRoom(testRoomId, memberId, accessToken).expect(204); + await kickFromRoom(testRoomId, adminId, accessToken).expect(204); + }); + it('from member: should return 403 Forbidden (member)', async () => { + const accessToken = await getAccessToken(constants.user.member); + const memberId = getUserIdFromAccessToken(accessToken); + await kickFromRoom(testRoomId, memberId, accessToken).expect(204); + }); + it('from member: clientRole < targetRole: should return 403 Forbidden', async () => { + const MemberAccessToken = await getAccessToken(constants.user.member); + const AdminAccessToken = await getAccessToken(constants.user.admin); + const adminId = getUserIdFromAccessToken( + await getAccessToken(constants.user.admin), + ); + const ownerId = getUserIdFromAccessToken( + await getAccessToken(constants.user.owner), + ); + await kickFromRoom(testRoomId, ownerId, AdminAccessToken).expect(403); + await kickFromRoom(testRoomId, ownerId, MemberAccessToken).expect(403); + await kickFromRoom(testRoomId, adminId, MemberAccessToken).expect(403); + }); }); describe('PATCH /room/:id/:userId (Modify user role in Room)', () => { - /* TODO */ + let testRoomId: number; + const toMemberDto: UpdateUserOnRoomDto = { role: Role.MEMBER }; + const toAdminDto: UpdateUserOnRoomDto = { role: Role.ADMINISTRATOR }; + const toOwnerDto: UpdateUserOnRoomDto = { role: Role.OWNER }; + + beforeEach(async () => { + testRoomId = await testRoomSetup(); + }); + afterEach(async () => { + const accessToken = await getAccessToken(constants.user.owner); + await deleteRoom(testRoomId, accessToken); + }); + + it('from member: should return 204 No Content (owner)', async () => { + const accessToken = await getAccessToken(constants.user.owner); + const adminId = getUserIdFromAccessToken( + await getAccessToken(constants.user.admin), + ); + const memberId = getUserIdFromAccessToken( + await getAccessToken(constants.user.member), + ); + await updateUserOnRoom( + testRoomId, + memberId, + toAdminDto, + accessToken, + ).expect(200); + await updateUserOnRoom( + testRoomId, + adminId, + toMemberDto, + accessToken, + ).expect(200); + }); + it('from member: clientRole >= targetRole: should return 204 No Content (admin)', async () => { + const accessToken = await getAccessToken(constants.user.admin); + const memberId = getUserIdFromAccessToken( + await getAccessToken(constants.user.member), + ); + const adminId = getUserIdFromAccessToken(accessToken); + await updateUserOnRoom( + testRoomId, + memberId, + toAdminDto, + accessToken, + ).expect(200); + await updateUserOnRoom( + testRoomId, + adminId, + toMemberDto, + accessToken, + ).expect(200); + }); + it('from member: should return 403 Forbidden (member)', async () => { + const accessToken = await getAccessToken(constants.user.member); + const memberId = getUserIdFromAccessToken(accessToken); + await updateUserOnRoom( + testRoomId, + memberId, + toMemberDto, + accessToken, + ).expect(403); + }); + it('from member: clientRole < targetRole: should return 403 Forbidden', async () => { + const MemberAccessToken = await getAccessToken(constants.user.member); + const AdminAccessToken = await getAccessToken(constants.user.admin); + const adminId = getUserIdFromAccessToken( + await getAccessToken(constants.user.admin), + ); + const ownerId = getUserIdFromAccessToken( + await getAccessToken(constants.user.owner), + ); + const memberId = getUserIdFromAccessToken( + await getAccessToken(constants.user.member), + ); + // from admin to owner + await updateUserOnRoom( + testRoomId, + ownerId, + toAdminDto, + AdminAccessToken, + ).expect(403); + await updateUserOnRoom( + testRoomId, + ownerId, + toMemberDto, + AdminAccessToken, + ).expect(403); + // from admin to member + await updateUserOnRoom( + testRoomId, + memberId, + toOwnerDto, + AdminAccessToken, + ).expect(403); + // from member to admin + await updateUserOnRoom( + testRoomId, + adminId, + toMemberDto, + MemberAccessToken, + ).expect(403); + // from member to owner + await updateUserOnRoom( + testRoomId, + ownerId, + toMemberDto, + MemberAccessToken, + ).expect(403); + }); }); });