Skip to content

Commit 50ad435

Browse files
Merge pull request #293 from boostcampwm-2024/develop-be-#278
JWT 인증방식 쿠키로 변경
2 parents f1e0e63 + 349fd58 commit 50ad435

20 files changed

+272
-160
lines changed

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@types/multer": "^1.4.12",
3939
"class-transformer": "^0.5.1",
4040
"class-validator": "^0.14.1",
41+
"cookie-parser": "^1.4.7",
4142
"lib0": "^0.2.98",
4243
"passport": "^0.7.0",
4344
"passport-kakao": "^1.0.1",
Lines changed: 22 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { AuthController } from './auth.controller';
33
import { AuthService } from './auth.service';
4-
import { JwtService } from '@nestjs/jwt';
5-
import { InvalidTokenException } from '../exception/invalid.exception';
6-
// import { LoginRequiredException } from '../exception/login.exception';
4+
import { JwtAuthGuard } from './guards/jwt-auth.guard';
5+
import { TokenService } from './token/token.service';
6+
import { LoginRequiredException } from '../exception/login.exception';
77

8-
// TODO: 테스트 코드 개선
98
describe('AuthController', () => {
109
let authController: AuthController;
11-
// let authService: AuthService;
12-
let jwtService: JwtService;
10+
let authService: AuthService;
1311

1412
beforeEach(async () => {
1513
const module: TestingModule = await Test.createTestingModule({
1614
controllers: [AuthController],
1715
providers: [
18-
AuthService,
19-
JwtService,
2016
{
2117
provide: AuthService,
2218
useValue: {
@@ -26,23 +22,23 @@ describe('AuthController', () => {
2622
},
2723
},
2824
{
29-
provide: JwtService,
25+
provide: TokenService,
3026
useValue: {
31-
sign: jest.fn().mockReturnValue('test-token'),
32-
verify: jest.fn((token: string) => {
33-
if (token === 'invalid-token') {
34-
throw new InvalidTokenException();
35-
}
36-
return { sub: 1, provider: 'naver' };
37-
}),
27+
generateAccessToken: jest.fn(() => 'mockedAccessToken'),
28+
generateRefreshToken: jest.fn(() => 'mockedRefreshToken'),
29+
refreshAccessToken: jest.fn(() => 'mockedAccessToken'),
3830
},
3931
},
4032
],
41-
}).compile();
33+
})
34+
.overrideGuard(JwtAuthGuard)
35+
.useValue({
36+
canActivate: jest.fn(() => true),
37+
})
38+
.compile();
4239

4340
authController = module.get<AuthController>(AuthController);
44-
// authService = module.get<AuthService>(AuthService);
45-
jwtService = module.get<JwtService>(JwtService);
41+
authService = module.get<AuthService>(AuthService);
4642
});
4743

4844
it('컨트롤러 클래스가 정상적으로 인스턴스화된다.', () => {
@@ -61,47 +57,13 @@ describe('AuthController', () => {
6157
});
6258
});
6359

64-
// it('JWT 토큰이 유효가지 않은 경우 InvalidTokenException을 throw한다.', async () => {
65-
// const req = {
66-
// headers: { authorization: 'Bearer invalid-token' },
67-
// user: undefined,
68-
// } as any;
69-
// try {
70-
// await authController.getProfile(req);
71-
// } catch (error) {
72-
// expect(error).toBeInstanceOf(InvalidTokenException);
73-
// }
74-
// });
75-
76-
// it('JWT 토큰이 없는 경우 LoginRequiredException을 throw한다.', async () => {
77-
// const req = { headers: {}, user: undefined } as any;
78-
// try {
79-
// await authController.getProfile(req);
80-
// } catch (error) {
81-
// expect(error).toBeInstanceOf(LoginRequiredException);
82-
// }
83-
// });
84-
});
85-
86-
describe('refreshAccessToken', () => {
87-
it('refresh token이 유효한 경우 access token을 성공적으로 발급한다.', async () => {
88-
jest
89-
.spyOn(jwtService, 'verify')
90-
.mockReturnValue({ sub: 1, provider: 'naver' });
91-
const req = { body: { refreshToken: 'valid-refresh-token' } } as any;
92-
const res = {
93-
cookie: jest.fn(),
94-
json: jest.fn(),
95-
} as any;
96-
97-
await authController.refreshAccessToken(req, res);
98-
expect(res.cookie).toHaveBeenCalledWith('accessToken', 'test-token', {
99-
httpOnly: true,
100-
maxAge: 3600000,
101-
});
102-
expect(res.json).toHaveBeenCalledWith({
103-
message: '새로운 Access Token 발급 성공',
104-
});
60+
it('JWT 토큰이 없는 경우 예외를 던진다.', async () => {
61+
const req = {} as any;
62+
try {
63+
authController.getProfile(req);
64+
} catch (error) {
65+
expect(error).toBeInstanceOf(LoginRequiredException);
66+
}
10567
});
10668
});
10769
});

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

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { Controller, Get, UseGuards, Req, Res, Post } from '@nestjs/common';
22
import { AuthGuard } from '@nestjs/passport';
33
import { AuthService } from './auth.service';
4-
import { JwtService } from '@nestjs/jwt';
54
import { JwtAuthGuard } from './guards/jwt-auth.guard';
65
import { Response } from 'express';
6+
import { MessageResponseDto } from './dtos/messageResponse.dto';
7+
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
8+
import { TokenService } from './token/token.service';
79

8-
const HOUR = 60 * 60 * 1000;
9-
const WEEK = 7 * 24 * 60 * 60 * 1000;
10+
export enum AuthResponseMessage {
11+
AUTH_LOGGED_OUT = '로그아웃하였습니다.',
12+
}
1013

1114
@Controller('auth')
1215
export class AuthController {
1316
constructor(
1417
private readonly authService: AuthService,
15-
private readonly jwtService: JwtService,
18+
private readonly tokenService: TokenService,
1619
) {}
1720

1821
@Get('naver')
@@ -27,17 +30,17 @@ export class AuthController {
2730
async naverCallback(@Req() req, @Res() res: Response) {
2831
// 네이버 인증 후 사용자 정보 반환
2932
const user = req.user;
30-
// TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
31-
const payload = { sub: user.id, provider: user.provider };
32-
const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
33-
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
33+
34+
// primary Key인 id 포함 payload 생성함
35+
// TODO: 여기서 권한 추가해야함
36+
const payload = { sub: user.id };
37+
const accessToken = this.tokenService.generateAccessToken(payload);
38+
const refreshToken = this.tokenService.generateRefreshToken(payload);
3439

3540
// 토큰을 쿠키에 담아서 메인 페이지로 리디렉션
36-
res.cookie('accessToken', accessToken, { httpOnly: true, maxAge: HOUR });
37-
res.cookie('refreshToken', refreshToken, {
38-
httpOnly: true,
39-
maxAge: WEEK,
40-
});
41+
this.tokenService.setAccessTokenCookie(res, accessToken);
42+
this.tokenService.setRefreshTokenCookie(res, refreshToken);
43+
4144
res.redirect(302, '/');
4245
}
4346

@@ -51,37 +54,31 @@ export class AuthController {
5154
@Get('kakao/callback')
5255
@UseGuards(AuthGuard('kakao'))
5356
async kakaoCallback(@Req() req, @Res() res: Response) {
54-
// 카카오 인증 후 사용자 정보 반환
57+
/// 카카오 인증 후 사용자 정보 반환
5558
const user = req.user;
56-
// TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
57-
const payload = { sub: user.id, provider: user.provider };
58-
const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
59-
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
59+
60+
// primary Key인 id 포함 payload 생성함
61+
// TODO: 여기서 권한 추가해야함
62+
const payload = { sub: user.id };
63+
const accessToken = this.tokenService.generateAccessToken(payload);
64+
const refreshToken = this.tokenService.generateRefreshToken(payload);
6065

6166
// 토큰을 쿠키에 담아서 메인 페이지로 리디렉션
62-
res.cookie('accessToken', accessToken, { httpOnly: true, maxAge: HOUR });
63-
res.cookie('refreshToken', refreshToken, {
64-
httpOnly: true,
65-
maxAge: WEEK,
66-
});
67+
this.tokenService.setAccessTokenCookie(res, accessToken);
68+
this.tokenService.setRefreshTokenCookie(res, refreshToken);
69+
6770
res.redirect(302, '/');
6871
}
6972

70-
@Post('refresh')
71-
async refreshAccessToken(@Req() req, @Res() res: Response) {
72-
const { refreshToken } = req.body;
73-
74-
const decoded = this.jwtService.verify(refreshToken, {
75-
secret: process.env.JWT_SECRET,
76-
});
77-
const payload = { sub: decoded.sub, provider: decoded.provider };
78-
const newAccessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
79-
res.cookie('accessToken', newAccessToken, {
80-
httpOnly: true,
81-
maxAge: HOUR,
82-
});
83-
return res.json({
84-
message: '새로운 Access Token 발급 성공',
73+
@ApiResponse({ type: MessageResponseDto })
74+
@ApiOperation({ summary: '사용자가 로그아웃합니다.' })
75+
@Post('logout')
76+
@UseGuards(JwtAuthGuard) // JWT 인증 검사
77+
logout(@Res() res: Response) {
78+
// 쿠키 삭제 (옵션이 일치해야 삭제됨)
79+
this.tokenService.clearCookies(res);
80+
return res.status(200).json({
81+
message: AuthResponseMessage.AUTH_LOGGED_OUT,
8582
});
8683
}
8784

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

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,11 @@ import { AuthService } from './auth.service';
55
import { AuthController } from './auth.controller';
66
import { NaverStrategy } from './strategies/naver.strategy';
77
import { KakaoStrategy } from './strategies/kakao.strategy';
8-
import { JwtModule } from '@nestjs/jwt';
98
import { JwtAuthGuard } from './guards/jwt-auth.guard';
10-
import { ConfigModule, ConfigService } from '@nestjs/config';
9+
import { TokenModule } from './token/token.module';
1110

1211
@Module({
13-
imports: [
14-
UserModule,
15-
ConfigModule.forRoot({ isGlobal: true }),
16-
JwtModule.registerAsync({
17-
imports: [ConfigModule],
18-
inject: [ConfigService],
19-
useFactory: async (configService: ConfigService) => ({
20-
secret: configService.get<string>('JWT_SECRET'),
21-
}),
22-
}),
23-
],
12+
imports: [UserModule, TokenModule],
2413
controllers: [AuthController],
2514
providers: [
2615
AuthService,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { AuthService } from './auth.service';
33
import { UserRepository } from '../user/user.repository';
4-
import { SignUpDto } from './dto/signUp.dto';
4+
import { SignUpDto } from './dtos/signUp.dto';
55
import { User } from '../user/user.entity';
66

77
describe('AuthService', () => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { UserRepository } from '../user/user.repository';
33
import { User } from '../user/user.entity';
4-
import { SignUpDto } from './dto/signUp.dto';
4+
import { SignUpDto } from './dtos/signUp.dto';
55

66
@Injectable()
77
export class AuthService {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsString } from 'class-validator';
3+
4+
export class MessageResponseDto {
5+
@ApiProperty({
6+
example: 'OO 생성에 성공했습니다.',
7+
description: 'api 요청 결과 메시지',
8+
})
9+
@IsString()
10+
message: string;
11+
}

apps/backend/src/auth/guards/jwt-auth.guard.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,69 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
22
import { JwtService } from '@nestjs/jwt';
33
import { LoginRequiredException } from '../../exception/login.exception';
44
import { InvalidTokenException } from '../../exception/invalid.exception';
5+
import { TokenExpiredError } from 'jsonwebtoken';
6+
import { TokenService } from '../token/token.service';
7+
import { Response } from 'express';
58

69
@Injectable()
710
export class JwtAuthGuard implements CanActivate {
8-
constructor(private readonly jwtService: JwtService) {}
11+
constructor(
12+
private readonly jwtService: JwtService,
13+
private readonly tokenService: TokenService,
14+
) {}
915

1016
async canActivate(context: ExecutionContext): Promise<boolean> {
1117
const request = context.switchToHttp().getRequest();
12-
const authorizationHeader = request.headers['authorization'];
18+
const response = context.switchToHttp().getResponse<Response>();
1319

14-
if (!authorizationHeader) {
15-
// console.log('Authorization header missing');
20+
const cookies = request.cookies; // 쿠키에서 가져오기
21+
22+
// 쿠키가 아예 없는 경우는 로그인 안 된 상태로 간주
23+
if (!cookies || !cookies.accessToken || !cookies.refreshToken) {
24+
// 관련된 쿠키 비워주기
25+
this.tokenService.clearCookies(response);
1626
throw new LoginRequiredException();
1727
}
1828

19-
const token = authorizationHeader.split(' ')[1];
29+
const { accessToken, refreshToken } = cookies;
2030

2131
try {
22-
const decodedToken = this.jwtService.verify(token, {
32+
// JWT 검증
33+
const decodedToken = this.jwtService.verify(accessToken, {
2334
secret: process.env.JWT_SECRET,
2435
});
36+
37+
// 유효한 토큰이면 요청 객체에 사용자 정보를 추가
2538
request.user = decodedToken;
2639
return true;
2740
} catch (error) {
28-
// console.log('Invalid token');
29-
throw new InvalidTokenException();
41+
// accessToken이 만료된 경우
42+
if (error instanceof TokenExpiredError) {
43+
try {
44+
// 새로운 accessToken 발급받기
45+
const newAccessToken =
46+
await this.tokenService.refreshAccessToken(refreshToken);
47+
48+
// 쿠키 업데이트
49+
this.tokenService.setAccessTokenCookie(response, newAccessToken);
50+
51+
// 요청 객체에 사용자 정보 추가
52+
const decodedNewToken = this.jwtService.verify(newAccessToken, {
53+
secret: process.env.JWT_SECRET,
54+
});
55+
request.user = decodedNewToken;
56+
57+
return true;
58+
} catch (refreshError) {
59+
// refreshToken 디코딩 실패 시 처리 쿠키 비워줌
60+
this.tokenService.clearCookies(response);
61+
throw new InvalidTokenException();
62+
}
63+
} else {
64+
// accessToken 디코딩(만료가 아닌 이유로) 실패 시 처리 쿠키 비워줌
65+
this.tokenService.clearCookies(response);
66+
throw new InvalidTokenException();
67+
}
3068
}
3169
}
3270
}

apps/backend/src/auth/strategies/kakao.strategy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
33
import { PassportStrategy } from '@nestjs/passport';
44
import { Profile, Strategy } from 'passport-kakao';
55
import { AuthService } from '../auth.service';
6-
import { SignUpDto } from '../dto/signUp.dto';
6+
import { SignUpDto } from '../dtos/signUp.dto';
77

88
@Injectable()
99
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {

0 commit comments

Comments
 (0)