Skip to content

Commit 587514d

Browse files
authored
[25.05.05 / TASK-182] Feature - middleware 인가 로직 전체 리펙토링, 그에 따른 대응 개발 (#30)
* feature: 인가 미들 웨어 분리, 인증 로직은 서비스로 합병, 그에 따른 타입을 포함한 전체 리펙토링 * feature: 미들웨어 자체 테스트 코드 추가, 그에 따른 velog api 유닛 테스트 추가 * modify: 검증 완료 * modify: user me api 에서 username 때문에 외부 API 호출부분 추가 * modify: SLACK_WEBHOOK_URL 이 왜 빠졌지? 추가 * modify: 테스트 코드 피드백 반영, 리펙토링 (가독성, 가시성)
1 parent 408bfcf commit 587514d

26 files changed

+752
-222
lines changed

.github/workflows/test-ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252

5353
- name: Create .env file
5454
run: |
55+
echo "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" >> .env
5556
echo "DATABASE_NAME=${{ secrets.DATABASE_NAME }}" >> .env
5657
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
5758
echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env

src/controllers/post.controller.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export class PostController {
9090
) => {
9191
try {
9292
const postId = req.params.postId;
93-
9493
const post = await this.postService.getPostByPostUUID(postId);
9594

9695
const response = new PostResponseDto(true, 'uuid로 post 조회에 성공하였습니다.', { post }, null);

src/controllers/user.controller.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { NextFunction, Request, Response, RequestHandler, CookieOptions } from 'express';
22
import logger from '@/configs/logger.config';
3-
import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types';
3+
import { EmptyResponseDto, LoginResponseDto } from '@/types';
44
import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type';
55
import { UserService } from '@/services/user.service';
6-
import { InvalidTokenError, TokenExpiredError } from '@/exception/token.exception';
6+
import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception/token.exception';
7+
import { fetchVelogApi } from '@/modules/velog/velog.api';
78

89
type Token10 = string & { __lengthBrand: 10 };
910

@@ -30,12 +31,15 @@ export class UserController {
3031

3132
login: RequestHandler = async (req: Request, res: Response<LoginResponseDto>, next: NextFunction): Promise<void> => {
3233
try {
33-
const { id, email, profile, username } = req.user;
34-
const { accessToken, refreshToken } = req.tokens;
3534

36-
const userWithToken: UserWithTokenDto = { id, email, accessToken, refreshToken };
37-
const isExistUser = await this.userService.handleUserTokensByVelogUUID(userWithToken);
35+
// 1. 외부 API (velog) 호출로 실존 하는 토큰 & 사용자 인지 검증
36+
const { accessToken, refreshToken } = req.body;
37+
const velogUser = await fetchVelogApi(accessToken, refreshToken);
3838

39+
// 2. 우리쪽 DB에 사용자 존재 여부 체크 후 로그인 바로 진행 또는 사용자 생성 후 로그인 진행
40+
const user = await this.userService.handleUserTokensByVelogUUID(velogUser, accessToken, refreshToken);
41+
42+
// 3. 로그이 완료 후 쿠키 세팅
3943
res.clearCookie('access_token', this.cookieOption());
4044
res.clearCookie('refresh_token', this.cookieOption());
4145

@@ -45,7 +49,7 @@ export class UserController {
4549
const response = new LoginResponseDto(
4650
true,
4751
'로그인에 성공하였습니다.',
48-
{ id: isExistUser.id, username, profile },
52+
{ id: user.id, username: velogUser.username, profile: velogUser.profile },
4953
null,
5054
);
5155

@@ -95,13 +99,15 @@ export class UserController {
9599
res.status(200).json(response);
96100
};
97101

98-
fetchCurrentUser: RequestHandler = (req: Request, res: Response<LoginResponseDto>) => {
99-
const { user } = req;
102+
fetchCurrentUser: RequestHandler = async (req: Request, res: Response<LoginResponseDto>) => {
103+
// 외부 API (velog) 호출로 username 을 가져와야 함, 게시글 바로가기 때문에 (username)
104+
const { accessToken, refreshToken } = req.tokens;
105+
const velogUser = await fetchVelogApi(accessToken, refreshToken);
100106

101107
const response = new LoginResponseDto(
102108
true,
103109
'유저 정보 조회에 성공하였습니다.',
104-
{ id: user.id, username: user.username, profile: user.profile },
110+
{ id: req.user.id, username: velogUser.username, profile: velogUser.profile },
105111
null,
106112
);
107113

@@ -118,7 +124,7 @@ export class UserController {
118124
const ip = typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'].split(',')[0].trim() : req.ip ?? '';
119125
const userAgent = req.headers['user-agent'] || '';
120126

121-
const token = await this.userService.create(user.id, ip, userAgent);
127+
const token = await this.userService.createUserQRToken(user.id, ip, userAgent);
122128
const typedToken = token as Token10;
123129

124130
const response = new QRLoginTokenResponseDto(
@@ -138,22 +144,19 @@ export class UserController {
138144
try {
139145
const token = req.query.token as string;
140146
if (!token) {
141-
throw new InvalidTokenError('토큰이 필요합니다.');
147+
throw new QRTokenInvalidError('토큰이 필요합니다.');
142148
}
143149

144-
const found = await this.userService.useToken(token);
145-
if (!found) {
146-
throw new TokenExpiredError();
150+
const userLoginToken = await this.userService.useToken(token);
151+
if (!userLoginToken) {
152+
throw new QRTokenExpiredError();
147153
}
148-
149-
const { decryptedAccessToken, decryptedRefreshToken } =
150-
await this.userService.findUserAndTokensByVelogUUID(found.user.toString());
151154

152155
res.clearCookie('access_token', this.cookieOption());
153156
res.clearCookie('refresh_token', this.cookieOption());
154157

155-
res.cookie('access_token', decryptedAccessToken, this.cookieOption());
156-
res.cookie('refresh_token', decryptedRefreshToken, this.cookieOption());
158+
res.cookie('access_token', userLoginToken.decryptedAccessToken, this.cookieOption());
159+
res.cookie('refresh_token', userLoginToken.decryptedRefreshToken, this.cookieOption());
157160

158161
res.redirect('/main');
159162
} catch (error) {

src/exception/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { CustomError } from './custom.exception';
22
export { DBError } from './db.exception';
3-
export { TokenError, TokenExpiredError, InvalidTokenError } from './token.exception';
3+
export { TokenError, TokenExpiredError, InvalidTokenError, QRTokenExpiredError, QRTokenInvalidError } from './token.exception';
44
export { UnauthorizedError } from './unauthorized.exception';
55
export { BadRequestError } from './badRequest.exception';
66
export { NotFoundError } from './notFound.exception';

src/exception/token.exception.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CustomError } from './custom.exception';
2+
import { BadRequestError } from './badRequest.exception';
23
import { UnauthorizedError } from './unauthorized.exception';
34

45
export class TokenError extends CustomError {
@@ -18,3 +19,19 @@ export class InvalidTokenError extends UnauthorizedError {
1819
super(message, 'INVALID_TOKEN');
1920
}
2021
}
22+
23+
/* ===================================================
24+
아래 부터는 QRToken 에 관한 에러
25+
=================================================== */
26+
27+
export class QRTokenExpiredError extends BadRequestError {
28+
constructor(message = 'QR 토큰이 만료되었습니다') {
29+
super(message, 'TOKEN_EXPIRED');
30+
}
31+
}
32+
33+
export class QRTokenInvalidError extends BadRequestError {
34+
constructor(message = '유효하지 않은 QR 토큰입니다') {
35+
super(message, 'INVALID_TOKEN');
36+
}
37+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { Request, Response } from 'express';
2+
import { authMiddleware } from '@/middlewares/auth.middleware';
3+
import pool from '@/configs/db.config';
4+
5+
// pool.query 모킹
6+
jest.mock('@/configs/db.config', () => ({
7+
query: jest.fn(),
8+
}));
9+
10+
// logger 모킹
11+
jest.mock('@/configs/logger.config', () => ({
12+
error: jest.fn(),
13+
info: jest.fn(),
14+
}));
15+
16+
describe('인증 미들웨어', () => {
17+
let mockRequest: Partial<Request>;
18+
let mockResponse: Partial<Response>;
19+
let nextFunction: jest.Mock;
20+
21+
beforeEach(() => {
22+
// 테스트마다 request, response, next 함수 초기화
23+
mockRequest = {
24+
body: {},
25+
headers: {},
26+
cookies: {},
27+
};
28+
mockResponse = {
29+
json: jest.fn(),
30+
status: jest.fn().mockReturnThis(),
31+
};
32+
nextFunction = jest.fn();
33+
});
34+
35+
afterEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
describe('verify', () => {
40+
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYzc1MDcyNDAtMDkzYi0xMWVhLTlhYWUtYTU4YTg2YmIwNTIwIiwiaWF0IjoxNjAzOTM0NTI5LCJleHAiOjE2MDM5MzgxMjksImlzcyI6InZlbG9nLmlvIiwic3ViIjoiYWNjZXNzX3Rva2VuIn0.Q_I4PMBeeZSU-HbPZt7z9OW-tQjE0NI0I0DLF2qpZjY';
41+
42+
it('유효한 토큰으로 사용자 정보를 Request에 추가해야 한다', async () => {
43+
// 유효한 토큰 준비
44+
mockRequest.cookies = {
45+
'access_token': validToken,
46+
'refresh_token': 'refresh-token'
47+
};
48+
49+
// 사용자 정보 mock
50+
const mockUser = {
51+
id: 1,
52+
username: 'testuser',
53+
54+
velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520'
55+
};
56+
57+
// DB 쿼리 결과 모킹
58+
(pool.query as jest.Mock).mockResolvedValueOnce({
59+
rows: [mockUser]
60+
});
61+
62+
// 미들웨어 실행
63+
await authMiddleware.verify(
64+
mockRequest as Request,
65+
mockResponse as Response,
66+
nextFunction
67+
);
68+
69+
// 검증
70+
expect(nextFunction).toHaveBeenCalledTimes(1);
71+
expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error));
72+
expect(mockRequest.user).toEqual(mockUser);
73+
expect(mockRequest.tokens).toEqual({
74+
accessToken: validToken,
75+
refreshToken: 'refresh-token'
76+
});
77+
expect(pool.query).toHaveBeenCalledWith(
78+
'SELECT * FROM "users_user" WHERE velog_uuid = $1',
79+
['c7507240-093b-11ea-9aae-a58a86bb0520']
80+
);
81+
});
82+
83+
it('토큰이 없으면 InvalidTokenError를 전달해야 한다', async () => {
84+
// 토큰 없음
85+
mockRequest.cookies = {};
86+
87+
// 미들웨어 실행
88+
await authMiddleware.verify(
89+
mockRequest as Request,
90+
mockResponse as Response,
91+
nextFunction
92+
);
93+
94+
// 검증
95+
expect(nextFunction).toHaveBeenCalledTimes(1);
96+
expect(nextFunction).toHaveBeenCalledWith(
97+
expect.objectContaining({
98+
name: 'InvalidTokenError',
99+
message: 'accessToken과 refreshToken의 입력이 올바르지 않습니다'
100+
})
101+
);
102+
});
103+
104+
it('유효하지 않은 토큰으로 InvalidTokenError를 전달해야 한다', async () => {
105+
// 유효하지 않은 토큰 (JWT 형식은 맞지만 내용이 잘못됨)
106+
const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnZhbGlkIjoidG9rZW4ifQ.invalidSignature';
107+
mockRequest.cookies = {
108+
'access_token': invalidToken,
109+
'refresh_token': 'refresh-token'
110+
};
111+
112+
// 미들웨어 실행
113+
await authMiddleware.verify(
114+
mockRequest as Request,
115+
mockResponse as Response,
116+
nextFunction
117+
);
118+
119+
// 검증
120+
expect(nextFunction).toHaveBeenCalledTimes(1);
121+
expect(nextFunction).toHaveBeenCalledWith(expect.any(Error));
122+
});
123+
124+
it('UUID가 없는 페이로드로 InvalidTokenError를 전달해야 한다', async () => {
125+
// UUID가 없는 토큰 (페이로드를 임의로 조작)
126+
const tokenWithoutUUID = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDM5MzQ1MjksImV4cCI6MTYwMzkzODEyOSwiaXNzIjoidmVsb2cuaW8iLCJzdWIiOiJhY2Nlc3NfdG9rZW4ifQ.2fLHQ3yKs9UmBQUa2oat9UOLiXzXvrhv_XHU2qwLBs8';
127+
128+
mockRequest.cookies = {
129+
'access_token': tokenWithoutUUID,
130+
'refresh_token': 'refresh-token'
131+
};
132+
133+
// 미들웨어 실행
134+
await authMiddleware.verify(
135+
mockRequest as Request,
136+
mockResponse as Response,
137+
nextFunction
138+
);
139+
140+
// 검증
141+
expect(nextFunction).toHaveBeenCalledTimes(1);
142+
expect(nextFunction).toHaveBeenCalledWith(
143+
expect.objectContaining({
144+
name: 'InvalidTokenError',
145+
message: '유효하지 않은 토큰 페이로드 입니다.'
146+
})
147+
);
148+
});
149+
150+
it('사용자를 찾을 수 없으면 DBError가 발생해야 한다', async () => {
151+
// 유효한 토큰 준비
152+
mockRequest.cookies = {
153+
'access_token': validToken,
154+
'refresh_token': 'refresh-token'
155+
};
156+
157+
// 사용자가 없음 모킹
158+
(pool.query as jest.Mock).mockResolvedValueOnce({
159+
rows: []
160+
});
161+
162+
// 미들웨어 실행
163+
await authMiddleware.verify(
164+
mockRequest as Request,
165+
mockResponse as Response,
166+
nextFunction
167+
);
168+
169+
// 검증
170+
expect(nextFunction).toHaveBeenCalledTimes(1);
171+
expect(mockRequest.user).toBeUndefined();
172+
expect(nextFunction).toHaveBeenCalledWith(
173+
expect.objectContaining({
174+
name: 'DBError',
175+
message: '사용자를 찾을 수 없습니다.'
176+
})
177+
);
178+
});
179+
180+
it('쿠키에 토큰이 없으면 헤더에서 토큰을 가져와야 한다', async () => {
181+
// 요청 본문에 토큰 설정
182+
mockRequest.body = {
183+
accessToken: validToken,
184+
refreshToken: 'refresh-token'
185+
};
186+
187+
// 사용자 정보 mock
188+
const mockUser = {
189+
id: 1,
190+
username: 'testuser',
191+
192+
velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520'
193+
};
194+
195+
// DB 쿼리 결과 모킹
196+
(pool.query as jest.Mock).mockResolvedValueOnce({
197+
rows: [mockUser]
198+
});
199+
200+
// 미들웨어 실행
201+
await authMiddleware.verify(
202+
mockRequest as Request,
203+
mockResponse as Response,
204+
nextFunction
205+
);
206+
207+
// 검증
208+
expect(nextFunction).toHaveBeenCalledTimes(1);
209+
expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error));
210+
expect(mockRequest.user).toEqual(mockUser);
211+
});
212+
});
213+
});

0 commit comments

Comments
 (0)