diff --git a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts index 72eabc92..ccf50158 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts @@ -1,15 +1,19 @@ +import { UseGuards } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { + ConnectedSocket, + MessageBody, OnGatewayConnection, OnGatewayInit, SubscribeMessage, WebSocketGateway, - WebSocketServer, WsException, } from '@nestjs/websockets'; import { ConfigService } from '@nestjs/config'; import { Server, Socket } from 'socket.io'; +import { WsJwtAuthGuard } from './guards/ws-jwt-auth.guard'; import { BoardTreesService } from './board-trees.service'; +import { WsMatchUserProfileGuard } from './guards/ws-match-user-profile.guard'; import type { BoardOperation } from './schemas/board-operation.schema'; @WebSocketGateway({ namespace: 'board' }) @@ -20,9 +24,6 @@ export class BoardTreesGateway implements OnGatewayInit, OnGatewayConnection { private configService: ConfigService, ) {} - @WebSocketServer() - server: Server; - afterInit(server: Server) { server.use((socket, next) => { const token = socket.handshake.auth.token; @@ -45,23 +46,30 @@ export class BoardTreesGateway implements OnGatewayInit, OnGatewayConnection { const boardId = query.boardId; if (!boardId) { - client.emit('board_id_required', new WsException('board id required')); + client.emit('boardIdRequired', new WsException('board id required')); client.disconnect(); } client.join(boardId); - client.emit('board_joined', boardId); + client.emit('boardJoined', boardId); } + @UseGuards(WsJwtAuthGuard, WsMatchUserProfileGuard) @SubscribeMessage('createOperation') - async handleCreateOperation(client: Socket, operation: BoardOperation) { + async handleCreateOperation( + @ConnectedSocket() client: Socket, + @MessageBody('operation') operation: BoardOperation, + ) { await this.boardTreesService.createOperationLog(operation); client.broadcast.to(operation.boardId).emit('operation', operation); - client.emit('operationCreated'); + return { status: true }; } + @UseGuards(WsJwtAuthGuard, WsMatchUserProfileGuard) @SubscribeMessage('getOperations') - async handleGetOperations(client: Socket, boardId: string) { + async handleGetOperations( + @MessageBody('boardId') boardId: string, + ): Promise { const operations = await this.boardTreesService.getOperationLogs(boardId); - client.emit('getOperations', operations); + return operations; } } diff --git a/nestjs-BE/server/src/board-trees/board-trees.module.ts b/nestjs-BE/server/src/board-trees/board-trees.module.ts index d7e47a91..8f32d1ab 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.module.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.module.ts @@ -8,6 +8,8 @@ import { BoardOperation, BoardOperationSchema, } from './schemas/board-operation.schema'; +import { WsMatchUserProfileGuard } from './guards/ws-match-user-profile.guard'; +import { ProfilesModule } from '../profiles/profiles.module'; @Module({ imports: [ @@ -16,7 +18,8 @@ import { { name: BoardOperation.name, schema: BoardOperationSchema }, ]), JwtModule, + ProfilesModule, ], - providers: [BoardTreesService, BoardTreesGateway], + providers: [BoardTreesService, BoardTreesGateway, WsMatchUserProfileGuard], }) export class BoardTreesModule {} diff --git a/nestjs-BE/server/src/board-trees/board-trees.service.ts b/nestjs-BE/server/src/board-trees/board-trees.service.ts index 1fe2c693..d5a0d8d8 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.service.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.service.ts @@ -1,66 +1,15 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { BoardTree } from './schemas/board-tree.schema'; import { BoardOperation } from './schemas/board-operation.schema'; -import { CrdtTree } from '../crdt/crdt-tree'; -import { Operation } from '../crdt/operation'; @Injectable() export class BoardTreesService { constructor( - @InjectModel(BoardTree.name) private boardTreeModel: Model, @InjectModel(BoardOperation.name) private boardOperationModel: Model, ) {} - private boardTrees = new Map(); - - async create(boardId: string, tree: string) { - const createdTree = new this.boardTreeModel({ - boardId, - tree, - }); - return createdTree.save(); - } - - async findByBoardId(boardId: string) { - return this.boardTreeModel.findOne({ boardId }).exec(); - } - - getTreeData(boardId: string) { - const boardTree = this.boardTrees.get(boardId); - return JSON.stringify(boardTree); - } - - async initBoardTree(boardId: string, boardName: string) { - const existingTree = await this.findByBoardId(boardId); - if (existingTree) { - this.boardTrees.set(boardId, CrdtTree.parse(existingTree.tree)); - } else { - const newTree = new CrdtTree(boardId); - newTree.tree.get('root').description = boardName; - this.create(boardId, JSON.stringify(newTree)); - this.boardTrees.set(boardId, newTree); - } - } - - applyOperation(boardId: string, operation: Operation) { - const boardTree = this.boardTrees.get(boardId); - boardTree.applyOperation(operation); - } - - hasTree(boardId: string) { - return this.boardTrees.has(boardId); - } - - updateTreeData(boardId: string) { - const tree = this.boardTrees.get(boardId); - this.boardTreeModel - .updateOne({ boardId }, { tree: JSON.stringify(tree) }) - .exec(); - } - async createOperationLog(operation: BoardOperation) { return this.boardOperationModel.create(operation); } diff --git a/nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.spec.ts b/nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.spec.ts new file mode 100644 index 00000000..c4cb7064 --- /dev/null +++ b/nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.spec.ts @@ -0,0 +1,75 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import { WsException } from '@nestjs/websockets'; +import { sign } from 'jsonwebtoken'; +import { WsJwtAuthGuard } from './ws-jwt-auth.guard'; + +import type { ExecutionContext } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; + +const JWT_ACCESS_SECRET = 'access token secret'; + +describe('JwtAuthGuard', () => { + let guard: WsJwtAuthGuard; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule], + providers: [ + { provide: ConfigService, useValue: { get: () => JWT_ACCESS_SECRET } }, + ], + }).compile(); + + const jwtService = module.get(JwtService); + const configService = module.get(ConfigService); + + guard = new WsJwtAuthGuard(jwtService, configService); + }); + + it('throw WsException when token not included', () => { + const context = createExecutionContext({}); + + expect(() => guard.canActivate(context)).toThrow(WsException); + }); + + it('throw WsException if token is invalid', () => { + const testToken = sign({ sub: 'test uuid' }, JWT_ACCESS_SECRET, { + expiresIn: '-5m', + }); + const context = createExecutionContext({ token: testToken }); + + expect(() => guard.canActivate(context)).toThrow(WsException); + }); + + it('return true if token is valid', () => { + const testToken = sign({ sub: 'test uuid' }, JWT_ACCESS_SECRET, { + expiresIn: '5m', + }); + const context = createExecutionContext({ token: testToken }); + + expect(guard.canActivate(context)).toBeTruthy(); + }); + + it('add data when successful', () => { + const testUuid = 'test uuid'; + const testToken = sign({ sub: testUuid }, JWT_ACCESS_SECRET, { + expiresIn: '5m', + }); + const context = createExecutionContext({ token: testToken }); + + guard.canActivate(context); + + expect(Reflect.getMetadata('user', context)).toEqual({ uuid: testUuid }); + }); +}); + +function createExecutionContext(payload: object): ExecutionContext { + const innerPayload = { ...payload }; + const context: ExecutionContext = { + switchToWs: () => ({ + getData: () => innerPayload, + }), + } as ExecutionContext; + return context; +} diff --git a/nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.ts b/nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.ts new file mode 100644 index 00000000..bf2e099f --- /dev/null +++ b/nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.ts @@ -0,0 +1,28 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { WsException } from '@nestjs/websockets'; + +@Injectable() +export class WsJwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private configService: ConfigService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToWs().getData(); + if (!request.token) { + throw new WsException('access token required'); + } + try { + const payload = this.jwtService.verify(request.token, { + secret: this.configService.get('JWT_ACCESS_SECRET'), + }); + Reflect.defineMetadata('user', { uuid: payload.sub }, context); + } catch (error) { + throw new WsException('access token invalid'); + } + return true; + } +} diff --git a/nestjs-BE/server/src/board-trees/guards/ws-match-user-profile.guard.spec.ts b/nestjs-BE/server/src/board-trees/guards/ws-match-user-profile.guard.spec.ts new file mode 100644 index 00000000..6067a01d --- /dev/null +++ b/nestjs-BE/server/src/board-trees/guards/ws-match-user-profile.guard.spec.ts @@ -0,0 +1,101 @@ +import { Test } from '@nestjs/testing'; +import { WsException } from '@nestjs/websockets'; +import { WsMatchUserProfileGuard } from './ws-match-user-profile.guard'; +import { ProfilesService } from '../../profiles/profiles.service'; + +import type { ExecutionContext } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; + +describe('WsMatchUserProfileGuard', () => { + let guard: WsMatchUserProfileGuard; + let profilesService: ProfilesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: ProfilesService, + useValue: { + findProfileByProfileUuid: jest.fn(() => ({ + userUuid: 'user uuid', + })), + }, + }, + ], + }).compile(); + + profilesService = module.get(ProfilesService); + + guard = new WsMatchUserProfileGuard(profilesService); + }); + + it('throw WsException when profile uuid not included', async () => { + const context = createExecutionContext({}); + Reflect.defineMetadata('user', { uuid: 'user uuid' }, context); + + const res = guard.canActivate(context); + + await expect(res).rejects.toThrow(WsException); + await expect(res).rejects.toMatchObject({ + message: 'profile uuid or user uuid required', + }); + }); + + it('throw WsException when user uuid not included', async () => { + const context = createExecutionContext({ profileUuid: 'profile uuid' }); + + const res = guard.canActivate(context); + + await expect(res).rejects.toThrow(WsException); + await expect(res).rejects.toMatchObject({ + message: 'profile uuid or user uuid required', + }); + }); + + it('bad request if profile not exist', async () => { + const context = createExecutionContext({ profileUuid: 'profile uuid' }); + Reflect.defineMetadata('user', { uuid: 'user uuid' }, context); + (profilesService.findProfileByProfileUuid as jest.Mock).mockReturnValueOnce( + null, + ); + + const res = guard.canActivate(context); + + await expect(res).rejects.toThrow(WsException); + await expect(res).rejects.toMatchObject({ + message: 'forbidden request', + }); + }); + + it('bad request if profile uuid not match', async () => { + const context = createExecutionContext({ profileUuid: 'profile uuid' }); + Reflect.defineMetadata('user', { uuid: 'user uuid' }, context); + (profilesService.findProfileByProfileUuid as jest.Mock).mockReturnValueOnce( + { userUuid: 'other user uuid' }, + ); + + const res = guard.canActivate(context); + + await expect(res).rejects.toThrow(WsException); + await expect(res).rejects.toMatchObject({ + message: 'forbidden request', + }); + }); + + it('success', async () => { + const context = createExecutionContext({ profileUuid: 'profile uuid' }); + Reflect.defineMetadata('user', { uuid: 'user uuid' }, context); + + await expect(guard.canActivate(context)).resolves.toBeTruthy(); + }); +}); + +function createExecutionContext(payload: object): ExecutionContext { + const innerPayload = { ...payload }; + const context: ExecutionContext = { + switchToWs: () => ({ + getData: () => innerPayload, + }), + } as ExecutionContext; + return context; +} diff --git a/nestjs-BE/server/src/board-trees/guards/ws-match-user-profile.guard.ts b/nestjs-BE/server/src/board-trees/guards/ws-match-user-profile.guard.ts new file mode 100644 index 00000000..d2fe7076 --- /dev/null +++ b/nestjs-BE/server/src/board-trees/guards/ws-match-user-profile.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { ProfilesService } from '../../profiles/profiles.service'; + +@Injectable() +export class WsMatchUserProfileGuard implements CanActivate { + constructor(private profilesService: ProfilesService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToWs().getData(); + const userUuid = Reflect.getMetadata('user', context)?.uuid; + const profileUuid = request.profileUuid; + if (!profileUuid || !userUuid) + throw new WsException('profile uuid or user uuid required'); + const profile = + await this.profilesService.findProfileByProfileUuid(profileUuid); + if (!profile || userUuid !== profile.userUuid) { + throw new WsException('forbidden request'); + } + return true; + } +} diff --git a/nestjs-BE/server/test/board-trees.e2e-spec.ts b/nestjs-BE/server/test/board-trees.e2e-spec.ts index 8e7b89b9..447ccd34 100644 --- a/nestjs-BE/server/test/board-trees.e2e-spec.ts +++ b/nestjs-BE/server/test/board-trees.e2e-spec.ts @@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { MongooseModule } from '@nestjs/mongoose'; +import { Profile } from '@prisma/client'; import { sign } from 'jsonwebtoken'; import { io, Socket } from 'socket.io-client'; import { v4 as uuid } from 'uuid'; @@ -15,6 +16,15 @@ import type { BoardOperation } from '../src/board-trees/schemas/board-operation. const PORT = 3000; +type WsException = { + status: string; + message: string; + cause: { + pattern: string; + data: object; + }; +}; + describe('BoardTreesGateway (e2e)', () => { const serverUrl = `ws://localhost:${PORT}/board`; let app: INestApplication; @@ -81,7 +91,8 @@ describe('BoardTreesGateway (e2e)', () => { }); it('success', async () => { - const testToken = await createUserToken(prisma, config); + const testUser = await createUser(prisma); + const testToken = await createUserToken(testUser.uuid, config); const connected = await new Promise((resolve) => { const socket = io(serverUrl, { auth: { token: testToken } }); @@ -100,15 +111,16 @@ describe('BoardTreesGateway (e2e)', () => { let testToken: string; beforeEach(async () => { - testToken = await createUserToken(prisma, config); + const testUser = await createUser(prisma); + testToken = await createUserToken(testUser.uuid, config); }); - it('board_id_required error when board id not included', async () => { + it('boardIdRequired error when board id not included', async () => { const error = new Promise((resolve, reject) => { const socket = io(serverUrl, { auth: { token: testToken }, }); - socket.on('board_id_required', (error) => { + socket.on('boardIdRequired', (error) => { reject(error); }); }); @@ -127,7 +139,7 @@ describe('BoardTreesGateway (e2e)', () => { auth: { token: testToken }, query: { boardId }, }); - socket.on('board_joined', (boardId) => { + socket.on('boardJoined', (boardId) => { socket.disconnect(); resolve(boardId); }); @@ -137,13 +149,120 @@ describe('BoardTreesGateway (e2e)', () => { }); }); + describe('Checking if WsJwtAuthGuard is applied', () => { + const boardId = uuid(); + let client: Socket; + + beforeEach(async () => { + const testUser = await createUser(prisma); + const testToken = await createUserToken(testUser.uuid, config); + client = await createClientSocket(serverUrl, { + auth: { token: testToken }, + query: { boardId }, + }); + }); + + afterEach(() => { + if (client.connected) { + client.disconnect(); + } + }); + + it('createOperation', async () => { + const testOperation = { + boardId, + type: 'add', + parentId: 'root', + content: 'new node', + }; + + const response: WsException = await new Promise((resolve) => { + client.on('exception', (exception) => { + resolve(exception); + }); + client.emit('createOperation', { operation: testOperation }); + }); + + expect(response.status).toBe('error'); + expect(response.message).toBe('access token required'); + expect(response.cause.pattern).toBe('createOperation'); + }); + + it('getOperations', async () => { + const response: WsException = await new Promise((resolve) => { + client.on('exception', (exception) => { + resolve(exception); + }); + client.emit('getOperations', { boardId }); + }); + + expect(response.status).toBe('error'); + expect(response.message).toBe('access token required'); + expect(response.cause.pattern).toBe('getOperations'); + }); + }); + + describe('Checking if WsMatchUserProfileGuard is applied', () => { + const boardId = uuid(); + let testToken: string; + let client: Socket; + + beforeEach(async () => { + const testUser = await createUser(prisma); + testToken = await createUserToken(testUser.uuid, config); + client = await createClientSocket(serverUrl, { + auth: { token: testToken }, + query: { boardId }, + }); + }); + + afterEach(() => { + if (client.connected) { + client.disconnect(); + } + }); + + it('createOperation', async () => { + const response: WsException = await new Promise((resolve, reject) => { + client.on('exception', (exception) => { + resolve(exception); + }); + client.emit('createOperation', { token: testToken }, (response) => { + reject(response); + }); + }); + + expect(response.status).toBe('error'); + expect(response.message).toBe('profile uuid or user uuid required'); + expect(response.cause.pattern).toBe('createOperation'); + }); + + it('getOperations', async () => { + const response: WsException = await new Promise((resolve, reject) => { + client.on('exception', (exception) => { + resolve(exception); + }); + client.emit('getOperations', { token: testToken }, (response) => { + reject(response); + }); + }); + + expect(response.status).toBe('error'); + expect(response.message).toBe('profile uuid or user uuid required'); + expect(response.cause.pattern).toBe('getOperations'); + }); + }); + describe('createOperation', () => { const boardId = 'board id'; + let testProfile: Profile; let testToken: string; let client: Socket; beforeEach(async () => { - testToken = await createUserToken(prisma, config); + const user = await createUser(prisma); + testProfile = await createProfile(user.uuid, prisma); + testToken = await createUserToken(user.uuid, config); client = await createClientSocket(serverUrl, { auth: { token: testToken }, query: { boardId }, @@ -164,12 +283,21 @@ describe('BoardTreesGateway (e2e)', () => { content: 'new node', }; - await new Promise((resolve) => { - client.on('operationCreated', () => { - resolve(null); + await new Promise((resolve, reject) => { + client.on('exception', (exception) => { + reject(exception); }); - - client.emit('createOperation', testOperation); + client.emit( + 'createOperation', + { + operation: testOperation, + token: testToken, + profileUuid: testProfile.uuid, + }, + (response) => { + resolve(response); + }, + ); }); const operations = await boardTreesService.getOperationLogs( @@ -179,7 +307,8 @@ describe('BoardTreesGateway (e2e)', () => { }); it('other client received operation', async () => { - const otherToken = await createUserToken(prisma, config); + const otherUser = await createUser(prisma); + const otherToken = await createUserToken(otherUser.uuid, config); const otherClient = await createClientSocket(serverUrl, { auth: { token: otherToken }, query: { boardId }, @@ -192,27 +321,38 @@ describe('BoardTreesGateway (e2e)', () => { content: 'new node', }; - const response = await new Promise((resolve) => { + const response = new Promise((resolve, reject) => { otherClient.on('operation', (operation) => { otherClient.disconnect(); resolve(operation); }); + client.on('exception', (exception) => { + otherClient.disconnect(); + reject(exception); + }); - client.emit('createOperation', testOperation); + client.emit('createOperation', { + operation: testOperation, + token: testToken, + profileUuid: testProfile.uuid, + }); }); - expect(response).toEqual(testOperation); + await expect(response).resolves.toEqual(testOperation); }); }); describe('getOperations', () => { const boardId = uuid(); let testToken: string; + let testProfile: Profile; let testOperations: BoardOperation[]; let client: Socket; beforeEach(async () => { - testToken = await createUserToken(prisma, config); + const testUser = await createUser(prisma); + testProfile = await createProfile(testUser.uuid, prisma); + testToken = await createUserToken(testUser.uuid, config); client = await createClientSocket(serverUrl, { auth: { token: testToken }, query: { boardId }, @@ -240,23 +380,44 @@ describe('BoardTreesGateway (e2e)', () => { }); it('get operation logs', async () => { - const response = await new Promise((resolve) => { - client.on('getOperations', (operationLogs) => { - resolve(operationLogs); + const response = new Promise((resolve, reject) => { + client.on('exception', (exception) => { + reject(exception); }); - - client.emit('getOperations', boardId); + client.emit( + 'getOperations', + { boardId, token: testToken, profileUuid: testProfile.uuid }, + (response: BoardOperation[]) => { + resolve(response); + }, + ); }); - expect(response).toEqual(expect.arrayContaining(testOperations)); + await expect(response).resolves.toEqual( + expect.arrayContaining(testOperations), + ); }); }); }); -async function createUserToken(prisma: PrismaService, config: ConfigService) { - const user = await prisma.user.create({ data: { uuid: uuid() } }); +async function createUser(prisma: PrismaService) { + return prisma.user.create({ data: { uuid: uuid() } }); +} + +async function createProfile(userUuid: string, prisma: PrismaService) { + return prisma.profile.create({ + data: { + uuid: uuid(), + userUuid, + image: 'test image', + nickname: 'test nickname', + }, + }); +} + +async function createUserToken(userUuid: string, config: ConfigService) { const token = sign( - { sub: user.uuid }, + { sub: userUuid }, config.get('JWT_ACCESS_SECRET'), { expiresIn: '5m' }, ); @@ -270,7 +431,7 @@ async function createClientSocket( let client: Socket; await new Promise((resolve) => { client = io(uri, opts); - client.on('board_joined', () => { + client.on('boardJoined', () => { resolve(null); }); });