Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6d3b0ec
feat: qrcode app ์ปจํŠธ๋กค๋Ÿฌ, ์„œ๋น„์Šค, ๋ ˆํฌ์ง€ํ† ๋ฆฌ, ํƒ€์ž… ์ถ”๊ฐ€, ๊ด€๋ จ API ๊ตฌํ˜„
Jihyun3478 Apr 20, 2025
3b6adaa
hotfix: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ ๋ฐ˜์˜
Jihyun3478 Apr 20, 2025
b70a582
modify: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ํด๋ž˜์Šค ์‚ญ์ œ
Jihyun3478 Apr 21, 2025
56c2283
refactor: ๋ถˆํ•„์š”ํ•œ ์˜์กด์„ฑ ์‚ญ์ œ
Jihyun3478 Apr 25, 2025
ac39619
refactor: ๊ณต๋ฐฑ ์ œ๊ฑฐ
Jihyun3478 Apr 25, 2025
d118b16
refactor: ํ™œ์šฉ๋„ ๋‚ฎ์€ ํ•„๋“œ ์ˆœ์„œ ์ •๋ฆฌ
Jihyun3478 Apr 25, 2025
f6f3936
modify: token ๊ณ ์ • ๊ธธ์ด ๊ฐ’ ๋ช…์‹œ & exception ์žฌ์‚ฌ์šฉ
Jihyun3478 Apr 25, 2025
065879c
refactor: repository ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๋ฉ”์„œ๋“œ ๋ถ„๋ฆฌ
Jihyun3478 Apr 25, 2025
3faf173
hotfix: uuid ์‚ญ์ œ
Jihyun3478 Apr 25, 2025
a007870
hotfix: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ๋ฆ„์— ๋งž๊ฒŒ ์ˆ˜์ •
Jihyun3478 Apr 25, 2025
a95635e
refactor: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ ๋ฐ˜์˜
Jihyun3478 Apr 25, 2025
245a721
hotfix: token 36์ž๊ฐ€ ์•„๋‹Œ 10์ž๋กœ ์ˆ˜์ •
Jihyun3478 Apr 29, 2025
4fd26ac
hotfix: ์ฟผ๋ฆฌ ์ž‘์„ฑ ์‹œ qr_login_tokens๊ฐ€ ์•„๋‹Œ users_qrlogintoken์œผ๋กœ ์ˆ˜์ •
Jihyun3478 Apr 29, 2025
c7e9019
test: ๋ ˆํฌ์ง€ํ† ๋ฆฌ ํ†ตํ•ฉํ…Œ์ŠคํŠธ์ฝ”๋“œ ๊ตฌํ˜„
Jihyun3478 Apr 29, 2025
51a0162
Merge branch 'main' into feature/qrcode-app
Jihyun3478 Apr 29, 2025
5932dac
hotfix: ์ค‘๋ณต ํ† ํฐ ํ…Œ์ŠคํŠธ ํ›„ DB ์—ฐ๊ฒฐ ์ข…๋ฃŒ
Jihyun3478 Apr 29, 2025
85b502c
hotfix: ์ค‘๋ณต ํ† ํฐ ์‚ฝ์ž… ํ…Œ์ŠคํŠธ ์‚ญ์ œ
Jihyun3478 Apr 29, 2025
37b9633
modify: QRLoginToken ๋ผ์šฐํ„ฐ, ์„œ๋น„์Šค, ๋ ˆํฌ User์ชฝ์œผ๋กœ ํ•ฉ์น˜๊ธฐ
Jihyun3478 Apr 29, 2025
3440fa2
modify: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ 1์ฐจ ๋ฐ˜์˜
Jihyun3478 Apr 29, 2025
d2d127a
hotfix: process.env ๋Œ€์‹  ์ž„์˜์˜ ๋‚œ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •
Jihyun3478 Apr 30, 2025
fe68599
refactor: lint ์ ์šฉ
Jihyun3478 Apr 30, 2025
f8801e0
refactor: ๋“ค์—ฌ์“ฐ๊ธฐ ์ •๋ฆฌ
Jihyun3478 Apr 30, 2025
5613dbb
hotfix: ์Šฌ๋ž™๋„ mockingํ•˜๋„๋ก ์ˆ˜์ •
Jihyun3478 Apr 30, 2025
31b1a43
hotfix: ํŠน์ • ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ๊ฐ€ ์™„๋ฃŒ๋œ ํ›„ ์ง€์šฐ๋„๋ก ์ˆ˜์ •
Jihyun3478 May 1, 2025
6d6f918
docs: Swagger ์ฃผ์„ ์ˆ˜์ • ๋ฐ ์ถ”๊ฐ€
Jihyun3478 May 1, 2025
3ce8994
hotfix: ํ† ํฐ ์ƒ์„ฑ ๋กœ์ง ์ˆ˜์ •
Jihyun3478 May 1, 2025
5c8f0a9
hotfix: ์‹ค์ œ ํด๋ผ์ด์–ธํŠธ์˜ IP์— ์ ‘๊ทผํ•˜๋„๋ก ์ˆ˜์ • & logger ๊ตฌ์ฒดํ™”
Jihyun3478 May 1, 2025
0769497
refactor: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ ๋ฐ˜์˜
Jihyun3478 May 1, 2025
624be89
docs: swagger ๋ฌธ์„œ ์ˆ˜์ •
Jihyun3478 May 2, 2025
c20e48e
modify: service์˜ getByToken์ด ์•„๋‹Œ repo์˜ findQRLoginToken์„ ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •
Jihyun3478 May 2, 2025
b08d834
refactor: findByVelogUUID & getDecryptedTokens ๋ฉ”์„œ๋“œ ๋ณ‘ํ•ฉ
Jihyun3478 May 3, 2025
21764ee
refactor: ๋ ˆํฌ ๊ณ„์ธต๊ณผ์˜ ์ค‘๋ณต ํ…Œ์ŠคํŠธ์ฝ”๋“œ ์ œ๊ฑฐ
Jihyun3478 May 3, 2025
c2dd34a
refactor: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import & ์ฝ”๋“œ ์ œ๊ฑฐ
Jihyun3478 May 3, 2025
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
66 changes: 66 additions & 0 deletions src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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';
import { NotFoundError } from '@/exception';

type Token32 = string & { __lengthBrand: 10 };

export class UserController {
constructor(private userService: UserService) { }

Expand Down Expand Up @@ -102,4 +108,64 @@ export class UserController {

res.status(200).json(response);
};

createToken: RequestHandler = async (
req: Request,
res: Response<QRLoginTokenResponseDto>,
next: NextFunction,
) => {
try {
const user = req.user;
const ip = req.ip ?? '';
const userAgent = req.headers['user-agent'] || '';

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

const response = new QRLoginTokenResponseDto(
true,
'QR ํ† ํฐ ์ƒ์„ฑ ์™„๋ฃŒ',
{ token: typedToken },
null
);
res.status(200).json(response);
} catch (error) {
logger.error('QR ํ† ํฐ ์ƒ์„ฑ ์‹คํŒจ:', 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 user = await this.userService.findByVelogUUID(found.user.toString());
if (!user) throw new NotFoundError('์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');

const { decryptedAccessToken, decryptedRefreshToken } = this.userService.getDecryptedTokens(
user.group_id,
user.access_token,
user.refresh_token
);

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 ํ† ํฐ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์‹คํŒจ', error);
next(error);
}
};
}
133 changes: 133 additions & 0 deletions src/repositories/__test__/qr.repo.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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(30000);

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 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();
expect(foundToken?.token).toBe(token);
expect(foundToken?.is_used).toBe(false);
expect(new Date(foundToken!.expires_at).getTime()).toBeGreaterThan(new Date(foundToken!.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();
});
});
});
80 changes: 80 additions & 0 deletions src/repositories/__test__/qr.repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { UserRepository } from '@/repositories/user.repository';
import { DBError } from '@/exception';
import { Pool } from 'pg';

const mockPool: Partial<Pool> = {
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);
});
});
});
43 changes: 42 additions & 1 deletion src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
@@ -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<User> {
try {
Expand Down Expand Up @@ -91,4 +92,44 @@ export class UserRepository {
throw new DBError('์œ ์ € ์ƒ์„ฑ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
}
}

async createQRLoginToken(token: string, userId: number, ip: string, userAgent: string): Promise<void> {
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<QRLoginToken | null> {
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<void> {
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 ์ฝ”๋“œ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
}
}
}
1 change: 1 addition & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ router.use('/', UserRouter);
router.use('/', PostRouter);
router.use('/', NotiRouter);
router.use('/', LeaderboardRouter);

export default router;
37 changes: 37 additions & 0 deletions src/routes/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,41 @@ router.post('/logout', authMiddleware.verify, userController.logout);
*/
router.get('/me', authMiddleware.login, userController.fetchCurrentUser);

/**
* @swagger
* /api/qr-login:
* post:
* summary: QR ๋กœ๊ทธ์ธ ํ† ํฐ ์ƒ์„ฑ
* tags: [QRLogin]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: QR ๋กœ๊ทธ์ธ ํ† ํฐ ์ƒ์„ฑ ์„ฑ๊ณต
*/
router.post('/qr-login', authMiddleware.login, userController.createToken);

/**
* @swagger
* /api/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;
Loading