diff --git a/src/app.ts b/src/app.ts index 327750b..8174eb1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,8 @@ import { NotFoundError } from './exception'; dotenv.config(); const app: Application = express(); +// 실제 클라이언트 IP를 알기 위한 trust proxy 설정 +app.set('trust proxy', true); const swaggerSpec = swaggerJSDoc(options); app.use(cookieParser()); diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 37d9f90..dab0d9b 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,7 +1,12 @@ import { NextFunction, Request, Response, RequestHandler, CookieOptions } from 'express'; import logger from '@/configs/logger.config'; import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types'; +import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type'; import { UserService } from '@/services/user.service'; +import { InvalidTokenError, TokenExpiredError } from '@/exception/token.exception'; + +type Token10 = string & { __lengthBrand: 10 }; + export class UserController { constructor(private userService: UserService) { } @@ -102,4 +107,58 @@ export class UserController { res.status(200).json(response); }; + + createToken: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const user = req.user; + 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 typedToken = token as Token10; + + const response = new QRLoginTokenResponseDto( + true, + 'QR 토큰 생성 완료', + { token: typedToken }, + null + ); + res.status(200).json(response); + } catch (error) { + logger.error(`QR 토큰 생성 실패: [userId: ${req.user?.id || 'anonymous'}]`, error); + next(error); + } + }; + + getToken: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + try { + const token = req.query.token as string; + if (!token) { + throw new InvalidTokenError('토큰이 필요합니다.'); + } + + const found = await this.userService.useToken(token); + if (!found) { + throw new TokenExpiredError(); + } + + 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.redirect('/main'); + } catch (error) { + logger.error(`QR 토큰 로그인 처리 실패: [userId: ${req.user?.id || 'anonymous'}]`, error); + next(error); + } + }; } diff --git a/src/repositories/__test__/qr.repo.integration.test.ts b/src/repositories/__test__/qr.repo.integration.test.ts new file mode 100644 index 0000000..d18a048 --- /dev/null +++ b/src/repositories/__test__/qr.repo.integration.test.ts @@ -0,0 +1,148 @@ +import dotenv from 'dotenv'; +import { Pool } from 'pg'; +import pg from 'pg'; +import { UserRepository } from '@/repositories/user.repository'; +import { generateRandomToken } from '@/utils/generateRandomToken.util'; +import logger from '@/configs/logger.config'; + +dotenv.config(); +jest.setTimeout(5000); + +describe('QRLoginTokenRepository 통합 테스트', () => { + let testPool: Pool; + let repo: UserRepository; + + const TEST_DATA = { + USER_ID: 1, + }; + + beforeAll(async () => { + const testPoolConfig: pg.PoolConfig = { + database: process.env.DATABASE_NAME, + user: process.env.POSTGRES_USER, + host: process.env.POSTGRES_HOST, + password: process.env.POSTGRES_PASSWORD, + port: Number(process.env.POSTGRES_PORT), + max: 1, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + allowExitOnIdle: false, + statement_timeout: 30000, + }; + + if (process.env.POSTGRES_HOST !== 'localhost') { + testPoolConfig.ssl = { rejectUnauthorized: false }; + } + + testPool = new Pool(testPoolConfig); + + await testPool.query('SELECT 1'); + logger.info('테스트 DB 연결 성공'); + + repo = new UserRepository(testPool); + }); + + afterAll(async () => { + try { + await testPool.query( + ` + DELETE FROM users_qrlogintoken + WHERE ip_address = '127.0.0.1' + AND user_agent = 'test-agent' + AND user_id = $1 + `, + [TEST_DATA.USER_ID] + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + if (testPool) { + await testPool.end(); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + logger.info('테스트 DB 연결 종료 및 테스트 데이터 정리 완료'); + } catch (error) { + logger.error('테스트 종료 중 오류:', error); + } + }); + + describe('QR 토큰 생성 및 조회', () => { + it('QR 토큰을 생성하고 정상 조회할 수 있어야 한다', async () => { + const token = generateRandomToken(); + const ip = '127.0.0.1'; + const userAgent = 'test-agent'; + + await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent); + const foundToken = await repo.findQRLoginToken(token); + + // 토큰이 존재함을 확인하고 타입 단언 + expect(foundToken).not.toBeNull(); + const nonNullToken = foundToken as NonNullable; + + expect(nonNullToken.token).toBe(token); + expect(nonNullToken.is_used).toBe(false); + expect(new Date(nonNullToken.expires_at).getTime()).toBeGreaterThan(new Date(nonNullToken.created_at).getTime()); + }); + + it('존재하지 않는 토큰 조회 시 null을 반환해야 한다', async () => { + const invalidToken = generateRandomToken(); + const result = await repo.findQRLoginToken(invalidToken); + + expect(result).toBeNull(); + }); + }); + + describe('QR 토큰 사용 처리', () => { + it('QR 토큰을 사용 처리한 후 조회되지 않아야 한다', async () => { + const token = generateRandomToken(); + const ip = '127.0.0.1'; + const userAgent = 'test-agent'; + + await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent); + await repo.markTokenUsed(token); + + const found = await repo.findQRLoginToken(token); + + expect(found).toBeNull(); + }); + }); + + describe('QR 토큰 만료 처리', () => { + it('만료된 토큰은 조회되지 않아야 한다', async () => { + const token = generateRandomToken(); + const ip = '127.0.0.1'; + const userAgent = 'test-agent'; + + await testPool.query( + ` + INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent) + VALUES ($1, $2, NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '5 minutes', false, $3, $4) + `, + [token, TEST_DATA.USER_ID, ip, userAgent] + ); + + const found = await repo.findQRLoginToken(token); + + expect(found).toBeNull(); + }); + + it('만료되고 사용된 토큰도 조회되지 않아야 한다', async () => { + const token = generateRandomToken(); + const ip = '127.0.0.1'; + const userAgent = 'test-agent'; + + await testPool.query( + ` + INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent) + VALUES ($1, $2, NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '5 minutes', true, $3, $4) + `, + [token, TEST_DATA.USER_ID, ip, userAgent] + ); + + const found = await repo.findQRLoginToken(token); + + expect(found).toBeNull(); + }); + }); +}); diff --git a/src/repositories/__test__/qr.repo.test.ts b/src/repositories/__test__/qr.repo.test.ts new file mode 100644 index 0000000..184fb85 --- /dev/null +++ b/src/repositories/__test__/qr.repo.test.ts @@ -0,0 +1,80 @@ +import { UserRepository } from '@/repositories/user.repository'; +import { DBError } from '@/exception'; +import { Pool } from 'pg'; + +const mockPool: Partial = { + query: jest.fn(), +}; + +describe('QRLoginTokenRepository', () => { + let repo: UserRepository; + + beforeEach(() => { + repo = new UserRepository(mockPool as Pool); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createQRLoginToken', () => { + it('QR 토큰을 성공적으로 삽입해야 한다', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce(undefined); + + await expect( + repo.createQRLoginToken('token', 1, 'ip', 'agent') + ).resolves.not.toThrow(); + + expect(mockPool.query).toHaveBeenCalled(); + }); + + it('삽입 중 오류 발생 시 DBError를 던져야 한다', async () => { + (mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail')); + + await expect( + repo.createQRLoginToken('token', 1, 'ip', 'agent') + ).rejects.toThrow(DBError); + }); + }); + + describe('findQRLoginToken', () => { + it('토큰이 존재할 경우 반환해야 한다', async () => { + const mockTokenData = { token: 'token', user: 1 }; + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [mockTokenData] }); + + const result = await repo.findQRLoginToken('token'); + expect(result).toEqual(mockTokenData); + }); + + it('토큰이 존재하지 않으면 null을 반환해야 한다', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); + + const result = await repo.findQRLoginToken('token'); + expect(result).toBeNull(); + }); + + it('조회 중 오류 발생 시 DBError를 던져야 한다', async () => { + (mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail')); + + await expect(repo.findQRLoginToken('token')).rejects.toThrow(DBError); + }); + }); + + describe('markTokenUsed', () => { + it('토큰을 사용 처리해야 한다', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce(undefined); + + await expect(repo.markTokenUsed('token')).resolves.not.toThrow(); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE users_qrlogintoken SET is_used = true'), + ['token'] + ); + }); + + it('토큰 사용 처리 중 오류 발생 시 DBError를 던져야 한다', async () => { + (mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail')); + + await expect(repo.markTokenUsed('token')).rejects.toThrow(DBError); + }); + }); +}); diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index a862aff..76c4655 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -1,10 +1,11 @@ import { Pool } from 'pg'; import logger from '@/configs/logger.config'; import { User } from '@/types'; +import { QRLoginToken } from "@/types/models/QRLoginToken.type"; import { DBError } from '@/exception'; export class UserRepository { - constructor(private readonly pool: Pool) { } + constructor(private readonly pool: Pool) {} async findByUserVelogUUID(uuid: string): Promise { try { @@ -91,4 +92,44 @@ export class UserRepository { throw new DBError('유저 생성 중 문제가 발생했습니다.'); } } + + async createQRLoginToken(token: string, userId: number, ip: string, userAgent: string): Promise { + try { + const query = ` + INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent) + VALUES ($1, $2, NOW(), NOW() + INTERVAL '5 minutes', false, $3, $4); + `; + await this.pool.query(query, [token, userId, ip, userAgent]); + } catch (error) { + logger.error('QRLoginToken Repo Create Error : ', error); + throw new DBError('QR 코드 토큰 생성 중 문제가 발생했습니다.'); + } + } + + async findQRLoginToken(token: string): Promise { + try { + const query = ` + SELECT * + FROM users_qrlogintoken + WHERE token = $1 AND is_used = false AND expires_at > NOW(); + `; + const result = await this.pool.query(query, [token]); + return result.rows[0] ?? null; + } catch (error) { + logger.error('QRLoginToken Repo find QR Code Error : ', error); + throw new DBError('QR 코드 토큰 조회 중 문제가 발생했습니다.'); + } + } + + async markTokenUsed(token: string): Promise { + try { + const query = ` + UPDATE users_qrlogintoken SET is_used = true WHERE token = $1; + `; + await this.pool.query(query, [token]); + } catch (error) { + logger.error('QRLoginToken Repo mark as used Error : ', error); + throw new DBError('QR 코드 사용 처리 중 문제가 발생했습니다.'); + } + } } diff --git a/src/routes/index.ts b/src/routes/index.ts index a2c1a7a..bcd21bc 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -14,4 +14,5 @@ router.use('/', UserRouter); router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); + export default router; diff --git a/src/routes/user.router.ts b/src/routes/user.router.ts index 7f30846..a841540 100644 --- a/src/routes/user.router.ts +++ b/src/routes/user.router.ts @@ -115,4 +115,41 @@ router.post('/logout', authMiddleware.verify, userController.logout); */ router.get('/me', authMiddleware.login, userController.fetchCurrentUser); +/** + * @swagger + * /qr-login: + * post: + * summary: QR 로그인 토큰 생성 + * tags: [QRLogin] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: QR 로그인 토큰 생성 성공 + */ +router.post('/qr-login', authMiddleware.login, userController.createToken); + +/** + * @swagger + * /qr-login: + * get: + * summary: QR 로그인 토큰 조회 및 자동 로그인 처리 + * tags: [QRLogin] + * parameters: + * - in: query + * name: token + * required: true + * schema: + * type: string + * description: 조회할 QR 토큰 + * responses: + * 302: + * description: 자동 로그인 완료 후 메인 페이지로 리디렉션 + * 400: + * description: 잘못된 토큰 + * 404: + * description: 만료 또는 존재하지 않는 토큰 + */ +router.get('/qr-login', userController.getToken); + export default router; diff --git a/src/services/__test__/qr.service.test.ts b/src/services/__test__/qr.service.test.ts new file mode 100644 index 0000000..9315fac --- /dev/null +++ b/src/services/__test__/qr.service.test.ts @@ -0,0 +1,97 @@ +import { UserService } from '@/services/user.service'; +import { UserRepository } from '@/repositories/user.repository'; +import { DBError } from '@/exception'; +import { QRLoginToken } from '@/types/models/QRLoginToken.type'; +import { Pool } from 'pg'; +import crypto from 'crypto'; + +const validKey = crypto.randomBytes(32).toString('utf8'); + +jest.mock('@/utils/key.util', () => ({ + getKeyByGroup: () => validKey, +})); + +jest.mock('@/modules/slack/slack.notifier', () => ({ + sendSlackMessage: jest.fn(), +})); + +jest.mock('@/repositories/user.repository'); + +describe('UserService 의 QRService', () => { + let service: UserService; + let repo: jest.Mocked; + + beforeEach(() => { + const mockPool = {} as jest.Mocked; + const repoInstance = new UserRepository(mockPool); + repo = repoInstance as jest.Mocked; + service = new UserService(repo); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('QR 토큰을 생성하고 반환해야 한다', async () => { + const userId = 1; + const ip = '127.0.0.1'; + const userAgent = 'Chrome'; + + const token = await service.create(userId, ip, userAgent); + + expect(typeof token).toBe('string'); + expect(token.length).toBe(10); + expect(/^[A-Za-z0-9\-_.~!]{10}$/.test(token)).toBe(true); + expect(repo.createQRLoginToken).toHaveBeenCalledWith(token, userId, ip, userAgent); + }); + + it('QR 토큰 생성 중 오류 발생 시 예외 발생', async () => { + repo.createQRLoginToken.mockRejectedValueOnce(new DBError('생성 실패')); + + await expect(service.create(1, 'ip', 'agent')).rejects.toThrow('생성 실패'); + }); + }); + + describe('useToken', () => { + it('유효한 토큰 사용 처리 후 반환', async () => { + const mockToken: QRLoginToken = { + token: 'token', + user: 1, + created_at: new Date(), + expires_at: new Date(), + is_used: false, + ip_address: '127.0.0.1', + user_agent: 'Chrome', + }; + repo.findQRLoginToken.mockResolvedValue(mockToken); + + const result = await service.useToken('token'); + + expect(result).toEqual(mockToken); + expect(repo.markTokenUsed).toHaveBeenCalledWith('token'); + }); + + it('토큰이 존재하지 않으면 null 반환', async () => { + repo.findQRLoginToken.mockResolvedValue(null); + const result = await service.useToken('token'); + expect(result).toBeNull(); + }); + + it('markTokenUsed 호출 시 예외 발생하면 전파', async () => { + const mockToken: QRLoginToken = { + token: 'token', + user: 1, + created_at: new Date(), + expires_at: new Date(), + is_used: false, + ip_address: '127.0.0.1', + user_agent: 'Chrome', + }; + repo.findQRLoginToken.mockResolvedValue(mockToken); + repo.markTokenUsed.mockRejectedValueOnce(new DBError('사용 처리 실패')); + + await expect(service.useToken('token')).rejects.toThrow('사용 처리 실패'); + }); + }); +}); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 6e0ff1d..13e4073 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -6,6 +6,8 @@ import { sendSlackMessage } from '@/modules/slack/slack.notifier'; import { UserRepository } from '@/repositories/user.repository'; import { UserWithTokenDto, User, SampleUser } from '@/types'; import { generateRandomGroupId } from '@/utils/generateGroupId.util'; +import { QRLoginToken } from "@/types/models/QRLoginToken.type"; +import { generateRandomToken } from '@/utils/generateRandomToken.util'; export class UserService { constructor(private userRepo: UserRepository) { } @@ -51,7 +53,7 @@ export class UserService { async handleUserTokensByVelogUUID(userData: UserWithTokenDto) { const { id, email, accessToken, refreshToken } = userData; try { - let user = await this.findByVelogUUID(id); + let user = await this.userRepo.findByUserVelogUUID(id); if (!user) { user = await this.createUser({ @@ -80,10 +82,6 @@ export class UserService { } } - async findByVelogUUID(uuid: string): Promise { - return await this.userRepo.findByUserVelogUUID(uuid); - } - async findSampleUser(): Promise { const user = await this.userRepo.findSampleUser(); if (!user) { @@ -122,4 +120,36 @@ export class UserService { async updateUserTokens(userData: UserWithTokenDto) { return await this.userRepo.updateTokens(userData.id, userData.accessToken, userData.refreshToken); } + + async findUserAndTokensByVelogUUID(uuid: string): Promise<{ user: User; decryptedAccessToken: string; decryptedRefreshToken: string }> { + const user = await this.userRepo.findByUserVelogUUID(uuid); + if (!user) { + throw new NotFoundError('유저를 찾을 수 없습니다.'); + } + + const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens( + user.group_id, + user.access_token, + user.refresh_token, + ); + + return { user, decryptedAccessToken, decryptedRefreshToken }; + } + + async create(userId: number, ip: string, userAgent: string): Promise { + const token = generateRandomToken(10); + await this.userRepo.createQRLoginToken(token, userId, ip, userAgent); + return token; + } + + async useToken(token: string): Promise { + const qrToken = await this.userRepo.findQRLoginToken(token); + + if (!qrToken) { + return null; + } + + await this.userRepo.markTokenUsed(token); + return qrToken; + } } diff --git a/src/types/dto/responses/qrResponse.type.ts b/src/types/dto/responses/qrResponse.type.ts new file mode 100644 index 0000000..1a8a652 --- /dev/null +++ b/src/types/dto/responses/qrResponse.type.ts @@ -0,0 +1,35 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; + +/** + * @swagger + * components: + * schemas: + * QRLoginTokenResponseData: + * type: object + * properties: + * token: + * type: string + * minLength: 10 + * maxLength: 10 + * example: ABC123EFGH + * description: QR 로그인용 10자리 토큰 + */ +type Token10 = string & { __lengthBrand: 10 }; + +export interface QRLoginTokenResponseData { + token: Token10; +} + +/** + * @swagger + * components: + * schemas: + * QRLoginTokenResponseDto: + * allOf: + * - $ref: '#/components/schemas/BaseResponseDto' + * - type: object + * properties: + * data: + * $ref: '#/components/schemas/QRLoginTokenResponseData' + */ +export class QRLoginTokenResponseDto extends BaseResponseDto {} diff --git a/src/types/models/QRLoginToken.type.ts b/src/types/models/QRLoginToken.type.ts new file mode 100644 index 0000000..1e3930b --- /dev/null +++ b/src/types/models/QRLoginToken.type.ts @@ -0,0 +1,9 @@ +export interface QRLoginToken { + token: string; + user: number; + is_used: boolean; + ip_address: string; + user_agent: string; + created_at: Date; + expires_at: Date; +} diff --git a/src/utils/generateRandomToken.util.ts b/src/utils/generateRandomToken.util.ts new file mode 100644 index 0000000..67bb3b1 --- /dev/null +++ b/src/utils/generateRandomToken.util.ts @@ -0,0 +1,15 @@ +import crypto from 'crypto'; + +const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~!'; +const CHARSET_LENGTH = CHARSET.length; + +export function generateRandomToken(length: number = 10): string { + const randomBytes = crypto.randomBytes(length); + const result = new Array(length); + + for (let i = 0; i < length; i++) { + result[i] = CHARSET[randomBytes[i] % CHARSET_LENGTH]; + } + + return result.join(''); +} \ No newline at end of file