Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

웹소켓 가드 #378

Merged
merged 26 commits into from
Feb 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9d022dc
fix: 이벤트로 보내지 않고 리턴
Conut-1 Feb 9, 2025
2720054
refactor: 사용하지 않는 프로퍼티 제거
Conut-1 Feb 9, 2025
0190a9e
refactor: 이벤트 이름 변경
Conut-1 Feb 9, 2025
fa3f34e
test: getOperations에 jwt guard 테스트트
Conut-1 Feb 11, 2025
aaec470
feat: websocket용 JwtAuth 가드
Conut-1 Feb 11, 2025
0fadf22
test: JwtAuthGuard 단위 테스트
Conut-1 Feb 11, 2025
9fd0bf7
feat: getOperations에 가드 적용
Conut-1 Feb 11, 2025
8193b39
test: createOperation에 JwtAuthGuard 테스트
Conut-1 Feb 11, 2025
84ffa45
feat: createOperation에 JWT 가드 적용
Conut-1 Feb 11, 2025
c731fc7
refactor: WsJwtAuthGuard로 이름 변경
Conut-1 Feb 12, 2025
4035ed9
rename: WsJwtAuthGuard 파일 이름 변경
Conut-1 Feb 12, 2025
ce0d048
refactor: 사용하지 않는 서비스 제거
Conut-1 Feb 12, 2025
3a9c1fb
test: WsJwtAuthGuard에서 user 데이터 추가
Conut-1 Feb 13, 2025
df22475
refactor: executionContext 생성 함수
Conut-1 Feb 13, 2025
d3645d4
feat: payload에 user 추가
Conut-1 Feb 13, 2025
626e29d
feat: 웹소켓용 유저 프로필 매치 가드
Conut-1 Feb 13, 2025
1595400
test: WsMatchUserProfileGuard 테스트
Conut-1 Feb 13, 2025
0ff34b8
fix: exception 발생 시 테스트 실패
Conut-1 Feb 13, 2025
9d628bd
refactor: 유저 생성 분리
Conut-1 Feb 15, 2025
c0710c4
test: WsMatchUserProfileGuard 적용 여부 테스트
Conut-1 Feb 15, 2025
da2657e
test: 가드 추가에 따른 테스트 수정
Conut-1 Feb 15, 2025
c692546
feat: WsMatchUserProfileGuard 적용
Conut-1 Feb 16, 2025
a9558be
fix: 유저 데이터 저장 시에 reflect 사용
Conut-1 Feb 16, 2025
10e1d6f
test: Reflect 사용으로 수정
Conut-1 Feb 16, 2025
e3f4d10
test: 테스트 이름 수정
Conut-1 Feb 16, 2025
174d3ea
refactor: 가드 적용에 대해서 묶어서 테스트
Conut-1 Feb 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions nestjs-BE/server/src/board-trees/board-trees.gateway.ts
Original file line number Diff line number Diff line change
@@ -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' })
Expand All @@ -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;
Expand All @@ -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<BoardOperation[]> {
const operations = await this.boardTreesService.getOperationLogs(boardId);
client.emit('getOperations', operations);
return operations;
}
}
5 changes: 4 additions & 1 deletion nestjs-BE/server/src/board-trees/board-trees.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -16,7 +18,8 @@ import {
{ name: BoardOperation.name, schema: BoardOperationSchema },
]),
JwtModule,
ProfilesModule,
],
providers: [BoardTreesService, BoardTreesGateway],
providers: [BoardTreesService, BoardTreesGateway, WsMatchUserProfileGuard],
})
export class BoardTreesModule {}
51 changes: 0 additions & 51 deletions nestjs-BE/server/src/board-trees/board-trees.service.ts
Original file line number Diff line number Diff line change
@@ -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<BoardTree>,
@InjectModel(BoardOperation.name)
private boardOperationModel: Model<BoardOperation>,
) {}

private boardTrees = new Map<string, CrdtTree>();

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);
}
Expand Down
75 changes: 75 additions & 0 deletions nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(JwtService);
const configService = module.get<ConfigService>(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;
}
28 changes: 28 additions & 0 deletions nestjs-BE/server/src/board-trees/guards/ws-jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string>('JWT_ACCESS_SECRET'),
});
Reflect.defineMetadata('user', { uuid: payload.sub }, context);
} catch (error) {
throw new WsException('access token invalid');
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -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>(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;
}
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
Loading