From 3a6a311a61921fcd416ff4ee06a43cdd4d3ec17d Mon Sep 17 00:00:00 2001 From: DoHeonLim Date: Mon, 29 Jun 2026 18:30:28 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20Security=20:=20SMS=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [๐Ÿ“ฑ SMS] - SMS ๋กœ๊ทธ์ธ ํ† ํฐ์ด ์ธ์ฆ ์ „ User.phone์„ ์ ์œ ํ•˜์ง€ ์•Š๋„๋ก ์ •๋ฆฌ - SMS ๋กœ๊ทธ์ธ ํ† ํฐ๊ณผ ํ”„๋กœํ•„ ์ „ํ™”๋ฒˆํ˜ธ ์ธ์ฆ ํ† ํฐ์˜ userId ๋ชฉ์  ๋ถ„๋ฆฌ - ํ”„๋กœํ•„ ์ธ์ฆ ํ† ํฐ์ด SMS ๋กœ๊ทธ์ธ ๊ฒ€์ฆ์—์„œ ์†Œ๋น„๋˜์ง€ ์•Š๋„๋ก ์ฐจ๋‹จ - ๋กœ๊ทธ์ธ SMS ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์ด์ „ ํ† ํฐ์˜ userId๊นŒ์ง€ ์กฐ๊ฑด๋ถ€ ๋ณต๊ตฌ [๐Ÿ—„๏ธ Database] - SMSToken.userId nullable ์ „ํ™˜ migration ์ถ”๊ฐ€ - ๋กœ๊ทธ์ธ SMS ํ† ํฐ์€ userId ์—†์ด ์ €์žฅํ•˜๊ณ , ๊ฒ€์ฆ ์„ฑ๊ณต ์‹œ ์ „ํ™”๋ฒˆํ˜ธ ๊ธฐ์ค€ User๋ฅผ ์กฐํšŒ ๋˜๋Š” ์ƒ์„ฑ [๐Ÿ” Rate Limit] - ์˜ค๋ž˜๋œ rate limit ๊ธฐ๋ก ์‚ญ์ œ๋ฅผ advisory lock transaction ๋ฐ–์œผ๋กœ ๋ถ„๋ฆฌ - PostgreSQL transaction ๋‚ด๋ถ€ ์‚ญ์ œ ์‹คํŒจ๊ฐ€ ์ดํ›„ ์กฐํšŒ/๊ธฐ๋ก์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ์ •๋ฆฌ - rate limit ํŒ๋‹จ transaction์€ lock, ์ตœ๊ทผ ์š”์ฒญ ์กฐํšŒ, ํ˜„์žฌ ์š”์ฒญ ๊ธฐ๋ก๋งŒ ์ˆ˜ํ–‰ํ•˜๋„๋ก ๋‹จ์ˆœํ™” [โ˜”๏ธ Test] - SMS ์ธ์ฆ ์ „ User ์ƒ์„ฑ ๋ฐฉ์ง€ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - ํ”„๋กœํ•„ ํ† ํฐ userId ์ž”์กด ๋ฐ ๋กœ๊ทธ์ธ ๋ชฉ์  ํ˜ผ์šฉ ๋ฐฉ์ง€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - SMS ๋ฐœ์†ก ์‹คํŒจ rollback ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ๋ณด๊ฐ• [โœ… Verification] - npx prisma generate - npm run test -- features/auth/service/sms.test.ts features/auth/service/rateLimit.test.ts features/user/service/phone.test.ts - npx tsc --noEmit - npm run lint - npm run test - npx prisma validate - git diff --check --- features/auth/service/rateLimit.ts | 31 ++--- features/auth/service/sms.test.ts | 111 ++++++++++++++++++ features/auth/service/sms.ts | 35 ++++-- .../migration.sql | 2 + prisma/schema.prisma | 5 +- 5 files changed, 155 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql diff --git a/features/auth/service/rateLimit.ts b/features/auth/service/rateLimit.ts index b68425a..ca06565 100644 --- a/features/auth/service/rateLimit.ts +++ b/features/auth/service/rateLimit.ts @@ -8,6 +8,7 @@ * 2026.06.27 ์ž„๋„ํ—Œ Created ํšŒ์›๊ฐ€์ž… IP hash ๊ธฐ๋ฐ˜ ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ ์ถ”๊ฐ€ * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ๋ฐœ์†ก IP hash ๊ธฐ๋ฐ˜ ์‹œ๊ฐ„๋‹น ์ œํ•œ ์ถ”๊ฐ€ * 2026.06.27 ์ž„๋„ํ—Œ Modified kind/keyHash ๋‹จ์œ„ transaction advisory lock ์ ์šฉ + * 2026.06.29 ์ž„๋„ํ—Œ Modified stale event cleanup์„ advisory lock transaction ๋ฐ–์œผ๋กœ ๋ถ„๋ฆฌ */ import "server-only"; @@ -82,25 +83,27 @@ async function checkAndRecordAuthRateLimitEvent( const keyHash = hashRateLimitKey(input.key); if (!keyHash) return { allowed: true }; + const windowStart = new Date(now.getTime() - input.windowMs); + + try { + await db.authRateLimitEvent.deleteMany({ + where: { + kind: input.kind, + created_at: { lt: windowStart }, + }, + }); + } catch (error) { + console.warn("[auth rate limit] stale event cleanup failed:", error); + } + return db.$transaction(async (tx) => { - // ๊ฐ™์€ ์ •์ฑ…/์‹๋ณ„์ž์— ๋Œ€ํ•œ check-and-record ๊ฒฝ์Ÿ์„ DB transaction ๋‹จ์œ„๋กœ ์ง๋ ฌํ™” + // PostgreSQL advisory lock์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์ •ํ•œ ์ˆซ์ž key๋กœ ์žก๋Š” DB ์ž ๊ธˆ์ด๋‹ค. + // ์—ฌ๊ธฐ์„œ๋Š” ๊ฐ™์€ kind/keyHash ์š”์ฒญ๋งŒ ํ•œ ์ค„๋กœ ์„ธ์›Œ, ๋™์‹œ์— limit์„ ํ†ต๊ณผํ•˜๊ณ  + // ๊ฐ๊ฐ ๊ธฐ๋ก๋˜๋Š” check-and-record ๊ฒฝ์Ÿ์„ ๋ง‰๋Š”๋‹ค. await tx.$executeRaw` SELECT pg_advisory_xact_lock(hashtext(${`${input.kind}:${keyHash}`})) `; - const windowStart = new Date(now.getTime() - input.windowMs); - - try { - await tx.authRateLimitEvent.deleteMany({ - where: { - kind: input.kind, - created_at: { lt: windowStart }, - }, - }); - } catch (error) { - console.warn("[auth rate limit] stale event cleanup failed:", error); - } - const recentAttempts = await tx.authRateLimitEvent.findMany({ where: { kind: input.kind, diff --git a/features/auth/service/sms.test.ts b/features/auth/service/sms.test.ts index 66624b6..d2eeddb 100644 --- a/features/auth/service/sms.test.ts +++ b/features/auth/service/sms.test.ts @@ -6,6 +6,7 @@ * History * Date Author Status Description * 2026.06.27 ์ž„๋„ํ—Œ Created SMS ๋งŒ๋ฃŒ/์ฟจ๋‹ค์šด/๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + * 2026.06.29 ์ž„๋„ํ—Œ Modified SMS ์ธ์ฆ ์ „ User.phone ์ ์œ , userId ์ž”์กด, ๋ชฉ์  ํ˜ผ์šฉ ๋ฐฉ์ง€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({ }, user: { update: vi.fn(), + upsert: vi.fn(), }, }, sendSMS: vi.fn(), @@ -58,6 +60,12 @@ describe("SMS verification service", () => { mocks.db.sMSToken.create.mockResolvedValue({ id: 1 }); mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 1 }); mocks.db.sMSToken.delete.mockResolvedValue({ id: 1 }); + mocks.db.user.upsert.mockResolvedValue({ + id: 10, + phone: "01012345678", + bannedAt: null, + bannedUntil: null, + }); mocks.sendSMS.mockResolvedValue(undefined); mocks.generateUniqueSmsToken.mockResolvedValue("654321"); mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({ @@ -138,6 +146,54 @@ describe("SMS verification service", () => { expect(mocks.sendSMS).not.toHaveBeenCalled(); }); + it("์ตœ์ดˆ SMS ๋ฐœ์†ก์€ ์ธ์ฆ ์ „ User๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๊ณ  ํ† ํฐ๋งŒ ์ €์žฅํ•œ๋‹ค", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue(null); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ success: true }); + expect(mocks.db.sMSToken.create).toHaveBeenCalledWith({ + data: { + token: "654321", + phone: "01012345678", + expires_at: new Date("2026-06-27T00:10:00.000Z"), + }, + }); + expect(mocks.db.user.upsert).not.toHaveBeenCalled(); + }); + + it("๊ธฐ์กด ํ”„๋กœํ•„ ์ธ์ฆ ํ† ํฐ์„ ๋กœ๊ทธ์ธ SMS๋กœ ๊ฐฑ์‹ ํ•  ๋•Œ userId ์—ฐ๊ฒฐ์„ ๋Š๋Š”๋‹ค", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + token: "123456", + phone: "01012345678", + userId: 10, + created_at: new Date("2026-06-26T23:58:00.000Z"), + expires_at: new Date("2026-06-27T00:08:00.000Z"), + }); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ success: true }); + expect(mocks.db.sMSToken.updateMany).toHaveBeenCalledWith({ + where: { + id: 1, + created_at: { lte: new Date("2026-06-26T23:59:00.000Z") }, + }, + data: { + token: "654321", + phone: "01012345678", + userId: null, + created_at: new Date("2026-06-27T00:00:00.000Z"), + expires_at: new Date("2026-06-27T00:10:00.000Z"), + }, + }); + }); + it("SMS ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์ด์ „ ์œ ํšจ ํ† ํฐ์„ ๋ณต๊ตฌํ•œ๋‹ค", async () => { const { createAndSendSmsToken } = await import("./sms"); @@ -169,6 +225,7 @@ describe("SMS verification service", () => { data: { token: previous.token, phone: previous.phone, + userId: previous.userId, created_at: previous.created_at, expires_at: previous.expires_at, }, @@ -218,4 +275,58 @@ describe("SMS verification service", () => { where: { id: 1 }, }); }); + + it("SMS ์ธ์ฆ ์„ฑ๊ณต ์‹œ ์ „ํ™”๋ฒˆํ˜ธ ๊ธฐ์ค€ User๋ฅผ ์ฐพ๊ฑฐ๋‚˜ ์ƒ์„ฑํ•œ ๋’ค ๋กœ๊ทธ์ธ ID๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค", async () => { + const { verifySmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + userId: null, + phone: "01012345678", + expires_at: new Date("2026-06-27T00:10:00.000Z"), + user: null, + }); + + const result = await verifySmsToken("01012345678", "123456"); + + expect(mocks.db.user.upsert).toHaveBeenCalledWith({ + where: { phone: "01012345678" }, + update: {}, + create: { + username: expect.stringMatching(/^user_[0-9a-f]{8}$/), + phone: "01012345678", + }, + select: { id: true, phone: true, bannedAt: true, bannedUntil: true }, + }); + expect(mocks.db.sMSToken.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + expect(result).toEqual({ success: true, data: { userId: 10 } }); + }); + + it("ํ”„๋กœํ•„ ์ธ์ฆ ํ† ํฐ์€ SMS ๋กœ๊ทธ์ธ ๊ฒ€์ฆ์—์„œ ์†Œ๋น„ํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + const { verifySmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + userId: 20, + phone: "01012345678", + expires_at: new Date("2026-06-27T00:10:00.000Z"), + user: { + id: 20, + phone: "01012345678", + bannedAt: null, + bannedUntil: null, + }, + }); + + const result = await verifySmsToken("01012345678", "123456"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_VERIFY_FAILED, + }); + expect(mocks.db.user.upsert).not.toHaveBeenCalled(); + expect(mocks.db.sMSToken.delete).not.toHaveBeenCalled(); + }); }); diff --git a/features/auth/service/sms.ts b/features/auth/service/sms.ts index 093b13e..27bc1ed 100644 --- a/features/auth/service/sms.ts +++ b/features/auth/service/sms.ts @@ -11,6 +11,7 @@ * 2026.02.08 ์ž„๋„ํ—Œ Modified ๋กœ๊ทธ์ธ ์‹œ ์ •์ง€(Ban) ์ฒดํฌ ๋ฐ ๋งŒ๋ฃŒ ์‹œ ์ž๋™ ํ•ด์ œ ๋กœ์ง ์ถ”๊ฐ€ * 2026.04.04 ์ž„๋„ํ—Œ Modified SMS ํ† ํฐ ๋ฐœ๊ธ‰/์†Œ๋ชจ ๋‹จ๊ณ„์˜ ์ธ๋ผ์ธ ์ฃผ์„ ๋ณด๊ฐ• * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ํ† ํฐ TTL, ์žฌ์ „์†ก/IP ์ฟจ๋‹ค์šด, ๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€ + * 2026.06.29 ์ž„๋„ํ—Œ Modified SMS ๋กœ๊ทธ์ธ ํ† ํฐ์˜ ์ธ์ฆ ์ „ User ์ƒ์„ฑ ๋ฐฉ์ง€, userId ์ž”์กด, ๋ชฉ์  ํ˜ผ์šฉ ๋ฐฉ์ง€ */ import "server-only"; @@ -96,6 +97,7 @@ export async function createAndSendSmsToken( data: { token, phone, + userId: null, created_at: now, expires_at: expiresAt, }, @@ -109,21 +111,12 @@ export async function createAndSendSmsToken( }; } } else { - // ํ† ํฐ ์ €์žฅ ๋ฐ phone ๊ธฐ์ค€ ์ž„์‹œ ๊ณ„์ • ์—ฐ๊ฒฐ + // ์ธ์ฆ ์ „์—๋Š” User.phone์„ ์ ์œ ํ•˜์ง€ ์•Š๊ณ  ๋ฐœ์†ก ํ† ํฐ๋งŒ ์ €์žฅ await db.sMSToken.create({ data: { token, phone, expires_at: expiresAt, - user: { - connectOrCreate: { - where: { phone }, - create: { - username: `user_${crypto.randomBytes(4).toString("hex")}`, - phone, - }, - }, - }, }, }); createdNewToken = true; @@ -143,6 +136,7 @@ export async function createAndSendSmsToken( data: { token: previousToken.token, phone: previousToken.phone, + userId: previousToken.userId, created_at: previousToken.created_at, expires_at: previousToken.expires_at, }, @@ -195,7 +189,7 @@ export async function verifySmsToken( phone: true, expires_at: true, user: { - select: { id: true, bannedAt: true, bannedUntil: true }, + select: { id: true, phone: true, bannedAt: true, bannedUntil: true }, }, }, }); @@ -210,7 +204,22 @@ export async function verifySmsToken( return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED }; } - const user = verifiedToken.user; + // ํ”„๋กœํ•„ ์ „ํ™”๋ฒˆํ˜ธ ๋ณ€๊ฒฝ์šฉ ํ† ํฐ์€ ๋กœ๊ทธ์ธ ๊ฒ€์ฆ์—์„œ ์†Œ๋น„ํ•˜์ง€ ์•Š์Œ + if (verifiedToken.userId !== null) { + return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED }; + } + + const user = + verifiedToken.user ?? + (await db.user.upsert({ + where: { phone }, + update: {}, + create: { + username: `user_${crypto.randomBytes(4).toString("hex")}`, + phone, + }, + select: { id: true, phone: true, bannedAt: true, bannedUntil: true }, + })); // ์ •์ง€ ์ƒํƒœ ํ™•์ธ ๋ฐ ๋งŒ๋ฃŒ ์‹œ ์ง€์—ฐ ํ•ด์ œ if (user.bannedAt) { @@ -233,5 +242,5 @@ export async function verifySmsToken( // ๊ฒ€์ฆ ์„ฑ๊ณต ํ›„ ํ† ํฐ 1ํšŒ ์†Œ๋ชจ await db.sMSToken.delete({ where: { id: verifiedToken.id } }); - return { success: true, data: { userId: verifiedToken.userId } }; + return { success: true, data: { userId: user.id } }; } diff --git a/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql b/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql new file mode 100644 index 0000000..8f02183 --- /dev/null +++ b/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql @@ -0,0 +1,2 @@ +-- SMS login tokens should not reserve User.phone before verification succeeds. +ALTER TABLE "SMSToken" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1627f91..37fd1d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -75,6 +75,7 @@ * 2026.04.29 ์ž„๋„ํ—Œ Modified ๋ณด๋“œ๊ฒŒ์ž„ ํ•œ๊ตญ์–ด ๊ฒ€์ˆ˜์ž ๊ด€๊ณ„์™€ taxonomy slug ๊ณ ์œ  ์ œ์•ฝ ์ถ”๊ฐ€ * 2026.04.29 ์ž„๋„ํ—Œ Modified ๋ณด๋“œ๊ฒŒ์ž„ ์นดํƒˆ๋กœ๊ทธ ๋„๋ฉ”์ธ ๋ชจ๋ธ/ํ•„๋“œ ์ฃผ์„ ๋ณด๊ฐ• * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ์ธ์ฆ๋ฒˆํ˜ธ ๋งŒ๋ฃŒ ์‹œ๊ฐ ๋ฐ ์ธ์ฆ rate limit ์ด๋ฒคํŠธ ๋ชจ๋ธ ์ถ”๊ฐ€ + * 2026.06.29 ์ž„๋„ํ—Œ Modified SMS ๋กœ๊ทธ์ธ ํ† ํฐ์˜ ์ธ์ฆ ์ „ User ์ ์œ ๋ฅผ ๋ง‰๊ธฐ ์œ„ํ•ด userId optional ์ฒ˜๋ฆฌ */ generator client { provider = "prisma-client" @@ -190,8 +191,8 @@ model SMSToken { updated_at DateTime @updatedAt expires_at DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @@index([expires_at]) }