Skip to content

Commit e0c2dd5

Browse files
authored
Merge branch 'develop' into refactor-be-#357
2 parents aadfeae + 3322052 commit e0c2dd5

13 files changed

+290
-72
lines changed

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"socket.io": "^4.8.1",
5656
"sqlite3": "^5.1.7",
5757
"typeorm": "^0.3.20",
58+
"uuid": "^11.0.3",
5859
"ws": "^8.14.2",
5960
"y-prosemirror": "^1.2.12",
6061
"y-protocols": "^1.0.6",

apps/backend/src/auth/auth.controller.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ export class AuthController {
4141
// 네이버 인증 후 사용자 정보 반환
4242
const user = req.user;
4343

44-
// primary Key인 id 포함 payload 생성함
45-
// TODO: 여기서 권한 추가해야함
46-
const payload = { sub: user.id };
47-
const accessToken = this.tokenService.generateAccessToken(payload);
48-
const refreshToken = this.tokenService.generateRefreshToken(payload);
44+
// access token 만들기
45+
const accessToken = this.tokenService.generateAccessToken(user.id);
46+
47+
// refresh token 만들어서 db에도 저장
48+
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
4949

5050
// 토큰을 쿠키에 담아서 메인 페이지로 리디렉션
5151
this.tokenService.setAccessTokenCookie(res, accessToken);
@@ -67,11 +67,11 @@ export class AuthController {
6767
/// 카카오 인증 후 사용자 정보 반환
6868
const user = req.user;
6969

70-
// primary Key인 id 포함 payload 생성함
71-
// TODO: 여기서 권한 추가해야함
72-
const payload = { sub: user.id };
73-
const accessToken = this.tokenService.generateAccessToken(payload);
74-
const refreshToken = this.tokenService.generateRefreshToken(payload);
70+
// access token 만들기
71+
const accessToken = this.tokenService.generateAccessToken(user.id);
72+
73+
// refresh token 만들어서 db에도 저장
74+
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
7575

7676
// 토큰을 쿠키에 담아서 메인 페이지로 리디렉션
7777
this.tokenService.setAccessTokenCookie(res, accessToken);
@@ -84,17 +84,18 @@ export class AuthController {
8484
@ApiOperation({ summary: '사용자가 로그아웃합니다.' })
8585
@Post('logout')
8686
@UseGuards(JwtAuthGuard) // JWT 인증 검사
87-
logout(@Res() res: Response) {
87+
logout(@Req() req, @Res() res: Response) {
8888
// 쿠키 삭제 (옵션이 일치해야 삭제됨)
8989
this.tokenService.clearCookies(res);
90+
// 현재 자동로그인에 사용되는 refresh Token db에서 삭제
91+
this.tokenService.deleteRefreshToken(req.user.sub);
9092
return res.status(200).json({
9193
message: AuthResponseMessage.AUTH_LOGGED_OUT,
9294
});
9395
}
9496

9597
// 클라이언트가 사용자의 외부 id(snowflakeId) + 이름을 알 수 있는 엔드포인트
9698
// auth/profile
97-
// TODO: 사용자 지정 닉네임 + 프로필 이미지도 return하게 확장
9899
@Get('profile')
99100
@UseGuards(JwtAuthGuard) // JWT 인증 검사
100101
async getProfile(@Req() req) {

apps/backend/src/auth/auth.service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,20 @@ export class AuthService {
4545
const newPage = Object.assign({}, findUser, dto);
4646
await this.userRepository.save(newPage);
4747
}
48+
49+
async compareStoredRefreshToken(
50+
id: number,
51+
refreshToken: string,
52+
): Promise<boolean> {
53+
// 유저를 찾는다.
54+
const user = await this.userRepository.findOneBy({ id });
55+
56+
// 유저가 없으면 오류
57+
if (!user) {
58+
throw new UserNotFoundException();
59+
}
60+
61+
// DB에 있는 값과 일치하는지 비교한다
62+
return user.refreshToken === refreshToken;
63+
}
4864
}

apps/backend/src/auth/token/token.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
22
import { JwtModule } from '@nestjs/jwt';
33
import { TokenService } from './token.service';
44
import { ConfigModule, ConfigService } from '@nestjs/config';
5+
import { UserModule } from '../../user/user.module';
56

67
@Module({
78
imports: [
9+
UserModule,
810
ConfigModule, // ConfigModule 등록
911
JwtModule.registerAsync({
1012
imports: [ConfigModule], // ConfigModule에서 환경 변수 로드

apps/backend/src/auth/token/token.service.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Injectable } from '@nestjs/common';
22
import { JwtService } from '@nestjs/jwt';
33
import { Response } from 'express';
4+
import { v4 as uuidv4 } from 'uuid';
5+
import { UserRepository } from '../../user/user.repository';
6+
import { UserNotFoundException } from '../../exception/user.exception';
7+
import { InvalidTokenException } from '../../exception/invalid.exception';
48

59
const HOUR = 60 * 60;
610
const DAY = 24 * 60 * 60;
@@ -9,18 +13,28 @@ const MS_HALF_YEAR = 6 * 30 * 24 * 60 * 60 * 1000;
913

1014
@Injectable()
1115
export class TokenService {
12-
constructor(private readonly jwtService: JwtService) {}
16+
constructor(
17+
private readonly jwtService: JwtService,
18+
private readonly userRepository: UserRepository,
19+
) {}
1320

14-
generateAccessToken(payload: any): string {
21+
generateAccessToken(userId: number): string {
22+
const payload = { sub: userId };
1523
return this.jwtService.sign(payload, {
1624
expiresIn: HOUR,
1725
});
1826
}
1927

20-
generateRefreshToken(payload: any): string {
21-
return this.jwtService.sign(payload, {
28+
async generateRefreshToken(userId: number): Promise<string> {
29+
// 보안성을 높이기 위해 랜덤한 tokenId인 jti를 생성한다
30+
const payload = { sub: userId, jti: uuidv4() };
31+
const refreshToken = this.jwtService.sign(payload, {
2232
expiresIn: FIVE_MONTHS,
2333
});
34+
35+
await this.updateRefreshToken(userId, refreshToken);
36+
37+
return refreshToken;
2438
}
2539

2640
generateInviteToken(workspaceId: number, role: string): string {
@@ -40,13 +54,23 @@ export class TokenService {
4054
}
4155

4256
async refreshAccessToken(refreshToken: string): Promise<string> {
43-
// refreshToken을 검증한다
57+
// refreshToken 1차 검증
4458
const decoded = this.jwtService.verify(refreshToken, {
4559
secret: process.env.JWT_SECRET,
4660
});
4761

62+
// 검증된 토큰에서 사용자 ID 추출
63+
const userId = decoded.sub;
64+
65+
// refreshToken 2차 검증
66+
// DB에 저장된 refreshToken과 비교
67+
const isValid = await this.compareStoredRefreshToken(userId, refreshToken);
68+
if (!isValid) {
69+
throw new InvalidTokenException();
70+
}
71+
4872
// 새로운 accessToken을 발급한다
49-
return this.generateAccessToken({ sub: decoded.sub });
73+
return this.generateAccessToken(decoded.sub);
5074
}
5175

5276
setAccessTokenCookie(response: Response, accessToken: string): void {
@@ -82,4 +106,48 @@ export class TokenService {
82106
sameSite: 'strict',
83107
});
84108
}
109+
110+
private async compareStoredRefreshToken(
111+
id: number,
112+
refreshToken: string,
113+
): Promise<boolean> {
114+
// 유저를 찾는다.
115+
const user = await this.userRepository.findOneBy({ id });
116+
117+
// 유저가 없으면 오류
118+
if (!user) {
119+
throw new UserNotFoundException();
120+
}
121+
122+
// DB에 있는 값과 일치하는지 비교한다
123+
return user.refreshToken === refreshToken;
124+
}
125+
126+
private async updateRefreshToken(id: number, refreshToken: string) {
127+
// 유저를 찾는다.
128+
const user = await this.userRepository.findOneBy({ id });
129+
130+
// 유저가 없으면 오류
131+
if (!user) {
132+
throw new UserNotFoundException();
133+
}
134+
135+
// 유저의 현재 REFRESH TOKEN 갱신
136+
user.refreshToken = refreshToken;
137+
await this.userRepository.save(user);
138+
}
139+
140+
async deleteRefreshToken(id: number) {
141+
// 유저를 찾는다.
142+
const user = await this.userRepository.findOneBy({ id });
143+
144+
// 유저가 없으면 오류
145+
if (!user) {
146+
throw new UserNotFoundException();
147+
}
148+
149+
// 유저의 현재 REFRESH TOKEN 삭제
150+
user.refreshToken = null;
151+
await this.userRepository.save(user);
152+
}
85153
}

apps/backend/src/user/user.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export class User {
3636
@Column({ nullable: true })
3737
profileImage: string;
3838

39+
@Column({ nullable: true })
40+
refreshToken: string;
41+
3942
@CreateDateColumn()
4043
createdAt: Date;
4144
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsString } from 'class-validator';
3+
import { UserWorkspaceDto } from './userWorkspace.dto';
4+
5+
export class GetWorkspaceResponseDto {
6+
@ApiProperty({
7+
example: 'OO 생성에 성공했습니다.',
8+
description: 'api 요청 결과 메시지',
9+
})
10+
@IsString()
11+
message: string;
12+
13+
@ApiProperty({
14+
example: [
15+
{
16+
workspaceId: 'snowflake-id-1',
17+
title: 'naver-boostcamp-9th',
18+
description: '네이버 부스트캠프 9기 워크스페이스입니다',
19+
thumbnailUrl: 'https://example.com/image1.png',
20+
role: 'guest',
21+
visibility: 'private',
22+
},
23+
],
24+
description: '사용자가 접근하려고 하는 워크스페이스 데이터',
25+
})
26+
workspace: UserWorkspaceDto;
27+
}

apps/backend/src/workspace/dtos/userWorkspace.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ export class UserWorkspaceDto {
33
title: string;
44
description: string | null;
55
thumbnailUrl: string | null;
6-
role: 'owner' | 'guest';
6+
role: 'owner' | 'guest' | null;
77
visibility: 'public' | 'private';
88
}

apps/backend/src/workspace/workspace.controller.spec.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('WorkspaceController', () => {
2626
getUserWorkspaces: jest.fn(),
2727
generateInviteUrl: jest.fn(),
2828
processInviteUrl: jest.fn(),
29-
checkAccess: jest.fn(),
29+
getWorkspaceData: jest.fn(),
3030
updateVisibility: jest.fn(),
3131
},
3232
},
@@ -117,13 +117,15 @@ describe('WorkspaceController', () => {
117117
description: 'Description 1',
118118
thumbnailUrl: 'http://example.com/image1.png',
119119
role: 'owner',
120+
visibility: 'private',
120121
},
121122
{
122123
workspaceId: 'snowflake-id-2',
123124
title: 'Workspace 2',
124125
description: null,
125126
thumbnailUrl: null,
126127
role: 'guest',
128+
visibility: 'private',
127129
},
128130
] as UserWorkspaceDto[];
129131

@@ -188,32 +190,45 @@ describe('WorkspaceController', () => {
188190
});
189191
});
190192

191-
describe('checkWorkspaceAccess', () => {
192-
it('워크스페이스에 접근 가능한 경우 메시지를 반환한다.', async () => {
193+
describe('getWorkspace', () => {
194+
it('워크스페이스에 접근 가능한 경우 워크스페이스 정보를 반환한다.', async () => {
193195
const workspaceId = 'workspace-snowflake-id';
194196
const userId = 'user-snowflake-id';
195197

196-
jest.spyOn(service, 'checkAccess').mockResolvedValue(undefined);
198+
const mockWorkspace = {
199+
workspaceId: 'snowflake-id-1',
200+
title: 'Workspace 1',
201+
description: 'Description 1',
202+
thumbnailUrl: 'http://example.com/image1.png',
203+
role: 'owner',
204+
visibility: 'public',
205+
} as UserWorkspaceDto;
197206

198-
const result = await controller.checkWorkspaceAccess(workspaceId, userId);
207+
jest.spyOn(service, 'getWorkspaceData').mockResolvedValue(mockWorkspace);
199208

200-
expect(service.checkAccess).toHaveBeenCalledWith(userId, workspaceId);
209+
const result = await controller.getWorkspace(workspaceId, userId);
210+
211+
expect(service.getWorkspaceData).toHaveBeenCalledWith(
212+
userId,
213+
workspaceId,
214+
);
201215
expect(result).toEqual({
202-
message: WorkspaceResponseMessage.WORKSPACE_ACCESS_CHECKED,
216+
message: WorkspaceResponseMessage.WORKSPACE_DATA_RETURNED,
217+
workspace: mockWorkspace,
203218
});
204219
});
205220

206221
it('로그인하지 않은 사용자의 경우 null로 처리하고 접근 가능한 경우 메시지를 반환한다.', async () => {
207222
const workspaceId = 'workspace-snowflake-id';
208223
const userId = 'null'; // 로그인되지 않은 상태를 나타냄
209224

210-
jest.spyOn(service, 'checkAccess').mockResolvedValue(undefined);
225+
jest.spyOn(service, 'getWorkspaceData').mockResolvedValue(undefined);
211226

212-
const result = await controller.checkWorkspaceAccess(workspaceId, userId);
227+
const result = await controller.getWorkspace(workspaceId, userId);
213228

214-
expect(service.checkAccess).toHaveBeenCalledWith(null, workspaceId);
229+
expect(service.getWorkspaceData).toHaveBeenCalledWith(null, workspaceId);
215230
expect(result).toEqual({
216-
message: WorkspaceResponseMessage.WORKSPACE_ACCESS_CHECKED,
231+
message: WorkspaceResponseMessage.WORKSPACE_DATA_RETURNED,
217232
});
218233
});
219234

@@ -223,14 +238,17 @@ describe('WorkspaceController', () => {
223238

224239
// 권한 없음
225240
jest
226-
.spyOn(service, 'checkAccess')
241+
.spyOn(service, 'getWorkspaceData')
227242
.mockRejectedValue(new ForbiddenAccessException());
228243

229244
await expect(
230-
controller.checkWorkspaceAccess(workspaceId, userId),
245+
controller.getWorkspace(workspaceId, userId),
231246
).rejects.toThrow(ForbiddenAccessException);
232247

233-
expect(service.checkAccess).toHaveBeenCalledWith(userId, workspaceId);
248+
expect(service.getWorkspaceData).toHaveBeenCalledWith(
249+
userId,
250+
workspaceId,
251+
);
234252
});
235253

236254
it('워크스페이스가 존재하지 않는 경우 WorkspaceNotFoundException을 던진다.', async () => {
@@ -239,14 +257,17 @@ describe('WorkspaceController', () => {
239257

240258
// 워크스페이스 없음
241259
jest
242-
.spyOn(service, 'checkAccess')
260+
.spyOn(service, 'getWorkspaceData')
243261
.mockRejectedValue(new WorkspaceNotFoundException());
244262

245263
await expect(
246-
controller.checkWorkspaceAccess(workspaceId, userId),
264+
controller.getWorkspace(workspaceId, userId),
247265
).rejects.toThrow(WorkspaceNotFoundException);
248266

249-
expect(service.checkAccess).toHaveBeenCalledWith(userId, workspaceId);
267+
expect(service.getWorkspaceData).toHaveBeenCalledWith(
268+
userId,
269+
workspaceId,
270+
);
250271
});
251272
});
252273
});

0 commit comments

Comments
 (0)