Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/test-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:

- name: Create .env file
run: |
echo "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" >> .env
echo "DATABASE_NAME=${{ secrets.DATABASE_NAME }}" >> .env
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env
Expand Down
1 change: 0 additions & 1 deletion src/controllers/post.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ export class PostController {
) => {
try {
const postId = req.params.postId;

const post = await this.postService.getPostByPostUUID(postId);

const response = new PostResponseDto(true, 'uuid로 post 조회에 성공하였습니다.', { post }, null);
Expand Down
43 changes: 23 additions & 20 deletions src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { NextFunction, Request, Response, RequestHandler, CookieOptions } from 'express';
import logger from '@/configs/logger.config';
import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types';
import { EmptyResponseDto, LoginResponseDto } from '@/types';
import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type';
import { UserService } from '@/services/user.service';
import { InvalidTokenError, TokenExpiredError } from '@/exception/token.exception';
import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception/token.exception';
import { fetchVelogApi } from '@/modules/velog/velog.api';

type Token10 = string & { __lengthBrand: 10 };

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

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

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

// 2. 우리쪽 DB에 사용자 존재 여부 체크 후 로그인 바로 진행 또는 사용자 생성 후 로그인 진행
const user = await this.userService.handleUserTokensByVelogUUID(velogUser, accessToken, refreshToken);

// 3. 로그이 완료 후 쿠키 세팅
res.clearCookie('access_token', this.cookieOption());
res.clearCookie('refresh_token', this.cookieOption());

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

Expand Down Expand Up @@ -95,13 +99,15 @@ export class UserController {
res.status(200).json(response);
};

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

const response = new LoginResponseDto(
true,
'유저 정보 조회에 성공하였습니다.',
{ id: user.id, username: user.username, profile: user.profile },
{ id: req.user.id, username: velogUser.username, profile: velogUser.profile },
null,
);

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

const token = await this.userService.create(user.id, ip, userAgent);
const token = await this.userService.createUserQRToken(user.id, ip, userAgent);
const typedToken = token as Token10;

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

const found = await this.userService.useToken(token);
if (!found) {
throw new TokenExpiredError();
const userLoginToken = await this.userService.useToken(token);
if (!userLoginToken) {
throw new QRTokenExpiredError();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found가 아닌 userLoginToken으로 명시하니 코드가 의미하는 바가 명확해진 것 같습니다!
이 역시 디테일한 부분을 놓쳤었던 것 같네요🥲

}

const { decryptedAccessToken, decryptedRefreshToken } =
await this.userService.findUserAndTokensByVelogUUID(found.user.toString());

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

res.cookie('access_token', decryptedAccessToken, this.cookieOption());
res.cookie('refresh_token', decryptedRefreshToken, this.cookieOption());
res.cookie('access_token', userLoginToken.decryptedAccessToken, this.cookieOption());
res.cookie('refresh_token', userLoginToken.decryptedRefreshToken, this.cookieOption());

res.redirect('/main');
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/exception/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { CustomError } from './custom.exception';
export { DBError } from './db.exception';
export { TokenError, TokenExpiredError, InvalidTokenError } from './token.exception';
export { TokenError, TokenExpiredError, InvalidTokenError, QRTokenExpiredError, QRTokenInvalidError } from './token.exception';
export { UnauthorizedError } from './unauthorized.exception';
export { BadRequestError } from './badRequest.exception';
export { NotFoundError } from './notFound.exception';
17 changes: 17 additions & 0 deletions src/exception/token.exception.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CustomError } from './custom.exception';
import { BadRequestError } from './badRequest.exception';
import { UnauthorizedError } from './unauthorized.exception';

export class TokenError extends CustomError {
Expand All @@ -18,3 +19,19 @@ export class InvalidTokenError extends UnauthorizedError {
super(message, 'INVALID_TOKEN');
}
}

/* ===================================================
아래 부터는 QRToken 에 관한 에러
=================================================== */

export class QRTokenExpiredError extends BadRequestError {
constructor(message = 'QR 토큰이 만료되었습니다') {
super(message, 'TOKEN_EXPIRED');
}
}

export class QRTokenInvalidError extends BadRequestError {
constructor(message = '유효하지 않은 QR 토큰입니다') {
super(message, 'INVALID_TOKEN');
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QR 토큰에 대한 에러는 UnauthorizedError가 아닌 BadRequestError가 확실히 더 적절하겠네요!
디테일한 부분을 놓쳤는데 수정해주셔서 감사합니다!👍

213 changes: 213 additions & 0 deletions src/middlewares/__test__/auth.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { Request, Response } from 'express';
import { authMiddleware } from '@/middlewares/auth.middleware';
import pool from '@/configs/db.config';

// pool.query 모킹
jest.mock('@/configs/db.config', () => ({
query: jest.fn(),
}));

// logger 모킹
jest.mock('@/configs/logger.config', () => ({
error: jest.fn(),
info: jest.fn(),
}));

describe('인증 미들웨어', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: jest.Mock;

beforeEach(() => {
// 테스트마다 request, response, next 함수 초기화
mockRequest = {
body: {},
headers: {},
cookies: {},
};
mockResponse = {
json: jest.fn(),
status: jest.fn().mockReturnThis(),
};
nextFunction = jest.fn();
});

afterEach(() => {
jest.clearAllMocks();
});

describe('verify', () => {
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYzc1MDcyNDAtMDkzYi0xMWVhLTlhYWUtYTU4YTg2YmIwNTIwIiwiaWF0IjoxNjAzOTM0NTI5LCJleHAiOjE2MDM5MzgxMjksImlzcyI6InZlbG9nLmlvIiwic3ViIjoiYWNjZXNzX3Rva2VuIn0.Q_I4PMBeeZSU-HbPZt7z9OW-tQjE0NI0I0DLF2qpZjY';

it('유효한 토큰으로 사용자 정보를 Request에 추가해야 한다', async () => {
// 유효한 토큰 준비
mockRequest.cookies = {
'access_token': validToken,
'refresh_token': 'refresh-token'
};

// 사용자 정보 mock
const mockUser = {
id: 1,
username: 'testuser',
email: 'test@example.com',
velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520'
};

// DB 쿼리 결과 모킹
(pool.query as jest.Mock).mockResolvedValueOnce({
rows: [mockUser]
});

// 미들웨어 실행
await authMiddleware.verify(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

// 검증
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error));
expect(mockRequest.user).toEqual(mockUser);
expect(mockRequest.tokens).toEqual({
accessToken: validToken,
refreshToken: 'refresh-token'
});
expect(pool.query).toHaveBeenCalledWith(
'SELECT * FROM "users_user" WHERE velog_uuid = $1',
['c7507240-093b-11ea-9aae-a58a86bb0520']
);
});

it('토큰이 없으면 InvalidTokenError를 전달해야 한다', async () => {
// 토큰 없음
mockRequest.cookies = {};

// 미들웨어 실행
await authMiddleware.verify(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

// 검증
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(nextFunction).toHaveBeenCalledWith(
expect.objectContaining({
name: 'InvalidTokenError',
message: 'accessToken과 refreshToken의 입력이 올바르지 않습니다'
})
);
});

it('유효하지 않은 토큰으로 InvalidTokenError를 전달해야 한다', async () => {
// 유효하지 않은 토큰 (JWT 형식은 맞지만 내용이 잘못됨)
const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnZhbGlkIjoidG9rZW4ifQ.invalidSignature';
mockRequest.cookies = {
'access_token': invalidToken,
'refresh_token': 'refresh-token'
};

// 미들웨어 실행
await authMiddleware.verify(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

// 검증
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(nextFunction).toHaveBeenCalledWith(expect.any(Error));
});

it('UUID가 없는 페이로드로 InvalidTokenError를 전달해야 한다', async () => {
// UUID가 없는 토큰 (페이로드를 임의로 조작)
const tokenWithoutUUID = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDM5MzQ1MjksImV4cCI6MTYwMzkzODEyOSwiaXNzIjoidmVsb2cuaW8iLCJzdWIiOiJhY2Nlc3NfdG9rZW4ifQ.2fLHQ3yKs9UmBQUa2oat9UOLiXzXvrhv_XHU2qwLBs8';

mockRequest.cookies = {
'access_token': tokenWithoutUUID,
'refresh_token': 'refresh-token'
};

// 미들웨어 실행
await authMiddleware.verify(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

// 검증
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(nextFunction).toHaveBeenCalledWith(
expect.objectContaining({
name: 'InvalidTokenError',
message: '유효하지 않은 토큰 페이로드 입니다.'
})
);
});

it('사용자를 찾을 수 없으면 DBError가 발생해야 한다', async () => {
// 유효한 토큰 준비
mockRequest.cookies = {
'access_token': validToken,
'refresh_token': 'refresh-token'
};

// 사용자가 없음 모킹
(pool.query as jest.Mock).mockResolvedValueOnce({
rows: []
});

// 미들웨어 실행
await authMiddleware.verify(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

// 검증
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(mockRequest.user).toBeUndefined();
expect(nextFunction).toHaveBeenCalledWith(
expect.objectContaining({
name: 'DBError',
message: '사용자를 찾을 수 없습니다.'
})
);
});

it('쿠키에 토큰이 없으면 헤더에서 토큰을 가져와야 한다', async () => {
// 요청 본문에 토큰 설정
mockRequest.body = {
accessToken: validToken,
refreshToken: 'refresh-token'
};

// 사용자 정보 mock
const mockUser = {
id: 1,
username: 'testuser',
email: 'test@example.com',
velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520'
};

// DB 쿼리 결과 모킹
(pool.query as jest.Mock).mockResolvedValueOnce({
rows: [mockUser]
});

// 미들웨어 실행
await authMiddleware.verify(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

// 검증
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error));
expect(mockRequest.user).toEqual(mockUser);
});
});
});
Loading
Loading