Skip to content

Commit 89b29ca

Browse files
committed
feature: 인가 미들 웨어 분리, 인증 로직은 서비스로 합병, 그에 따른 타입을 포함한 전체 리펙토링
1 parent 408bfcf commit 89b29ca

File tree

22 files changed

+365
-217
lines changed

22 files changed

+365
-217
lines changed

src/controllers/user.controller.ts

Lines changed: 18 additions & 17 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

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

121-
const token = await this.userService.create(user.id, ip, userAgent);
125+
const token = await this.userService.createUserQRToken(user.id, ip, userAgent);
122126
const typedToken = token as Token10;
123127

124128
const response = new QRLoginTokenResponseDto(
@@ -138,22 +142,19 @@ export class UserController {
138142
try {
139143
const token = req.query.token as string;
140144
if (!token) {
141-
throw new InvalidTokenError('토큰이 필요합니다.');
145+
throw new QRTokenInvalidError('토큰이 필요합니다.');
142146
}
143147

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

152153
res.clearCookie('access_token', this.cookieOption());
153154
res.clearCookie('refresh_token', this.cookieOption());
154155

155-
res.cookie('access_token', decryptedAccessToken, this.cookieOption());
156-
res.cookie('refresh_token', decryptedRefreshToken, this.cookieOption());
156+
res.cookie('access_token', userLoginToken.decryptedAccessToken, this.cookieOption());
157+
res.cookie('refresh_token', userLoginToken.decryptedRefreshToken, this.cookieOption());
157158

158159
res.redirect('/main');
159160
} 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+
}

src/middlewares/auth.middleware.ts

Lines changed: 13 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { NextFunction, Request, Response } from 'express';
2-
import axios from 'axios';
32
import { isUUID } from 'class-validator';
43
import logger from '@/configs/logger.config';
54
import pool from '@/configs/db.config';
6-
import { DBError, InvalidTokenError } from '@/exception';
7-
import { VELOG_API_URL, VELOG_QUERIES } from '@/constants/velog.constans';
5+
import { InvalidTokenError } from '@/exception';
6+
import { VelogJWTPayload, User } from '@/types';
87

98
/**
109
* 요청에서 토큰을 추출하는 함수
@@ -22,60 +21,23 @@ const extractTokens = (req: Request): { accessToken: string; refreshToken: strin
2221
return { accessToken, refreshToken };
2322
};
2423

25-
/**
26-
* Velog API를 통해 사용자 정보를 조회합니다.
27-
* @param query - GraphQL 쿼리 문자열
28-
* @param accessToken - Velog access token
29-
* @throws {Error} API 호출 실패 시
30-
* @returns Promise<VelogUserLoginResponse | null>
31-
*/
32-
const fetchVelogApi = async (query: string, accessToken: string, refreshToken: string) => {
33-
try {
34-
const response = await axios.post(
35-
VELOG_API_URL,
36-
{ query, variables: {} },
37-
{
38-
headers: {
39-
authority: 'v3.velog.io',
40-
origin: 'https://velog.io',
41-
'content-type': 'application/json',
42-
cookie: `access_token=${accessToken}; refresh_token=${refreshToken}`,
43-
},
44-
},
45-
);
46-
47-
const result = response.data;
48-
49-
if (result.errors) {
50-
logger.error('GraphQL Errors : ', result.errors);
51-
throw new InvalidTokenError('Velog API 인증에 실패했습니다.');
52-
}
53-
54-
return result.data.currentUser || null;
55-
} catch (error) {
56-
logger.error('Velog API 호출 중 오류 : ', error);
57-
throw new InvalidTokenError('Velog API 인증에 실패했습니다.');
58-
}
59-
};
60-
6124
/**
6225
* JWT 토큰에서 페이로드를 추출하고 디코딩하는 함수
26+
* 이건 진짜 velog 에서 사용하는 걸 그대로 가져온 함수임!
6327
* @param token - 디코딩할 JWT 토큰 문자열
64-
* @returns JSON 객체로 디코딩된 페이로드
28+
* @returns {VelogJWTPayload}
6529
* @throws {Error} 토큰이 잘못되었거나 디코딩할 수 없는 경우
6630
* @example
6731
* const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
6832
* const payload = extractPayload(token);
6933
* // 반환값: { sub: "1234567890" }
7034
*/
71-
const extractPayload = (token: string) => JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
35+
const extractPayload = (token: string): VelogJWTPayload => JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
7236

7337
/**
74-
* Bearer 토큰을 검증한뒤 최초 로그인이라면 Velog 사용자를 인증을, 아니라면 기존 사용자를 인증하여 user정보를 Request 객체에 담는 함수
75-
* @param query - 사용자 정보를 조회할 GraphQL 쿼리
76-
* @returns
38+
* Bearer 토큰을 검증한뒤 user정보를 Request 객체에 담는 인가 함수
7739
*/
78-
const verifyBearerTokens = (query?: string) => {
40+
const verifyBearerTokens = () => {
7941
return async (req: Request, res: Response, next: NextFunction) => {
8042
try {
8143
const { accessToken, refreshToken } = extractTokens(req);
@@ -84,23 +46,12 @@ const verifyBearerTokens = (query?: string) => {
8446
throw new InvalidTokenError('accessToken과 refreshToken의 입력이 올바르지 않습니다');
8547
}
8648

87-
let user = null;
88-
if (query) {
89-
user = await fetchVelogApi(query, accessToken, refreshToken);
90-
if (!user) {
91-
throw new InvalidTokenError('유효하지 않은 토큰입니다.');
92-
}
93-
} else {
94-
const payload = extractPayload(accessToken);
95-
if (!payload.user_id || !isUUID(payload.user_id)) {
96-
throw new InvalidTokenError('유효하지 않은 토큰 페이로드 입니다.');
97-
}
98-
99-
user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])).rows[0];
100-
if (!user) {
101-
throw new DBError('사용자를 찾을 수 없습니다.');
102-
}
49+
const payload = extractPayload(accessToken);
50+
if (!payload.user_id || !isUUID(payload.user_id)) {
51+
throw new InvalidTokenError('유효하지 않은 토큰 페이로드 입니다.');
10352
}
53+
54+
const user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])).rows[0] as User;
10455
req.user = user;
10556
req.tokens = { accessToken, refreshToken };
10657

@@ -114,10 +65,8 @@ const verifyBearerTokens = (query?: string) => {
11465

11566
/**
11667
* 사용자 인증을 위한 미들웨어 모음
117-
* @property {Function} login - 최초 로그인 시 Velog API를 호출하는 인증 미들웨어
118-
* @property {Function} verify - 기존 유저를 인증하는 미들웨어
68+
* @property {Function} verify
11969
*/
12070
export const authMiddleware = {
121-
login: verifyBearerTokens(VELOG_QUERIES.LOGIN),
12271
verify: verifyBearerTokens(),
12372
};

src/modules/token_encryption/aes_encryption.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import crypto from 'crypto';
22

3+
export interface TokenEncryptionService {
4+
encrypt(plaintext: string): string;
5+
decrypt(encryptedData: string): string;
6+
}
7+
38
/**
49
* AES 암호화/복호화 유틸리티 클래스
510
* AES-256-CBC 알고리즘을 사용하여 데이터를 암호화하고 복호화합니다.
611
*/
7-
class AESEncryption {
12+
class AESEncryption implements TokenEncryptionService {
813
private readonly key: Buffer;
914

1015
/**

src/modules/velog/velog.api.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import axios from 'axios';
2+
3+
import logger from '@/configs/logger.config';
4+
import { InvalidTokenError } from '@/exception';
5+
import { VELOG_API_URL, VELOG_QUERIES } from '@/modules/velog/velog.constans';
6+
import { VelogUserCurrentResponse } from './velog.type';
7+
8+
/**
9+
* Velog API를 통해 사용자 정보를 조회합니다.
10+
* @param accessToken - Velog access token
11+
* @param refreshToken - Velog refresh token
12+
* @throws {Error} API 호출 실패 시
13+
* @returns Promise<VelogUserLoginResponse | null>
14+
*/
15+
export const fetchVelogApi = async (accessToken: string, refreshToken: string): Promise<VelogUserCurrentResponse> => {
16+
try {
17+
const response = await axios.post(
18+
VELOG_API_URL,
19+
{ VELOG_QUERIES, variables: {} },
20+
{
21+
headers: {
22+
authority: 'v3.velog.io',
23+
origin: 'https://velog.io',
24+
'content-type': 'application/json',
25+
cookie: `access_token=${accessToken}; refresh_token=${refreshToken}`,
26+
},
27+
},
28+
);
29+
30+
const result = response.data;
31+
32+
if (result.errors) {
33+
logger.error('GraphQL Errors : ', result.errors);
34+
throw new InvalidTokenError('Velog API 인증에 실패했습니다.');
35+
}
36+
37+
if (!result.data.currentUser) {
38+
logger.error('Velog API 응답에 currentUser 정보가 없습니다.');
39+
throw new InvalidTokenError('Velog 사용자 정보를 가져오지 못했습니다.');
40+
}
41+
42+
// email이 undefined인 경우 null로 변환
43+
const currentUser = result.data.currentUser;
44+
return {
45+
...currentUser,
46+
email: currentUser.email ?? null
47+
};
48+
} catch (error) {
49+
logger.error('Velog API 호출 중 오류 : ', error);
50+
throw new InvalidTokenError('Velog API 인증에 실패했습니다.');
51+
}
52+
};

src/modules/velog/velog.type.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
2+
/**
3+
* Velog 쪽에서 사용하는 토큰을 사용할때 실제 payload 값
4+
*/
5+
export interface VelogJWTPayload {
6+
user_id: string; // UUID 값, key를 바꿀 순 없음
7+
iat: number; // issued at timestamp
8+
exp: number; // expiration timestamp
9+
iss: string; // issuer
10+
sub: string; // subject
11+
}
12+
13+
14+
/**
15+
* Velog 쪽에서 사용하는, 실제 currentUser API 호출시 주는 값 중 일부분
16+
*/
17+
export interface VelogUserCurrentResponse {
18+
id: string; // 이는 실제로 uuid를 줌
19+
username: string;
20+
email: string | null;
21+
profile: {
22+
thumbnail: string;
23+
};
24+
}

src/repositories/__test__/qr.repo.integration.test.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import logger from '@/configs/logger.config';
88
dotenv.config();
99
jest.setTimeout(5000);
1010

11-
describe('QRLoginTokenRepository 통합 테스트', () => {
11+
describe('UserRepository QR 토큰 통합 테스트', () => {
1212
let testPool: Pool;
1313
let repo: UserRepository;
1414

@@ -53,13 +53,13 @@ describe('QRLoginTokenRepository 통합 테스트', () => {
5353
`,
5454
[TEST_DATA.USER_ID]
5555
);
56-
56+
5757
await new Promise(resolve => setTimeout(resolve, 1000));
58-
58+
5959
if (testPool) {
6060
await testPool.end();
6161
}
62-
62+
6363
await new Promise(resolve => setTimeout(resolve, 1000));
6464
logger.info('테스트 DB 연결 종료 및 테스트 데이터 정리 완료');
6565
} catch (error) {
@@ -79,9 +79,12 @@ describe('QRLoginTokenRepository 통합 테스트', () => {
7979
// 토큰이 존재함을 확인하고 타입 단언
8080
expect(foundToken).not.toBeNull();
8181
const nonNullToken = foundToken as NonNullable<typeof foundToken>;
82-
82+
8383
expect(nonNullToken.token).toBe(token);
84+
expect(Number(nonNullToken.user_id)).toBe(TEST_DATA.USER_ID);
8485
expect(nonNullToken.is_used).toBe(false);
86+
expect(nonNullToken.ip_address).toBe(ip);
87+
expect(nonNullToken.user_agent).toBe(userAgent);
8588
expect(new Date(nonNullToken.expires_at).getTime()).toBeGreaterThan(new Date(nonNullToken.created_at).getTime());
8689
});
8790

@@ -100,11 +103,16 @@ describe('QRLoginTokenRepository 통합 테스트', () => {
100103
const userAgent = 'test-agent';
101104

102105
await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent);
103-
await repo.markTokenUsed(token);
104106

105-
const found = await repo.findQRLoginToken(token);
107+
// 토큰 조회 후 user_id를 얻어 updateQRLoginTokenToUse 호출
108+
const foundToken = await repo.findQRLoginToken(token);
109+
expect(foundToken).not.toBeNull();
106110

107-
expect(found).toBeNull();
111+
await repo.updateQRLoginTokenToUse(TEST_DATA.USER_ID);
112+
113+
// 토큰이 is_used=true로 변경되었으므로 findQRLoginToken에서 null 반환 예상
114+
const afterUpdate = await repo.findQRLoginToken(token);
115+
expect(afterUpdate).toBeNull();
108116
});
109117
});
110118

@@ -145,4 +153,4 @@ describe('QRLoginTokenRepository 통합 테스트', () => {
145153
expect(found).toBeNull();
146154
});
147155
});
148-
});
156+
});

0 commit comments

Comments
 (0)