From cbcb60244b27e56170e0be7afeea695879f716cb Mon Sep 17 00:00:00 2001 From: DoHeonLim Date: Thu, 25 Jun 2026 22:13:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=9A=91=20Hotfix=20:=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=20=EC=A1=B0=ED=9A=8C=EC=9E=90=20=EC=8B=A0?= =?UTF-8?q?=EB=A2=B0=20=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [๐Ÿ”Ž Audit] /api/* ๊ฒฝ๋กœ๊ฐ€ middleware ์ธ์ฆ ๊ฐ€๋“œ๋ฅผ ํƒ€์ง€ ์•Š๋Š” ๊ตฌ์กฐ์—์„œ ๋ชฉ๋ก API๊ฐ€ ์ž์ฒด ๊ถŒํ•œ ๊ฒฝ๊ณ„๋ฅผ ์ฑ…์ž„ํ•˜๋„๋ก ์ •๋ฆฌ ๋น„๋กœ๊ทธ์ธ ์ง์ ‘ API ์š”์ฒญ์ด query viewerId๋กœ ํƒ€์ธ์˜ ๊ฐœ์ธํ™” ์กฐํšŒ์ž ์ปจํ…์ŠคํŠธ๋ฅผ ํ‰๋‚ด๋‚ผ ์ˆ˜ ์žˆ๋˜ ๊ฒฝ๋กœ ์ฐจ๋‹จ Cloudflare webhook secret ๋ˆ„๋ฝ ์‹œ production์—์„œ ๊ฒ€์ฆ์„ ๊ฑด๋„ˆ๋›ธ ์ˆ˜ ์žˆ๋˜ fail-open ๊ฐ€๋Šฅ์„ฑ ์ฐจ๋‹จ Stream/VOD ๊ฐœ์ธํ™” ์‘๋‹ต์„ query key๊ฐ€ ์ถฉ๋ถ„ํžˆ ๊ตฌ๋ถ„ํ•˜์ง€ ๋ชปํ•˜๋˜ ์บ์‹œ ์ •ํ•ฉ์„ฑ ๋ณด๊ฐ• [๐ŸŽฅ Stream] ์ŠคํŠธ๋ฆผ/๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก API์˜ viewerId query fallback ์ œ๊ฑฐ ๋ชฉ๋ก ์กฐํšŒ ๊ถŒํ•œ ๊ธฐ์ค€์„ ์„œ๋ฒ„ ์„ธ์…˜ ID๋กœ ๊ณ ์ • ๋น„๋กœ๊ทธ์ธ ๋ชฉ๋ก API ์ง์ ‘ ํ˜ธ์ถœ ์‹œ ๋นˆ ๋ชฉ๋ก์œผ๋กœ ์ข…๋ฃŒ Server Action ๋ชฉ๋ก ์กฐํšŒ๋„ ์„ธ์…˜ ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌ ์ŠคํŠธ๋ฆผ/๋‹ค์‹œ๋ณด๊ธฐ query key์— viewerId์™€ ํŒ”๋กœ์ž‰ ํ•„ํ„ฐ ๋ฐ˜์˜ ํŒ”๋กœ์šฐ ์งํ›„ ๊ธฐ๋ณธ ํŒ”๋กœ์ž‰ ์ŠคํŠธ๋ฆผ cache seed key๋ฅผ ์กฐํšŒ์ž๋ณ„ key์™€ ์ผ์น˜์‹œํ‚ด [โ˜๏ธ Webhook] Cloudflare Stream webhook secret ๋ˆ„๋ฝ ์‹œ production fail-closed ์ฒ˜๋ฆฌ Cloudflare Destination webhook secret ๋ˆ„๋ฝ ์‹œ production fail-closed ์ฒ˜๋ฆฌ ํ•ธ๋“œ์…ฐ์ดํฌ์™€ ๋นˆ body ์š”์ฒญ์€ ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์ด ๊ธฐ์กด ํ†ต๊ณผ ์ •์ฑ… ์œ ์ง€ secret ๋ˆ„๋ฝ ์ •์ฑ…์„ webhookAuth ์œ ํ‹ธ๋กœ ๋ถ„๋ฆฌ [โ˜”๏ธ Test] ์ŠคํŠธ๋ฆผ/๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก API viewerId spoof ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ ์„ธ์…˜์ด ์žˆ์„ ๋•Œ query viewerId๋ณด๋‹ค session ID๊ฐ€ ์šฐ์„ ๋˜๋Š”์ง€ ๊ฒ€์ฆ production webhook secret ๋ˆ„๋ฝ ์‹œ DB ๊ฐฑ์‹ ๊ณผ Realtime ์†ก์‹ ์ด ์‹œ์ž‘๋˜์ง€ ์•Š๋Š”์ง€ ๊ฒ€์ฆ webhook secret ์ •์ฑ… ์œ ํ‹ธ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ [โœ… Verification] ์ŠคํŠธ๋ฆผ/๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก API์˜ ๋น„๋กœ๊ทธ์ธ ๊ณต๊ฐœ ์†Œ๋น„์ฒ˜๊ฐ€ ์—†์Œ์„ grep์œผ๋กœ ํ™•์ธ npx tsc --noEmit npm run lint npm run test # 23 files / 107 tests npm run build npm run test:e2e # 7 passed / 19 skipped git diff --check --- app/(app)/(tabs)/streams/page.tsx | 33 ++--- app/api/streams/recordings/route.test.ts | 70 +++++++++++ app/api/streams/recordings/route.ts | 5 +- app/api/streams/route.test.ts | 67 ++++++++++ app/api/streams/route.ts | 5 +- app/api/webhooks/cloudflare/route.test.ts | 117 ++++++++++++++++++ app/api/webhooks/cloudflare/route.ts | 30 ++++- features/stream/actions/list.ts | 13 +- .../stream/hooks/useRecordingPagination.ts | 17 +-- features/stream/hooks/useStreamPagination.ts | 16 +-- features/stream/utils/webhookAuth.test.ts | 55 ++++++++ features/stream/utils/webhookAuth.ts | 36 ++++++ features/user/hooks/useFollowToggle.ts | 13 +- 13 files changed, 433 insertions(+), 44 deletions(-) create mode 100644 app/api/streams/recordings/route.test.ts create mode 100644 app/api/streams/route.test.ts create mode 100644 app/api/webhooks/cloudflare/route.test.ts create mode 100644 features/stream/utils/webhookAuth.test.ts create mode 100644 features/stream/utils/webhookAuth.ts diff --git a/app/(app)/(tabs)/streams/page.tsx b/app/(app)/(tabs)/streams/page.tsx index af033cb1..7ed2e2c1 100644 --- a/app/(app)/(tabs)/streams/page.tsx +++ b/app/(app)/(tabs)/streams/page.tsx @@ -44,6 +44,7 @@ * 2026.04.20 ์ž„๋„ํ—Œ Modified sm ๊ตฌ๊ฐ„ ๋ฐ์Šคํฌํ†ฑ ํ—ค๋”๊ฐ€ ๋’ค ์ฝ˜ํ…์ธ ๋ฅผ ๋น„์น˜์ง€ ์•Š๋„๋ก ๋ฐ˜ํˆฌ๋ช… ํ—ค๋”/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ผ ํ‘œ๋ฉด์„ ๋ถˆํˆฌ๋ช… ํ†ค์œผ๋กœ ์ •๋ฆฌ * 2026.05.08 ์ž„๋„ํ—Œ Modified ์ŠคํŠธ๋ฆผ ์กฐํšŒ ๋ฒ”์œ„ ํƒ€์ž…์„ StreamScope ๊ณต์šฉ ํƒ€์ž…์œผ๋กœ ๊ต์ฒด * 2026.05.17 ์ž„๋„ํ—Œ Modified prefetch ๋ฐ์ดํ„ฐ ํƒ€์ž…์„ InfiniteData๋กœ ๋ช…์‹œ + * 2026.06.25 ์ž„๋„ํ—Œ Modified ์„œ๋ฒ„ prefetch query key๋ฅผ ์กฐํšŒ์ž/ํŒ”๋กœ์ž‰ ํ•„ํ„ฐ ์Šค์ฝ”ํ”„์™€ ์ผ์น˜ํ•˜๋„๋ก ์ •๋ฆฌ */ import { Suspense } from "react"; import { Metadata } from "next"; @@ -136,30 +137,39 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) { sort: recordingSort, scope: scope === "following" ? "following" : "", }; + // ๋กœ๊ทธ์ธ ๊ฐ€๋“œ ์ดํ›„์—๋Š” viewerId๊ฐ€ ์กด์žฌํ•˜์ง€๋งŒ, ํด๋ผ์ด์–ธํŠธ ํ›…์˜ query key ์Šค์ฝ”ํ”„์™€ ๋งž์ถ”๊ธฐ ์œ„ํ•ด guest fallback์„ ์œ ์ง€ํ•œ๋‹ค. + const liveListQueryKey = queryKeys.streams.list(scope, { + ...liveQueryParams, + viewerId: viewerId ?? "guest", + }); + const recordingListQueryKey = queryKeys.streams.recordingList( + recordingSort, + { + ...recordingQueryParams, + followingOnly: scope === "following", + viewerId: viewerId ?? "guest", + } + ); const queryClient = getQueryClient(); const [, unreadCount] = await Promise.all([ // ๋ผ์ด๋ธŒ/๋‹ค์‹œ๋ณด๊ธฐ์˜ ์„œ๋กœ ๋‹ค๋ฅธ ์ฟผ๋ฆฌ ํ‚ค/fetcher์— ๋งž์ถ˜ ํ˜„์žฌ ๋ชจ๋“œ ๋ชฉ๋ก๋งŒ ์„œ๋ฒ„ ์„ ํ”„๋ฆฌํŒจ์น˜ mode === "recordings" ? queryClient.prefetchInfiniteQuery({ - queryKey: queryKeys.streams.recordingList( - recordingSort, - recordingQueryParams - ), + queryKey: recordingListQueryKey, queryFn: () => getRecordingsListAction( recordingSort, scope === "following", null, - recordingQueryParams, - viewerId + recordingQueryParams ), initialPageParam: null as number | null, }) : queryClient.prefetchInfiniteQuery({ - queryKey: queryKeys.streams.list(scope, liveQueryParams), + queryKey: liveListQueryKey, queryFn: () => - getStreamsListAction(scope, null, liveQueryParams, viewerId), + getStreamsListAction(scope, null, liveQueryParams), initialPageParam: null as number | null, }), getUnreadNotificationCount(), @@ -167,11 +177,7 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) { const prefetchData = queryClient.getQueryData< InfiniteData - >( - mode === "recordings" - ? queryKeys.streams.recordingList(recordingSort, recordingQueryParams) - : queryKeys.streams.list(scope, liveQueryParams) - ); + >(mode === "recordings" ? recordingListQueryKey : liveListQueryKey); const firstPage = prefetchData?.pages[0]; const isDataEmpty = mode === "recordings" @@ -401,4 +407,3 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) { ); } - diff --git a/app/api/streams/recordings/route.test.ts b/app/api/streams/recordings/route.test.ts new file mode 100644 index 00000000..340e4813 --- /dev/null +++ b/app/api/streams/recordings/route.test.ts @@ -0,0 +1,70 @@ +/** + * File Name : app/api/streams/recordings/route.test.ts + * Description : ๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก API ๊ถŒํ•œ ๊ฒฝ๊ณ„ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.25 ์ž„๋„ํ—Œ Created URL viewerId๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ์•Š๋Š” ์„ธ์…˜ ๊ธฐ์ค€ ์กฐํšŒ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getRecordingsList: vi.fn(), +})); + +vi.mock("@/lib/session", () => ({ + default: mocks.getSession, +})); + +vi.mock("@/features/stream/service/list", () => ({ + getRecordingsList: mocks.getRecordingsList, +})); + +describe("GET /api/streams/recordings", () => { + beforeEach(() => { + mocks.getSession.mockReset(); + mocks.getRecordingsList.mockReset(); + }); + + it("๋น„๋กœ๊ทธ์ธ ์š”์ฒญ์˜ viewerId query๋ฅผ ์กฐํšŒ์ž ๊ถŒํ•œ์œผ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + const { GET } = await import("./route"); + const request = new NextRequest( + "http://localhost/api/streams/recordings?followingOnly=true&viewerId=123" + ); + + mocks.getSession.mockResolvedValue(null); + + const response = await GET(request); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + recordings: [], + nextCursor: null, + }); + expect(mocks.getRecordingsList).not.toHaveBeenCalled(); + }); + + it("์„ธ์…˜์ด ์žˆ์œผ๋ฉด query viewerId๋ณด๋‹ค ์„ธ์…˜ ID๋ฅผ ์šฐ์„ ํ•œ๋‹ค", async () => { + const { GET } = await import("./route"); + const request = new NextRequest( + "http://localhost/api/streams/recordings?followingOnly=true&viewerId=123&cursor=50" + ); + + mocks.getSession.mockResolvedValue({ id: 7 }); + mocks.getRecordingsList.mockResolvedValue([]); + + await GET(request); + + expect(mocks.getRecordingsList).toHaveBeenCalledWith( + expect.objectContaining({ + followingOnly: true, + viewerId: 7, + cursor: 50, + }) + ); + }); +}); diff --git a/app/api/streams/recordings/route.ts b/app/api/streams/recordings/route.ts index 402c6989..41633d37 100644 --- a/app/api/streams/recordings/route.ts +++ b/app/api/streams/recordings/route.ts @@ -6,6 +6,7 @@ * History * Date Author Status Description * 2026.05.19 ์ž„๋„ํ—Œ Created Client queryFn์—์„œ ์กฐํšŒ์šฉ Server Action์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜์ง€ ์•Š๋„๋ก ๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก ์กฐํšŒ API ๋ถ„๋ฆฌ + * 2026.06.25 ์ž„๋„ํ—Œ Modified URL viewerId fallback ์ œ๊ฑฐ ๋ฐ ์„ธ์…˜ ๊ธฐ์ค€ ์กฐํšŒ์ž ๊ถŒํ•œ ๊ณ ์ • */ import { NextRequest, NextResponse } from "next/server"; @@ -52,8 +53,8 @@ export async function GET(request: NextRequest) { const sort: RecordingSort = searchParams.get("sort") === "popular" ? "popular" : "latest"; const followingOnly = searchParams.get("followingOnly") === "true"; - const viewerId = - session?.id ?? parseNullableNumberParam(searchParams.get("viewerId")); + // /api ๊ฒฝ๋กœ๋Š” middleware ์ธ์ฆ ๊ฐ€๋“œ๋ฅผ ํƒ€์ง€ ์•Š์œผ๋ฏ€๋กœ URL viewerId๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ์•Š๊ณ  ์„ธ์…˜๋งŒ ์กฐํšŒ์ž ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + const viewerId = session?.id ?? null; if (!viewerId) { return NextResponse.json({ recordings: [], nextCursor: null }); diff --git a/app/api/streams/route.test.ts b/app/api/streams/route.test.ts new file mode 100644 index 00000000..f675a3fb --- /dev/null +++ b/app/api/streams/route.test.ts @@ -0,0 +1,67 @@ +/** + * File Name : app/api/streams/route.test.ts + * Description : ๋ผ์ด๋ธŒ ๋ฐฉ์†ก ๋ชฉ๋ก API ๊ถŒํ•œ ๊ฒฝ๊ณ„ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.25 ์ž„๋„ํ—Œ Created URL viewerId๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ์•Š๋Š” ์„ธ์…˜ ๊ธฐ์ค€ ์กฐํšŒ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getStreamsList: vi.fn(), +})); + +vi.mock("@/lib/session", () => ({ + default: mocks.getSession, +})); + +vi.mock("@/features/stream/service/list", () => ({ + getStreamsList: mocks.getStreamsList, +})); + +describe("GET /api/streams", () => { + beforeEach(() => { + mocks.getSession.mockReset(); + mocks.getStreamsList.mockReset(); + }); + + it("๋น„๋กœ๊ทธ์ธ ์š”์ฒญ์˜ viewerId query๋ฅผ ์กฐํšŒ์ž ๊ถŒํ•œ์œผ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + const { GET } = await import("./route"); + const request = new NextRequest( + "http://localhost/api/streams?scope=following&viewerId=123" + ); + + mocks.getSession.mockResolvedValue(null); + + const response = await GET(request); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ streams: [], nextCursor: null }); + expect(mocks.getStreamsList).not.toHaveBeenCalled(); + }); + + it("์„ธ์…˜์ด ์žˆ์œผ๋ฉด query viewerId๋ณด๋‹ค ์„ธ์…˜ ID๋ฅผ ์šฐ์„ ํ•œ๋‹ค", async () => { + const { GET } = await import("./route"); + const request = new NextRequest( + "http://localhost/api/streams?scope=following&viewerId=123&cursor=50" + ); + + mocks.getSession.mockResolvedValue({ id: 7 }); + mocks.getStreamsList.mockResolvedValue([]); + + await GET(request); + + expect(mocks.getStreamsList).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "following", + viewerId: 7, + cursor: 50, + }) + ); + }); +}); diff --git a/app/api/streams/route.ts b/app/api/streams/route.ts index 3306c2a8..61031e24 100644 --- a/app/api/streams/route.ts +++ b/app/api/streams/route.ts @@ -6,6 +6,7 @@ * History * Date Author Status Description * 2026.05.19 ์ž„๋„ํ—Œ Created Client queryFn์—์„œ ์กฐํšŒ์šฉ Server Action์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜์ง€ ์•Š๋„๋ก ๋ผ์ด๋ธŒ ๋ฐฉ์†ก ๋ชฉ๋ก ์กฐํšŒ API ๋ถ„๋ฆฌ + * 2026.06.25 ์ž„๋„ํ—Œ Modified URL viewerId fallback ์ œ๊ฑฐ ๋ฐ ์„ธ์…˜ ๊ธฐ์ค€ ์กฐํšŒ์ž ๊ถŒํ•œ ๊ณ ์ • */ import { NextRequest, NextResponse } from "next/server"; @@ -51,8 +52,8 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const scope: StreamScope = searchParams.get("scope") === "following" ? "following" : "all"; - const viewerId = - session?.id ?? parseNullableNumberParam(searchParams.get("viewerId")); + // /api ๊ฒฝ๋กœ๋Š” middleware ์ธ์ฆ ๊ฐ€๋“œ๋ฅผ ํƒ€์ง€ ์•Š์œผ๋ฏ€๋กœ URL viewerId๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ์•Š๊ณ  ์„ธ์…˜๋งŒ ์กฐํšŒ์ž ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + const viewerId = session?.id ?? null; if (!viewerId) { return NextResponse.json({ streams: [], nextCursor: null }); diff --git a/app/api/webhooks/cloudflare/route.test.ts b/app/api/webhooks/cloudflare/route.test.ts new file mode 100644 index 00000000..16845eb3 --- /dev/null +++ b/app/api/webhooks/cloudflare/route.test.ts @@ -0,0 +1,117 @@ +/** + * File Name : app/api/webhooks/cloudflare/route.test.ts + * Description : Cloudflare Webhook Route ์ธ์ฆ ๊ฒฝ๊ณ„ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.25 ์ž„๋„ํ—Œ Created production secret ๋ˆ„๋ฝ ์‹œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์ค‘๋‹จ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + db: { + liveInput: { + findUnique: vi.fn(), + }, + broadcast: { + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + vodAsset: { + upsert: vi.fn(), + }, + postVideo: { + findFirst: vi.fn(), + update: vi.fn(), + }, + }, + revalidateTag: vi.fn(), + sendLiveStatusFromServer: vi.fn(), + sendLiveStartNotifications: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + default: mocks.db, +})); + +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => ({ + revalidateTag: mocks.revalidateTag, +})); + +vi.mock("@/features/stream/service/realtime", () => ({ + sendLiveStatusFromServer: mocks.sendLiveStatusFromServer, +})); + +vi.mock("@/features/notification/service/live", () => ({ + sendLiveStartNotifications: mocks.sendLiveStartNotifications, +})); + +function liveConnectedRequest(headers?: HeadersInit) { + return new Request("http://localhost/api/webhooks/cloudflare", { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify({ + type: "live_input.connected", + liveInput: "live-input-1", + }), + }); +} + +describe("POST /api/webhooks/cloudflare", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("production Stream webhook secret์ด ์—†์œผ๋ฉด ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋ฅผ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("CLOUDFLARE_STREAM_WEBHOOK_SECRET", ""); + vi.stubEnv("CLOUDFLARE_WEBHOOK_SECRET", "destination-secret"); + + const { POST } = await import("./route"); + const response = await POST( + liveConnectedRequest({ + "webhook-signature": "time=1,sig1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + ); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + ok: false, + error: "WEBHOOK_SECRET_NOT_CONFIGURED", + }); + expect(mocks.db.liveInput.findUnique).not.toHaveBeenCalled(); + expect(mocks.db.broadcast.update).not.toHaveBeenCalled(); + expect(mocks.sendLiveStatusFromServer).not.toHaveBeenCalled(); + }); + + it("production Destination webhook secret์ด ์—†์œผ๋ฉด ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋ฅผ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("CLOUDFLARE_STREAM_WEBHOOK_SECRET", "stream-secret"); + vi.stubEnv("CLOUDFLARE_WEBHOOK_SECRET", ""); + + const { POST } = await import("./route"); + const response = await POST(liveConnectedRequest()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + ok: false, + error: "WEBHOOK_SECRET_NOT_CONFIGURED", + }); + expect(mocks.db.liveInput.findUnique).not.toHaveBeenCalled(); + expect(mocks.db.broadcast.update).not.toHaveBeenCalled(); + expect(mocks.sendLiveStatusFromServer).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/webhooks/cloudflare/route.ts b/app/api/webhooks/cloudflare/route.ts index d97eef05..bcc6968e 100644 --- a/app/api/webhooks/cloudflare/route.ts +++ b/app/api/webhooks/cloudflare/route.ts @@ -21,6 +21,7 @@ * 2026.04.05 ์ž„๋„ํ—Œ Modified ๊ฒŒ์‹œ๊ธ€ ๋™์˜์ƒ draftKey๋ฅผ READY ์›นํ›…์—์„œ ์กฐ๊ธฐ ํ•ด์ œํ•˜์ง€ ์•Š๊ณ  ์‹ค์ œ ๊ฒŒ์‹œ๊ธ€ ์—ฐ๊ฒฐ ์‹œ์ ๊นŒ์ง€ ์œ ์ง€ * 2026.05.12 ์ž„๋„ํ—Œ Modified ๊ฒŒ์‹œ๊ธ€ ๋™์˜์ƒ READY ์„ ๋„์ฐฉ/Cloudflare error ์›นํ›… ์ฒ˜๋ฆฌ ๋ณด๊ฐ• * 2026.05.17 ์ž„๋„ํ—Œ Modified Cloudflare Stream ์›นํ›… ํŽ˜์ด๋กœ๋“œ ํƒ€์ž… ๋ช…์‹œ + * 2026.06.25 ์ž„๋„ํ—Œ Modified production secret ๋ˆ„๋ฝ ์‹œ Stream/Destination ์›นํ›… fail-closed ์ฒ˜๋ฆฌ */ import "server-only"; @@ -32,6 +33,7 @@ import db from "@/lib/db"; import { sendLiveStatusFromServer } from "@/features/stream/service/realtime"; import { sendLiveStartNotifications } from "@/features/notification/service/live"; import { Prisma } from "@/generated/prisma/client"; +import { isMissingRequiredCloudflareWebhookSecret } from "@/features/stream/utils/webhookAuth"; import type { CloudflareStreamAssetPayload, CloudflareVideoListResponse, @@ -813,8 +815,22 @@ export async function POST(req: Request) { const sigHeader = req.headers.get("webhook-signature"); const isStreamWebhook = !!sigHeader; + // production์—์„œ๋Š” secret ๋ˆ„๋ฝ์„ ๊ฒ€์ฆ ์ƒ๋žต์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๊ณ  ์ƒํƒœ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ๋ฅผ fail-closed ํ•œ๋‹ค. if (isStreamWebhook) { // Stream Webhook โ†’ HMAC ์„œ๋ช… ๊ฒ€์ฆ + if ( + isMissingRequiredCloudflareWebhookSecret({ + kind: "stream", + streamSecret: STREAM_SECRET, + destinationSecret: DEST_SECRET, + }) + ) { + return NextResponse.json( + { ok: false, error: "WEBHOOK_SECRET_NOT_CONFIGURED" }, + { status: 500 } + ); + } + if (STREAM_SECRET) { const ok = await verifyStreamSignatureWebCrypto( raw, @@ -829,6 +845,19 @@ export async function POST(req: Request) { } } else { // Destination Webhook โ†’ ์ธ์ฆ ํ—ค๋” ํ™•์ธ (์˜ต์…˜) + if ( + isMissingRequiredCloudflareWebhookSecret({ + kind: "destination", + streamSecret: STREAM_SECRET, + destinationSecret: DEST_SECRET, + }) + ) { + return NextResponse.json( + { ok: false, error: "WEBHOOK_SECRET_NOT_CONFIGURED" }, + { status: 500 } + ); + } + if (DEST_SECRET && !hasDestinationHeaderSecret(req, DEST_SECRET)) { return NextResponse.json( { ok: false, error: "UNAUTHORIZED" }, @@ -891,4 +920,3 @@ export async function POST(req: Request) { ); } } - diff --git a/features/stream/actions/list.ts b/features/stream/actions/list.ts index f3bdd0b0..64a76bb2 100644 --- a/features/stream/actions/list.ts +++ b/features/stream/actions/list.ts @@ -21,6 +21,7 @@ * 2026.05.08 ์ž„๋„ํ—Œ Modified ๋ชฉ๋ก ์‘๋‹ต ํƒ€์ž…๊ณผ ์กฐํšŒ ๋ฒ”์œ„ ํƒ€์ž…์„ features/stream/types.ts๋กœ ์ด๋™ * 2026.05.15 ์ž„๋„ํ—Œ Modified ์œ ์ € ์ฑ„๋„ ๋‹ค์‹œ๋ณด๊ธฐ ๋ฌดํ•œ์Šคํฌ๋กค ์•ก์…˜ ์ถ”๊ฐ€ * 2026.05.18 ์ž„๋„ํ—Œ Modified ์ฑ„๋„ ๋‹ค์‹œ๋ณด๊ธฐ ์ถ”๊ฐ€ ํŽ˜์ด์ง€์—์„œ๋„ ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ข‹์•„์š” ์—ฌ๋ถ€๋ฅผ ์œ ์ง€ํ•˜๋„๋ก viewerId ์ „๋‹ฌ + * 2026.06.25 ์ž„๋„ํ—Œ Modified ๋ชฉ๋ก ์•ก์…˜์˜ ์กฐํšŒ์ž ๊ถŒํ•œ ํŒ๋‹จ์„ ์„œ๋ฒ„ ์„ธ์…˜ ๊ธฐ์ค€์œผ๋กœ ๊ณ ์ • */ "use server"; @@ -90,17 +91,15 @@ function applyChannelVodAccess( * @param {StreamScope} scope - ์กฐํšŒ ๋ฒ”์œ„ ("all" | "following") * @param {number | null} cursor - ์ด์ „ ํŽ˜์ด์ง€์˜ ๋งˆ์ง€๋ง‰ ๋ฐฉ์†ก ID * @param {Record} searchParams - ์นดํ…Œ๊ณ ๋ฆฌ ๋ฐ ํ‚ค์›Œ๋“œ ํ•„ํ„ฐ ์กฐ๊ฑด - * @param {number | null} viewerId - ์กฐํšŒ์ž ID (ํŒ”๋กœ์ž‰ ๋ชฉ๋ก ํ™•์ธ์šฉ) * @returns {Promise} ํ‰ํƒ„ํ™”๋œ ๋ฐฉ์†ก ๋ชฉ๋ก๊ณผ ํŽ˜์ด์ง• ์ปค์„œ ๋ฐ˜ํ™˜ */ export async function getStreamsListAction( scope: StreamScope, cursor: number | null, - searchParams: Record, - viewerId: number | null + searchParams: Record ): Promise { const session = await getSession(); - const userId = session?.id ?? viewerId; + const userId = session?.id ?? null; if (!userId) return { streams: [], nextCursor: null }; @@ -127,16 +126,16 @@ export async function getStreamsListAction( * - ์ •๋ ฌ ๊ธฐ์ค€(latest/popular)๊ณผ ํŒ”๋กœ์ž‰ ์ „์šฉ ํ•„ํ„ฐ๋ฅผ service ๊ณ„์ธต์— ์œ„์ž„ * - ์นดํ…Œ๊ณ ๋ฆฌ/ํ‚ค์›Œ๋“œ ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ณต๋ฐฑ ์ •๊ทœํ™” ํ›„ ์ „๋‹ฌ * - ๋ฌดํ•œ ์Šคํฌ๋กค์šฉ recordings ๋ฐฐ์—ด๊ณผ ๋‹ค์Œ ์ปค์„œ(nextCursor)๋ฅผ ๋ฐ˜ํ™˜ + * - ์กฐํšŒ์ž ๊ถŒํ•œ ํŒ๋‹จ์€ ์„œ๋ฒ„ ์„ธ์…˜๋งŒ ์‹ ๋ขฐ */ export async function getRecordingsListAction( sort: "latest" | "popular", followingOnly: boolean, cursor: number | null, - searchParams: Record, - viewerId: number | null + searchParams: Record ): Promise { const session = await getSession(); - const userId = session?.id ?? viewerId; + const userId = session?.id ?? null; if (!userId) return { recordings: [], nextCursor: null }; diff --git a/features/stream/hooks/useRecordingPagination.ts b/features/stream/hooks/useRecordingPagination.ts index 6f456d18..addee129 100644 --- a/features/stream/hooks/useRecordingPagination.ts +++ b/features/stream/hooks/useRecordingPagination.ts @@ -10,6 +10,7 @@ * 2026.03.31 ์ž„๋„ํ—Œ Modified export ํ›… ์—ญํ• ๊ณผ ๋ฐ˜ํ™˜๊ฐ’์ด ๋ฐ”๋กœ ๋ณด์ด๋„๋ก JSDoc ๋ณด๊ฐ• * 2026.04.02 ์ž„๋„ํ—Œ Modified ๋‹ค์‹œ๋ณด๊ธฐ ํŽ˜์ด์ง• ํ›… ํŒŒ๋ผ๋ฏธํ„ฐ/๋ฐ˜ํ™˜ ํƒ€์ž… ์„ค๋ช… ๋ณด๊ฐ• * 2026.05.19 ์ž„๋„ํ—Œ Modified Client queryFn ์ดˆ๊ธฐ ๋ Œ๋”์˜ ์กฐํšŒ์šฉ Server Action ํ˜ธ์ถœ ์˜ค๋ฅ˜๋ฅผ ํ”ผํ•˜๋„๋ก Route Handler fetch๋กœ ์ „ํ™˜ + * 2026.06.25 ์ž„๋„ํ—Œ Modified viewerId URL ์ „๋‹ฌ ์ œ๊ฑฐ ๋ฐ ์กฐํšŒ์ž/ํŒ”๋กœ์ž‰ ํ•„ํ„ฐ๋ณ„ query key ์Šค์ฝ”ํ”„ ๋ถ„๋ฆฌ */ "use client"; @@ -42,9 +43,10 @@ function buildRecordingsApiUrl({ sort, followingOnly, searchParams, - viewerId, cursor, -}: UseRecordingPaginationParams & { cursor: number | null }): string { +}: Omit & { + cursor: number | null; +}): string { const params = new URLSearchParams({ sort }); if (followingOnly) { @@ -55,10 +57,6 @@ function buildRecordingsApiUrl({ params.set("cursor", String(cursor)); } - if (viewerId !== undefined && viewerId !== null) { - params.set("viewerId", String(viewerId)); - } - const category = searchParams.category; const keyword = searchParams.keyword; @@ -108,7 +106,11 @@ export function useRecordingPagination({ viewerId, }: UseRecordingPaginationParams): UseRecordingPaginationResult { // ์ •๋ ฌ/๊ฒ€์ƒ‰ ์กฐ๊ฑด์ด ๋ฐ”๋€Œ๋ฉด ๋ชฉ๋ก ์บ์‹œ๋„ ๋ณ„๋„ ์Šค์ฝ”ํ”„๋กœ ๋ถ„๋ฆฌ - const queryKey = queryKeys.streams.recordingList(sort, searchParams); + const queryKey = queryKeys.streams.recordingList(sort, { + ...searchParams, + followingOnly, + viewerId: viewerId ?? "guest", + }); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSuspenseInfiniteQuery({ @@ -120,7 +122,6 @@ export function useRecordingPagination({ sort, followingOnly, searchParams, - viewerId, cursor: pageParam as number | null, }) ); diff --git a/features/stream/hooks/useStreamPagination.ts b/features/stream/hooks/useStreamPagination.ts index e173280f..c5f897ca 100644 --- a/features/stream/hooks/useStreamPagination.ts +++ b/features/stream/hooks/useStreamPagination.ts @@ -13,6 +13,7 @@ * 2026.04.17 ์ž„๋„ํ—Œ Modified ํ˜„์žฌ ํ›…์ด ๋‹ด๋‹นํ•˜๋Š” ๋ฒ”์œ„๊ฐ€ ๋ฌดํ•œ ์Šคํฌ๋กค ํŽ˜์ด์ง• ์ค‘์‹ฌ์œผ๋กœ ์ฝํžˆ๋„๋ก ์„ค๋ช…์„ ์ตœ์‹ ํ™” * 2026.05.08 ์ž„๋„ํ—Œ Modified ์ŠคํŠธ๋ฆผ ์กฐํšŒ ๋ฒ”์œ„ ํƒ€์ž…์„ StreamScope ๊ณต์šฉ ํƒ€์ž…์œผ๋กœ ๊ต์ฒด * 2026.05.19 ์ž„๋„ํ—Œ Modified Client queryFn ์ดˆ๊ธฐ ๋ Œ๋”์˜ ์กฐํšŒ์šฉ Server Action ํ˜ธ์ถœ ์˜ค๋ฅ˜๋ฅผ ํ”ผํ•˜๋„๋ก Route Handler fetch๋กœ ์ „ํ™˜ + * 2026.06.25 ์ž„๋„ํ—Œ Modified viewerId URL ์ „๋‹ฌ ์ œ๊ฑฐ ๋ฐ ์กฐํšŒ์ž๋ณ„ query key ์Šค์ฝ”ํ”„ ๋ถ„๋ฆฌ */ "use client"; @@ -47,19 +48,16 @@ export interface UseStreamPaginationResult { function buildStreamsApiUrl({ scope, searchParams, - viewerId, cursor, -}: UseStreamPaginationParams & { cursor: number | null }): string { +}: Omit & { + cursor: number | null; +}): string { const params = new URLSearchParams({ scope }); if (cursor !== null) { params.set("cursor", String(cursor)); } - if (viewerId !== undefined && viewerId !== null) { - params.set("viewerId", String(viewerId)); - } - const category = searchParams.category; const keyword = searchParams.keyword; @@ -107,7 +105,10 @@ export function useStreamPagination({ searchParams, viewerId, }: UseStreamPaginationParams): UseStreamPaginationResult { - const queryKey = queryKeys.streams.list(scope, searchParams); + const queryKey = queryKeys.streams.list(scope, { + ...searchParams, + viewerId: viewerId ?? "guest", + }); // TanStack Query๋ฅผ ํ™œ์šฉํ•œ ๋ฌดํ•œ ์Šคํฌ๋กค ์ฟผ๋ฆฌ ๊ตฌ์„ฑ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = @@ -119,7 +120,6 @@ export function useStreamPagination({ buildStreamsApiUrl({ scope, searchParams, - viewerId, cursor: pageParam as number | null, }) ); diff --git a/features/stream/utils/webhookAuth.test.ts b/features/stream/utils/webhookAuth.test.ts new file mode 100644 index 00000000..3d4a32d3 --- /dev/null +++ b/features/stream/utils/webhookAuth.test.ts @@ -0,0 +1,55 @@ +/** + * File Name : features/stream/utils/webhookAuth.test.ts + * Description : Cloudflare Webhook ์ธ์ฆ ์ •์ฑ… ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.25 ์ž„๋„ํ—Œ Created production secret ๋ˆ„๋ฝ fail-closed ์ •์ฑ… ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { describe, expect, it } from "vitest"; +import { isMissingRequiredCloudflareWebhookSecret } from "./webhookAuth"; + +describe("isMissingRequiredCloudflareWebhookSecret", () => { + it("production Stream webhook์—์„œ HMAC secret์ด ์—†์œผ๋ฉด ๋ˆ„๋ฝ์œผ๋กœ ํŒ๋‹จํ•œ๋‹ค", () => { + expect( + isMissingRequiredCloudflareWebhookSecret({ + kind: "stream", + streamSecret: "", + destinationSecret: "destination-secret", + nodeEnv: "production", + }) + ).toBe(true); + }); + + it("production Destination webhook์—์„œ destination secret์ด ์—†์œผ๋ฉด ๋ˆ„๋ฝ์œผ๋กœ ํŒ๋‹จํ•œ๋‹ค", () => { + expect( + isMissingRequiredCloudflareWebhookSecret({ + kind: "destination", + streamSecret: "stream-secret", + destinationSecret: "", + nodeEnv: "production", + }) + ).toBe(true); + }); + + it("secret์ด ์„ค์ •๋˜์–ด ์žˆ๊ฑฐ๋‚˜ production์ด ์•„๋‹ˆ๋ฉด ๋ˆ„๋ฝ์œผ๋กœ ํŒ๋‹จํ•˜์ง€ ์•Š๋Š”๋‹ค", () => { + expect( + isMissingRequiredCloudflareWebhookSecret({ + kind: "stream", + streamSecret: "stream-secret", + destinationSecret: "", + nodeEnv: "production", + }) + ).toBe(false); + expect( + isMissingRequiredCloudflareWebhookSecret({ + kind: "destination", + streamSecret: "", + destinationSecret: "", + nodeEnv: "test", + }) + ).toBe(false); + }); +}); diff --git a/features/stream/utils/webhookAuth.ts b/features/stream/utils/webhookAuth.ts new file mode 100644 index 00000000..821ba88e --- /dev/null +++ b/features/stream/utils/webhookAuth.ts @@ -0,0 +1,36 @@ +/** + * File Name : features/stream/utils/webhookAuth.ts + * Description : Cloudflare Webhook ์ธ์ฆ ์ •์ฑ… ์œ ํ‹ธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.25 ์ž„๋„ํ—Œ Created ์›นํ›… secret ๋ˆ„๋ฝ ์‹œ production fail-closed ์ •์ฑ… ๋ถ„๋ฆฌ + */ + +export type CloudflareWebhookKind = "stream" | "destination"; + +/** + * ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์‹ค์ œ ์ƒํƒœ ๋ณ€๊ฒฝ ์›นํ›…์— ํ•„์š”ํ•œ secret์ด ๋น„์–ด ์žˆ์œผ๋ฉด ์š”์ฒญ์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค. + * + * @param params.kind - Stream signature ์›นํ›…์ธ์ง€ Destination header ์›นํ›…์ธ์ง€ + * @param params.streamSecret - Stream HMAC ๊ฒ€์ฆ์šฉ secret + * @param params.destinationSecret - Destination header ๊ฒ€์ฆ์šฉ secret + * @param params.nodeEnv - ํ˜„์žฌ Node ํ™˜๊ฒฝ + * @returns production์—์„œ ํ•ด๋‹น ์›นํ›… secret์ด ํ•„์ˆ˜์ธ๋ฐ ๋ˆ„๋ฝ๋˜์—ˆ๋Š”์ง€ ์—ฌ๋ถ€ + */ +export function isMissingRequiredCloudflareWebhookSecret({ + kind, + streamSecret, + destinationSecret, + nodeEnv = process.env.NODE_ENV, +}: { + kind: CloudflareWebhookKind; + streamSecret: string; + destinationSecret: string; + nodeEnv?: string; +}) { + if (nodeEnv !== "production") return false; + + return kind === "stream" ? !streamSecret : !destinationSecret; +} diff --git a/features/user/hooks/useFollowToggle.ts b/features/user/hooks/useFollowToggle.ts index f2738e74..a6451b5f 100644 --- a/features/user/hooks/useFollowToggle.ts +++ b/features/user/hooks/useFollowToggle.ts @@ -25,6 +25,7 @@ * 2026.05.08 ์ž„๋„ํ—Œ Modified ํŒ”๋กœ์šฐ ์•ก์…˜ ๊ฒฐ๊ณผ ํƒ€์ž… import ๊ฒฝ๋กœ๋ฅผ user types๋กœ ์ •๋ฆฌ * 2026.05.16 ์ž„๋„ํ—Œ Modified ํŒ”๋กœ์šฐ/์ŠคํŠธ๋ฆผ ์บ์‹œ ๊ฐฑ์‹  ํƒ€์ž…์„ ๋ช…์‹œํ•ด any ์บ์ŠคํŒ… ์ œ๊ฑฐ * 2026.06.17 ์ž„๋„ํ—Œ Modified ์„œ๋ฒ„ ์„ฑ๊ณต ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ ์ ‘๊ทผ ์ƒํƒœ๋ฅผ ๋™๊ธฐํ™”ํ•˜๋Š” ์ฑ…์ž„์„ ์ฃผ์„์— ๋ช…ํ™•ํžˆ ๋ฐ˜์˜ + * 2026.06.25 ์ž„๋„ํ—Œ Modified ๊ธฐ๋ณธ ํŒ”๋กœ์ž‰ ์ŠคํŠธ๋ฆผ seed key๋ฅผ ์กฐํšŒ์ž๋ณ„ ์บ์‹œ ์Šค์ฝ”ํ”„์™€ ์ผ์น˜ํ•˜๋„๋ก ์ •๋ฆฌ */ "use client"; @@ -211,10 +212,18 @@ export function useFollowToggle() { ); } - if (res.isFollowing && targetUserStreams.size > 0) { + if ( + res.isFollowing && + targetUserStreams.size > 0 && + opts?.viewerId != null + ) { + // ๊ธฐ๋ณธ ํŒ”๋กœ์ž‰ ํƒญ seed๋„ ์‹ค์ œ ์ŠคํŠธ๋ฆผ ๋ชฉ๋ก๊ณผ ๊ฐ™์€ ์กฐํšŒ์ž๋ณ„ query key์—๋งŒ ๊ธฐ๋กํ•œ๋‹ค. const defaultFollowingKey = queryKeys.streams.list( "following", - DEFAULT_STREAM_LIST_FILTERS + { + ...DEFAULT_STREAM_LIST_FILTERS, + viewerId: opts.viewerId, + } ); queryClient.setQueryData>( From d4d36c914feafc7bf6816ba10c8b33256475cf24 Mon Sep 17 00:00:00 2001 From: DoHeonLim Date: Fri, 26 Jun 2026 21:07:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=9A=20Docs=20:=20README=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=ED=8F=B4=EB=A6=AC=EC=98=A4=20=EC=84=9C?= =?UTF-8?q?=EC=82=AC=EC=99=80=20=EA=B6=8C=ED=95=9C=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [๐Ÿ“Œ README] - ์ฒซ ํ™”๋ฉด์— Key Engineering Problems ์„น์…˜ ์ถ”๊ฐ€ - ๋Œ€ํ‘œ ๋ฌธ์ œ ํ•ด๊ฒฐ ์‚ฌ๋ก€๋ฅผ ์ฆ์ƒ/ํ•ด๊ฒฐ ๋ฐฉํ–ฅ/๋ฌธ์„œ ๋งํฌ ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌ - Demo ์•ž๋’ค ํ๋ฆ„์„ ์„œ๋น„์Šค ์†Œ๊ฐœ, ํ•ต์‹ฌ ๋ฌธ์ œ, ๋ฐ๋ชจ, Feature Flow ์ˆœ์„œ๋กœ ์žฌ๋ฐฐ์น˜ - Recommended Reading์„ ํ•ต์‹ฌ ์„ค๊ณ„์™€ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… ๋ฌธ์„œ ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ - [๐Ÿ›ก๏ธ Access Control] - ๊ถŒํ•œ / ์ ‘๊ทผ ์ œ์–ด ๋งคํŠธ๋ฆญ์Šค ๋ฌธ์„œ ์ถ”๊ฐ€ - ํŽ˜์ด์ง€, Route Handler, Server Action, service ๊ณ„์ธต์˜ ๋ณดํ˜ธ ๊ธฐ์ค€ ์ •๋ฆฌ - ๋„๋ฉ”์ธ๋ณ„ ์ฃผ์š” ํ–‰์œ„์™€ ๋Œ€ํ‘œ ๊ถŒํ•œ ๊ฐ€๋“œ ์ •๋ฆฌ - Webhook secret ๋ˆ„๋ฝ ์‹œ production fail-closed ์›์น™ ๋ฌธ์„œํ™” - [๐Ÿ“š Docs Index] - docs ์ถ”์ฒœ ์ฝ๋Š” ์ˆœ์„œ๋ฅผ ํ”„๋กœ์ ํŠธ ๊ฐœ์š”์™€ ํ•ต์‹ฌ ๋ฌธ์ œ ํ•ด๊ฒฐ ์‚ฌ๋ก€ ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ - ๊ถŒํ•œ ๋งคํŠธ๋ฆญ์Šค์™€ PWA Push ๋ฌธ์„œ๋ฅผ ์ถ”๊ฐ€ ์‚ฌ๋ก€๋กœ ์—ฐ๊ฒฐ - [โœ… Verification] - git diff --check --- README.md | 23 ++++- docs/README.md | 12 ++- docs/operations/access-control-matrix.md | 117 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 docs/operations/access-control-matrix.md diff --git a/README.md b/README.md index 6e33e98b..ddb16af8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BoardPort -BoardPort๋Š” ๋ณด๋“œ๊ฒŒ์ž„ ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ์ฑ„ํŒ… ์•ฝ์†, ๋ผ์ด๋ธŒ ๋ฐฉ์†ก, ์•Œ๋ฆผ, ๊ด€๋ฆฌ์ž ์šด์˜์„ ํ•˜๋‚˜์˜ ์‚ฌ์šฉ์ž ํ๋ฆ„์œผ๋กœ ์—ฐ๊ฒฐํ•œ ๋ชจ๋ฐ”์ผ ํผ์ŠคํŠธ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. +BoardPort๋Š” ๋ณด๋“œ๊ฒŒ์ž„ ์ค‘๊ณ ๊ฑฐ๋ž˜์—์„œ ์ž์ฃผ ๋ถ„๋ฆฌ๋˜๋Š” ์ƒํ’ˆ ํƒ์ƒ‰, ๋ฌธ์˜, ์ง๊ฑฐ๋ž˜ ์•ฝ์†, ํ›„๊ธฐ, ์ฝ˜ํ…์ธ , ์šด์˜ ํ๋ฆ„์„ ํ•˜๋‚˜์˜ ์„œ๋น„์Šค๋กœ ์—ฐ๊ฒฐํ•œ ๋ชจ๋ฐ”์ผ ํผ์ŠคํŠธ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. ๋ฒ”์šฉ ์ค‘๊ณ ๊ฑฐ๋ž˜ ์•ฑ์—์„œ ๋ถ€์กฑํ•œ ๋ณด๋“œ๊ฒŒ์ž„ ํŠนํ™” ๋งฅ๋ฝ์„ ๋ณด์™„ํ•˜๊ธฐ ์œ„ํ•ด, ๊ฑฐ๋ž˜ยท๋ฃฐ ์งˆ๋ฌธยทํ›„๊ธฐยทํ”Œ๋ ˆ์ด ๊ณต์œ ๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ด์–ด์ง€๋Š” ํ๋ฆ„์— ์ดˆ์ ์„ ๋งž์ท„์Šต๋‹ˆ๋‹ค. @@ -13,11 +13,16 @@ BoardPort๋Š” ๋ณด๋“œ๊ฒŒ์ž„ ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ์ฑ„ํŒ… ์•ฝ์†, ๋ผ์ด๋ธŒ ๋ฐฉ์†ก, | Domain | Board Game Marketplace ยท Community ยท Live Streaming ยท Chat | | Core Stack | Next.js 14 App Router, React 18, TypeScript, Prisma, PostgreSQL, TanStack Query v5, Zustand, Supabase Realtime | -## Feature Flow +## Key Engineering Problems -BoardPort๋Š” ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ๋ฐฉ์†ก, ์•Œ๋ฆผ, ๊ด€๋ฆฌ์ž ์šด์˜์ด ๋…๋ฆฝ์ ์œผ๋กœ ๋™์ž‘ํ•˜๋ฉด์„œ ๋ณด๋“œ๊ฒŒ์ž„ ๋„๊ฐ์œผ๋กœ ์ฝ˜ํ…์ธ  ๋งฅ๋ฝ์„ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค. +| ๋ฌธ์ œ | ํ•ด๊ฒฐ ๋ฐฉํ–ฅ | ๊ด€๋ จ ๋ฌธ์„œ | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ๊ณผ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ๊ฐ€ ์–ด๊ธ‹๋‚  ์ˆ˜ ์žˆ์Œ | ์•ฝ์† ์ˆ˜๋ฝ, ์ƒํ’ˆ ์˜ˆ์•ฝ ์ „ํ™˜, ๋‹ค๋ฅธ ๋Œ€๊ธฐ ์•ฝ์† ์ทจ์†Œ, ์‹œ์Šคํ…œ ๋ฉ”์‹œ์ง€๋ฅผ ํ•˜๋‚˜์˜ transaction์œผ๋กœ ๋ฌถ๊ณ  `updateMany` ์กฐ๊ฑด์œผ๋กœ ๋™์‹œ ์ˆ˜๋ฝ์„ ๋ฐฉ์–ด | [Appointment Atomic Transition](./docs/troubleshooting/troubleshooting-appointment-atomic-transition.md) | +| App Router ๋ชจ๋‹ฌ ์ƒ์„ธ์™€ ์ผ๋ฐ˜ ์ƒ์„ธ์˜ ๋ณต๊ท€ ๋ฌธ๋งฅ์ด ์„ž์ž„ | Intercepting Route ๋ชจ๋‹ฌ, ์ผ๋ฐ˜ ์ƒ์„ธ, ์ˆ˜์ •/์‚ญ์ œ ๋ณต๊ท€๋ฅผ `returnTo`, `flow`, refresh flag๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  mixed tree ์ผ€์ด์Šค๋ฅผ ๋ฌธ๋งฅ๋ณ„๋กœ ์ •๋ฆฌ | [Product Modal Routing](./docs/troubleshooting/troubleshooting-product-modal-routing.md) | +| ์™ธ๋ถ€ ๋™์˜์ƒ ์ธ์ฝ”๋”ฉ ์ด๋ฒคํŠธ์™€ ๊ฒŒ์‹œ๊ธ€ ์ €์žฅ ์ˆœ์„œ๊ฐ€ ๋ณด์žฅ๋˜์ง€ ์•Š์Œ | Cloudflare webhook์˜ READY ์„ ๋„์ฐฉ๊ณผ error payload๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ , `draftKey`๋ฅผ ์‹ค์ œ ๊ฒŒ์‹œ๊ธ€ ์—ฐ๊ฒฐ ์ „๊นŒ์ง€ ๋ณด์กดํ•ด READY/FAILED ์ƒํƒœ๋กœ ์ˆ˜๋ ด | [Post Video Webhook](./docs/troubleshooting/troubleshooting-post-video-cloudflare-webhook.md) | +| Server State, UI State, Realtime ์ด๋ฒคํŠธ๊ฐ€ ์„ž์—ฌ ๊ฐฑ์‹  ๊ธฐ์ค€์ด ๋ถ„์‚ฐ๋จ | Zustand๋Š” UI ์ƒํƒœ, TanStack Query๋Š” ์„œ๋ฒ„ ์ƒํƒœ, Realtime์€ invalidate/refetch ์‹ ํ˜ธ๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  Route Handler fetch์™€ Query Key Factory๋กœ ์žฌ๊ฒ€์ฆ ๊ฒฝ๋กœ๋ฅผ ํ†ต์ผ | [State Management Modernization](./docs/architecture/case-study-state-management-modernization.md) | -![BoardPort Feature Flow](./docs/assets/readme/boardport-feature-flow.png) +๊ฐ ๋ฌธ์„œ๋Š” ๋ฆด๋ฆฌ์ฆˆ ์ „ QA์—์„œ ํ™•์ธํ•œ ์ฆ์ƒ, ์›์ธ, ์ฝ”๋“œ ๊ธฐ์ค€, ์šด์˜ ํŒ๋‹จ์„ ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. ## Demo @@ -39,6 +44,12 @@ BoardPort์˜ ์ฃผ์š” ๋„๋ฉ”์ธ์„ ๋น ๋ฅด๊ฒŒ ํ›‘์–ด๋ณด๋Š” ์ „์ฒด ํ๋ฆ„์ž…๋‹ˆ๋‹ค. +## Feature Flow + +BoardPort๋Š” ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ๋ฐฉ์†ก, ์•Œ๋ฆผ, ๊ด€๋ฆฌ์ž ์šด์˜์ด ๋…๋ฆฝ์ ์œผ๋กœ ๋™์ž‘ํ•˜๋ฉด์„œ ๋ณด๋“œ๊ฒŒ์ž„ ๋„๊ฐ์œผ๋กœ ์ฝ˜ํ…์ธ  ๋งฅ๋ฝ์„ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค. + +![BoardPort Feature Flow](./docs/assets/readme/boardport-feature-flow.png) + ## Feature Tour
@@ -178,8 +189,10 @@ App Router์˜ ์ผ๋ฐ˜ ์ƒ์„ธ ํŽ˜์ด์ง€์™€ ๋ชจ๋‹ฌ ์ƒ์„ธ ํŽ˜์ด์ง€๊ฐ€ ๊ฐ™์€ ๋ฐ - [Project Overview](./docs/architecture/boardport-project-overview.md) - [State Management Modernization](./docs/architecture/case-study-state-management-modernization.md) -- [Product Modal Routing Troubleshooting](./docs/troubleshooting/troubleshooting-product-modal-routing.md) - [Appointment Atomic Transition Troubleshooting](./docs/troubleshooting/troubleshooting-appointment-atomic-transition.md) +- [Post Video Cloudflare Webhook Troubleshooting](./docs/troubleshooting/troubleshooting-post-video-cloudflare-webhook.md) +- [Product Modal Routing Troubleshooting](./docs/troubleshooting/troubleshooting-product-modal-routing.md) +- [Access Control Matrix](./docs/operations/access-control-matrix.md) - [PWA Web Push Routing Troubleshooting](./docs/troubleshooting/troubleshooting-pwa-web-push-routing.md) ## Project Structure diff --git a/docs/README.md b/docs/README.md index 268c5c7d..95d3b59f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,12 +17,19 @@ 3. [troubleshooting/troubleshooting-appointment-atomic-transition.md](./troubleshooting/troubleshooting-appointment-atomic-transition.md) - ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ๊ณผ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ๋ฅผ ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋งž์ถ˜ ์‚ฌ๋ก€ -4. [troubleshooting/troubleshooting-pwa-web-push-routing.md](./troubleshooting/troubleshooting-pwa-web-push-routing.md) - - In-App ์•Œ๋ฆผ๊ณผ Web Push ์ค‘๋ณต ์ œ์–ด, Service Worker ๋ผ์šฐํŒ…์„ ์ •๋ฆฌํ•œ ๊ธฐ๋ก +4. [troubleshooting/troubleshooting-post-video-cloudflare-webhook.md](./troubleshooting/troubleshooting-post-video-cloudflare-webhook.md) + - Cloudflare ์›นํ›…๊ณผ ๊ฒŒ์‹œ๊ธ€ ์ €์žฅ ์ˆœ์„œ๊ฐ€ ์—‡๊ฐˆ๋ฆฌ๋Š” ๋™์˜์ƒ ์ƒํƒœ ์ˆ˜๋ ด ์‚ฌ๋ก€ 5. [troubleshooting/troubleshooting-product-modal-routing.md](./troubleshooting/troubleshooting-product-modal-routing.md) - App Router Intercepting Route, ํŽธ์ง‘ ๋ณต๊ท€, ๋ชจ๋‹ฌ ํžˆ์Šคํ† ๋ฆฌ ๋ฌธ์ œ ํ•ด๊ฒฐ ์‚ฌ๋ก€ +์ถ”๊ฐ€ ์‚ฌ๋ก€: + +- [operations/access-control-matrix.md](./operations/access-control-matrix.md) + - ํŽ˜์ด์ง€, Route Handler, Server Action, Webhook ๊ธฐ์ค€์˜ ๊ถŒํ•œ/์ ‘๊ทผ ์ œ์–ด ์ •๋ฆฌ +- [troubleshooting/troubleshooting-pwa-web-push-routing.md](./troubleshooting/troubleshooting-pwa-web-push-routing.md) + - In-App ์•Œ๋ฆผ๊ณผ Web Push ์ค‘๋ณต ์ œ์–ด, Service Worker ๋ผ์šฐํŒ…์„ ์ •๋ฆฌํ•œ ๊ธฐ๋ก + ## ๋ฌธ์„œ ๋ถ„๋ฅ˜ ### Architecture @@ -39,6 +46,7 @@ ### Operations +- [๊ถŒํ•œ / ์ ‘๊ทผ ์ œ์–ด ๋งคํŠธ๋ฆญ์Šค](./operations/access-control-matrix.md) - [๋ณด์•ˆ ํ—ค๋” / CSP ์šด์˜ ์ •์ฑ…](./operations/security-headers-csp-policy.md) - [๋ณด๋“œ๊ฒŒ์ž„ ๋ฐ์ดํ„ฐ import ์šด์˜ ๊ธฐ์ค€](./operations/boardgame-data-import-runbook.md) - [์‹ ๊ณ  ์ฒ˜๋ฆฌ์™€ ์ œ์žฌ ์šด์˜ ์ •์ฑ…](./operations/report-moderation-policy.md) diff --git a/docs/operations/access-control-matrix.md b/docs/operations/access-control-matrix.md new file mode 100644 index 00000000..a0370f0c --- /dev/null +++ b/docs/operations/access-control-matrix.md @@ -0,0 +1,117 @@ +# ๊ถŒํ•œ / ์ ‘๊ทผ ์ œ์–ด ๋งคํŠธ๋ฆญ์Šค + +BoardPort์˜ ์ ‘๊ทผ ์ œ์–ด๋Š” middleware ํ•œ ๊ณณ์—๋งŒ ์˜์กดํ•˜์ง€ ์•Š๊ณ , ํŽ˜์ด์ง€ ์ง„์ž…, Route Handler, Server Action, service ๊ณ„์ธต์—์„œ ๋„๋ฉ”์ธ๋ณ„๋กœ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + +์ด ๋ฌธ์„œ๋Š” ๋ฉด์ ‘, ์ฝ”๋“œ ๋ฆฌ๋ทฐ, ๋ฆด๋ฆฌ์ฆˆ ์ „ ์ ๊ฒ€์—์„œ ์ฃผ์š” ์‚ฌ์šฉ์ž ์—ญํ• ๊ณผ ๋ณดํ˜ธ ์ง€์ ์„ ๋น ๋ฅด๊ฒŒ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•œ ์šด์˜ ๊ธฐ์ค€์ž…๋‹ˆ๋‹ค. ์„ธ๋ถ€ ๊ตฌํ˜„์€ ๊ฐ ๋„๋ฉ”์ธ์˜ `actions`, `service`, `route.ts` ํŒŒ์ผ์„ ๊ธฐ์ค€์œผ๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + +## 1. ๊ธฐ๋ณธ ์›์น™ + +| ์›์น™ | ๊ธฐ์ค€ | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| ์„ธ์…˜ ์šฐ์„  | ์กฐํšŒ์ž ๋˜๋Š” ํ–‰์œ„์ž ID๋Š” ํด๋ผ์ด์–ธํŠธ ์ž…๋ ฅ๋ณด๋‹ค ์„œ๋ฒ„ ์„ธ์…˜์„ ์šฐ์„ ํ•ฉ๋‹ˆ๋‹ค. | +| ๊ณ„์ธต๋ณ„ ์žฌ๊ฒ€์ฆ | ๋ณดํ˜ธ ํŽ˜์ด์ง€๋ผ๋„ Server Action๊ณผ Route Handler์—์„œ ๊ถŒํ•œ์„ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. | +| ์†Œ์œ ์ž ๊ธฐ์ค€ | ์ˆ˜์ •, ์‚ญ์ œ, ์ƒํƒœ ๋ณ€๊ฒฝ์€ ์ž‘์„ฑ์ž ๋˜๋Š” ์†Œ์œ ์ž ๊ถŒํ•œ์„ service ๊ณ„์ธต์—์„œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. | +| ๊ด€๊ณ„ ๊ธฐ์ค€ | ์ฑ„ํŒ…, ํŒ”๋กœ์šฐ, ์ฐจ๋‹จ, ์‹ ๊ณ , ๋ฐฉ์†ก ์ ‘๊ทผ์€ ์‚ฌ์šฉ์ž ๊ฐ„ ๊ด€๊ณ„์™€ ์ฝ˜ํ…์ธ  ์ƒํƒœ๋ฅผ ํ•จ๊ป˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. | +| ์™ธ๋ถ€ ์š”์ฒญ fail-closed | Webhook์ฒ˜๋Ÿผ ์™ธ๋ถ€์—์„œ ๋“ค์–ด์˜ค๋Š” ์š”์ฒญ์€ production secret ๋˜๋Š” signature ๋ˆ„๋ฝ ์‹œ ์ƒํƒœ ๋ณ€๊ฒฝ ์ „์— ๊ฑฐ๋ถ€ํ•ฉ๋‹ˆ๋‹ค. | +| ์บ์‹œ์™€ ๊ถŒํ•œ ๋ถ„๋ฆฌ | TanStack Query key๋Š” ๊ฐœ์ธํ™” ๊ฒฐ๊ณผ๋ฅผ ๊ตฌ๋ถ„ํ•˜๊ธฐ ์œ„ํ•œ cache identity์ด๊ณ , ๊ถŒํ•œ ํŒ๋‹จ์€ ์„œ๋ฒ„ ์„ธ์…˜๊ณผ DB ์ƒํƒœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค. | + +## 2. ํŽ˜์ด์ง€ / API ๋ณดํ˜ธ ๊ธฐ์ค€ + +| ์˜์—ญ | ์ง„์ž… ๊ฒฝ๋กœ | ๋ณดํ˜ธ ๊ธฐ์ค€ | +| ----------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| ๊ณต๊ฐœ ์˜์—ญ | `/`, `/login`, `/create-account`, ๊ณต์œ  ์ด๋ฏธ์ง€ route | ๋น„๋กœ๊ทธ์ธ ์ ‘๊ทผ ํ—ˆ์šฉ. ์ธ์ฆ ์‚ฌ์šฉ์ž๋Š” guest-only ํŽ˜์ด์ง€์—์„œ ์•ฑ ์˜์—ญ์œผ๋กœ ์ด๋™ | +| ์•ฑ ์˜์—ญ | `/products`, `/posts`, `/chat`, `/profile`, `/streams` ๋“ฑ | middleware์—์„œ ๋กœ๊ทธ์ธ ์„ธ์…˜ ํ™•์ธ ํ›„ ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋Š” `/login?callbackUrl=...`๋กœ ์ด๋™ | +| ๊ด€๋ฆฌ์ž ์˜์—ญ | `/admin/*` | ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™•์ธ. ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ๊ด€๋ฆฌ์ž ํ™”๋ฉด์— ๋‚จ์„ ์ˆ˜ ์—†์Œ | +| API Route Handler | `/api/*` | middleware matcher์—์„œ ์ œ์™ธ๋˜๋ฏ€๋กœ ๊ณต๊ฐœ ์กฐํšŒ handler๋Š” ์ž…๋ ฅ์„ ๊ฒ€์ฆํ•˜๊ณ , ๊ฐœ์ธํ™”/๋ณ€๊ฒฝ handler๋Š” ์„ธ์…˜๊ณผ ๊ถŒํ•œ์„ ์ง์ ‘ ํ™•์ธ | +| Server Action | `features/*/actions/*` | ์‚ฌ์šฉ์ž ์˜๋„ ๊ธฐ๋ฐ˜ ๋ณ€๊ฒฝ ์ž‘์—…์€ action ๋‚ด๋ถ€์—์„œ ์„ธ์…˜์„ ์ฝ๊ณ  service์— actor ID ์ „๋‹ฌ | +| Webhook | `/api/webhooks/cloudflare` | Cloudflare Stream signature ๋˜๋Š” Destination secret ๊ธฐ์ค€์œผ๋กœ ์ธ์ฆ. production secret ๋ˆ„๋ฝ ์‹œ fail-closed | + +## 3. ๋„๋ฉ”์ธ๋ณ„ ๋งคํŠธ๋ฆญ์Šค + +### Product / Marketplace + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| ------------------- | ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| ์ƒํ’ˆ ๋“ฑ๋ก | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ์‚ฌ์šฉ์ž ์ƒํƒœ, ์ž…๋ ฅ ์Šคํ‚ค๋งˆ | ์ง€์—ญ, ์นดํ…Œ๊ณ ๋ฆฌ, ์ด๋ฏธ์ง€ ๋“ฑ ๋„๋ฉ”์ธ validation ์ ์šฉ | +| ์ƒํ’ˆ ์ˆ˜์ •/์‚ญ์ œ | ์ƒํ’ˆ ์ž‘์„ฑ์ž | ์„ธ์…˜ userId์™€ product.userId ๋น„๊ต | ์‚ญ์ œ ์‹œ ๊ด€๋ จ ์•Œ๋ฆผ, ๋ฆฌ๋ทฐ, ์ฑ„ํŒ… ์ฐธ์กฐ ์ •๋ฆฌ | +| ์ข‹์•„์š” | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ์ฐจ๋‹จ ๊ด€๊ณ„, ์ž๊ธฐ ์ƒํ’ˆ ์—ฌ๋ถ€ | ๊ฐœ์ธํ™” ์บ์‹œ๋Š” viewer๋ณ„ query key๋กœ ๋ถ„๋ฆฌ | +| ์˜ˆ์•ฝ/ํŒ๋งค ์ƒํƒœ ๋ณ€๊ฒฝ | ์ƒํ’ˆ ์ž‘์„ฑ์ž | ์„ธ์…˜, product.userId, ํ˜„์žฌ ๊ฑฐ๋ž˜ ์ƒํƒœ ์กฐ๊ฑด | ์˜ˆ์•ฝ์ž/๊ตฌ๋งค์ž ์ •๋ณด์™€ ๊ด€๋ จ ์•ฝ์† ์ƒํƒœ๋ฅผ ํ•จ๊ป˜ ์ •๋ฆฌ | +| ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | ์•ฑ ํ™”๋ฉด ์‚ฌ์šฉ์ž, ๋น„๊ฐœ์ธํ™” ์ง์ ‘ ์š”์ฒญ | ์ง€์—ญ/๊ฒ€์ƒ‰ ํ•„ํ„ฐ, ์„œ๋ฒ„ ์„ธ์…˜ ๊ธฐ๋ฐ˜ viewerId, ์ฐจ๋‹จ ๊ด€๊ณ„ | `/products` ํ™”๋ฉด์€ ๋กœ๊ทธ์ธ ๋ณดํ˜ธ. ์ง์ ‘ API ํ˜ธ์ถœ์€ ์„œ๋ฒ„๊ฐ€ ์„ธ์…˜ ๋ถ€์žฌ ์‹œ `-1` sentinel์„ ์ฃผ์ž…ํ•œ ๋น„๊ฐœ์ธํ™” ๋ชฉ๋ก์œผ๋กœ ์ฒ˜๋ฆฌ | + +### Chat / Appointment + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| -------------- | -------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- | +| ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ | ์ƒํ’ˆ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€, ์ •์ง€ ์ƒํƒœ, ์ฐจ๋‹จ ๊ด€๊ณ„ | ๋™์ผ ์ƒํ’ˆ/์‚ฌ์šฉ์ž ์กฐํ•ฉ์€ ๊ธฐ์กด ๋ฐฉ ์žฌ์‚ฌ์šฉ๊ณผ ์ธ๋ฉ”๋ชจ๋ฆฌ lock์œผ๋กœ ๋‹จ์ผ ์ธ์Šคํ„ด์Šค ์ค‘๋ณต ๋ฐฉ์–ด | +| ์ฑ„ํŒ…๋ฐฉ ์กฐํšŒ | ์ฐธ์—ฌ์ž | `checkChatRoomAccess` | ์ฐธ์—ฌ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์ƒ์„ธ ์ง„์ž… ๋ถˆ๊ฐ€ | +| ๋ฉ”์‹œ์ง€ ์ „์†ก | ์ฐธ์—ฌ์ž | ์„ธ์…˜, ๋ฐฉ ์ฐธ์—ฌ ์—ฌ๋ถ€, ์ฐจ๋‹จ/์ •์ง€ ์ƒํƒœ | ์ „์†ก ํ›„ Realtime ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ | +| ์•ฝ์† ์ œ์•ˆ | ์ฐธ์—ฌ์ž | ์„ธ์…˜, ๋ฐฉ ์ฐธ์—ฌ ์—ฌ๋ถ€, ์•ฝ์† ์‹œ๊ฐ„/์žฅ์†Œ validation | ๊ฐ™์€ ์ฑ„ํŒ…๋ฐฉ์˜ ๊ธฐ์กด PENDING ์•ฝ์† ์ •๋ฆฌ | +| ์•ฝ์† ์ˆ˜๋ฝ/๊ฑฐ์ ˆ | ์ˆ˜์‹ ์ž | ์„ธ์…˜, receiverId, ํ˜„์žฌ ์•ฝ์† ์ƒํƒœ, ์ƒํ’ˆ ๊ฑฐ๋ž˜ ์ƒํƒœ | ์ˆ˜๋ฝ ์‹œ ์•ฝ์†๊ณผ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ „ํ™˜์„ transaction์œผ๋กœ ์ฒ˜๋ฆฌ | + +### Post / Comment / Media + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| ------------------ | --------------------------------- | ----------------------------------------- | -------------------------------------------------- | +| ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ์ž…๋ ฅ ์Šคํ‚ค๋งˆ, ์ฒจ๋ถ€ draft ์†Œ์œ ์ž ํ™•์ธ | ์ด๋ฏธ์ง€/๋™์˜์ƒ/์ž„๋ฒ ๋“œ ๋ธ”๋ก ์ง€์› | +| ๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ •/์‚ญ์ œ | ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž | ์„ธ์…˜ userId์™€ post.userId ๋น„๊ต | ์‚ญ์ œ ํ›„ ๋ชฉ๋ก cache์™€ stale cursor ์ •๋ฆฌ | +| ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ๊ฒŒ์‹œ๊ธ€ ์กด์žฌ ์—ฌ๋ถ€, ์ฐจ๋‹จ ๊ด€๊ณ„ | ๋Œ“๊ธ€ ๋ชฉ๋ก์€ ์ฐจ๋‹จ ๊ด€๊ณ„๋ฅผ ๋ฐ˜์˜ | +| ๋Œ“๊ธ€ ์‚ญ์ œ | ๋Œ“๊ธ€ ์ž‘์„ฑ์ž ๋˜๋Š” ์ •์ฑ…์ƒ ํ—ˆ์šฉ ์ฃผ์ฒด | ์„ธ์…˜๊ณผ ๋Œ“๊ธ€ ์ž‘์„ฑ์ž ํ™•์ธ | ์ƒ์„ธ ๊ถŒํ•œ์€ comment service ๊ธฐ์ค€ | +| ๊ฒŒ์‹œ๊ธ€ ๋™์˜์ƒ ์—ฐ๊ฒฐ | draft ์ž‘์„ฑ์ž | draftKey์™€ userId ๋™์‹œ ํ™•์ธ | READY ์„ ๋„์ฐฉ ์‹œ draftKey๋ฅผ ๊ฒŒ์‹œ๊ธ€ ์ €์žฅ ์ „๊นŒ์ง€ ๋ณด์กด | + +### Stream / VOD + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| -------------------- | --------------------------- | ---------------------------- | ----------------------------------------------------------------- | +| ๋ฐฉ์†ก ์ƒ์„ฑ/๊ด€๋ฆฌ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž, ๋ฐฉ์†ก ์†Œ์œ ์ž | ์„ธ์…˜, liveInput ์†Œ์œ ์ž ํ™•์ธ | Cloudflare Stream ์—ฐ๋™ | +| ๋ผ์ด๋ธŒ/VOD ๋ชฉ๋ก ์กฐํšŒ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | Route Handler ๋‚ด๋ถ€ ์„ธ์…˜ ID | query `viewerId` fallback ์ œ๊ฑฐ. ๋น„๋กœ๊ทธ์ธ ์ง์ ‘ ํ˜ธ์ถœ์€ ๋นˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜ | +| PUBLIC ์ƒ์„ธ/์žฌ์ƒ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ์ฐจ๋‹จ ๊ด€๊ณ„, ๋ฐฉ์†ก ์ƒํƒœ | `/streams`๋Š” ๋กœ๊ทธ์ธ ๋ณดํ˜ธ ํŽ˜์ด์ง€ | +| FOLLOWERS ์ƒ์„ธ/์žฌ์ƒ | ์†Œ์œ ์ž ๋˜๋Š” ํŒ”๋กœ์›Œ | ์„ธ์…˜, ํŒ”๋กœ์šฐ ๊ด€๊ณ„ | ํŒ”๋กœ์šฐ ํ›„ query invalidation์œผ๋กœ ๋ชฉ๋ก/์ž ๊ธˆ ์ƒํƒœ ์ˆ˜๋ ด | +| PRIVATE ์ƒ์„ธ/์žฌ์ƒ | ์†Œ์œ ์ž ๋˜๋Š” unlock๋œ ์‚ฌ์šฉ์ž | ์„ธ์…˜, private unlock session | ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์ œ ์ƒํƒœ๋Š” session ๊ธฐ๋ฐ˜์œผ๋กœ ํ™•์ธ | +| VOD ์ข‹์•„์š”/๋Œ“๊ธ€ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, VOD ์ƒํƒœ, ์ฐจ๋‹จ ๊ด€๊ณ„ | ์ข‹์•„์š” ์ƒํƒœ๋Š” viewer๋ณ„ query key๋กœ ๋ถ„๋ฆฌ | + +์ด ๋ฌธ์„œ์—์„œ PUBLIC์€ ๋น„๋กœ๊ทธ์ธ ์ธํ„ฐ๋„ท ๊ณต๊ฐœ๊ฐ€ ์•„๋‹ˆ๋ผ, ๋กœ๊ทธ์ธํ•œ BoardPort ์•ฑ ์‚ฌ์šฉ์ž์—๊ฒŒ ๊ณต๊ฐœ๋˜๋Š” ๋ฒ”์œ„๋ฅผ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค. + +### Notification / Push + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| --------------------------- | ------------------- | ---------------------------------------------- | -------------------------------------------------- | +| ์•Œ๋ฆผ ๋ชฉ๋ก ์กฐํšŒ | ๋ณธ์ธ | ์„ธ์…˜ userId | ์‚ญ์ œ๋œ ์ฝ˜ํ…์ธ  ์•Œ๋ฆผ์€ ์‘๋‹ต ๋‹จ๊ณ„์—์„œ link/image ์ •๋ฆฌ | +| ์•Œ๋ฆผ ์„ค์ • ๋ณ€๊ฒฝ | ๋ณธ์ธ | ์„ธ์…˜ userId | In-App ์„ค์ •๊ณผ Push ์ •์ฑ… ๋ถ„๋ฆฌ | +| Push subscription ๋“ฑ๋ก/์‚ญ์ œ | ๋ณธ์ธ ๋ธŒ๋ผ์šฐ์ € ๊ตฌ๋… | ์„ธ์…˜, endpoint/userId unique ๊ธฐ์ค€ | endpoint์™€ userId ์กฐํ•ฉ์œผ๋กœ ์ค‘๋ณต ๊ตฌ๋… ๋ฐฉ์ง€ | +| Push ๋ฐœ์†ก | ์„œ๋ฒ„ ์ •์ฑ… ํ†ต๊ณผ ๋Œ€์ƒ | ์•Œ๋ฆผ ํƒ€์ž… ์„ค์ •, quiet hours, subscription ์ƒํƒœ | In-App ์•Œ๋ฆผ๊ณผ Web Push๋Š” ๋ณ„๋„ ์ •์ฑ…์œผ๋กœ ์ฒ˜๋ฆฌ | + +### Report / Admin + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| ---------------- | ------------- | -------------------------------------- | -------------------------------------------- | +| ์‹ ๊ณ  ์ƒ์„ฑ | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž | ์„ธ์…˜, ๋Œ€์ƒ ์กด์žฌ ์—ฌ๋ถ€, ์ค‘๋ณต ์‹ ๊ณ  unique | userId, targetType, targetId ๊ธฐ์ค€ ์ค‘๋ณต ๋ฐฉ์ง€ | +| ์‹ ๊ณ  ๋ชฉ๋ก/์ฒ˜๋ฆฌ | ๊ด€๋ฆฌ์ž | ๊ด€๋ฆฌ์ž ๊ถŒํ•œ, ์ฒ˜๋ฆฌ ์ƒํƒœ | ์‹ ๊ณ  ์ฒ˜๋ฆฌ, ์ฝ˜ํ…์ธ  ์กฐ์น˜, ์œ ์ € ์ œ์žฌ ๊ธฐ๋ก | +| ๊ฐ์‚ฌ ๋กœ๊ทธ ์กฐํšŒ | ๊ด€๋ฆฌ์ž | ๊ด€๋ฆฌ์ž ๊ถŒํ•œ | ์ฒ˜๋ฆฌ์ž, ๋Œ€์ƒ, action, trace URL ๊ธฐ์ค€ ์ถ”์  | +| ๊ด€๋ฆฌ์ž ํ™”๋ฉด ์ ‘๊ทผ | ๊ด€๋ฆฌ์ž | middleware ๋ฐ ์„œ๋ฒ„ ์ธก ๊ถŒํ•œ ํ™•์ธ | ์ผ๋ฐ˜ ๊ณ„์ •์˜ ๊ด€๋ฆฌ์ž ํ™”๋ฉด ์ ‘๊ทผ ์ฐจ๋‹จ E2E๋กœ ํ™•์ธ | + +### Webhook / External Event + +| ํ–‰์œ„ | ํ—ˆ์šฉ ๋Œ€์ƒ | ๋Œ€ํ‘œ ๊ฐ€๋“œ | ๋น„๊ณ  | +| ------------------------------ | ------------------------- | ---------------------------------------------------- | ---------------------------------------------- | +| Cloudflare Stream webhook | Cloudflare ์„œ๋ช… ์š”์ฒญ | raw body HMAC, timestamp skew, constant-time compare | `CLOUDFLARE_STREAM_WEBHOOK_SECRET` ํ•„์š” | +| Cloudflare Destination webhook | secret header๊ฐ€ ๋งž๋Š” ์š”์ฒญ | destination secret header ํ™•์ธ | `CLOUDFLARE_WEBHOOK_SECRET` ํ•„์š” | +| production secret ๋ˆ„๋ฝ | ํ—ˆ์šฉํ•˜์ง€ ์•Š์Œ | `WEBHOOK_SECRET_NOT_CONFIGURED` 500 ๋ฐ˜ํ™˜ | DB ๊ฐฑ์‹ , Realtime ์†ก์‹ , ์•Œ๋ฆผ ์†ก์‹  ์‹œ์ž‘ ์ „ ์ฐจ๋‹จ | +| Handshake / empty body | ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์Œ | ์ƒํƒœ ๋ณ€๊ฒฝ ์ „ ์กฐ๊ธฐ ์‘๋‹ต | Cloudflare ๋“ฑ๋ก ๊ฒ€์ฆ ํ๋ฆ„์„ ์œ ์ง€ | + +## 4. ๋ฆด๋ฆฌ์ฆˆ ์ „ ํ™•์ธ ํฌ์ธํŠธ + +- `/api/*`๊ฐ€ middleware ๋ณดํ˜ธ๋ฅผ ๋ฐ›๋Š”๋‹ค๊ณ  ๊ฐ€์ •ํ•˜์ง€ ์•Š์•˜๋Š”๊ฐ€? +- Route Handler์—์„œ ์กฐํšŒ์ž ID๋ฅผ query/body ์ž…๋ ฅ์œผ๋กœ ์‹ ๋ขฐํ•˜์ง€ ์•Š๋Š”๊ฐ€? +- Server Action์ด ์„ธ์…˜ ์—†์ด actor ID๋ฅผ ์™ธ๋ถ€ ์ž…๋ ฅ์œผ๋กœ ๋ฐ›์ง€ ์•Š๋Š”๊ฐ€? +- ๋ชฉ๋ก, ์ƒ์„ธ, ์ข‹์•„์š”, ์ž ๊ธˆ ์ƒํƒœ์ฒ˜๋Ÿผ ๊ฐœ์ธํ™” ์‘๋‹ต์„ ๋งŒ๋“œ๋Š” query key์— viewer scope๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”๊ฐ€? +- production webhook secret ๋ˆ„๋ฝ ์‹œ ์ƒํƒœ ๋ณ€๊ฒฝ ์ฒ˜๋ฆฌ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š๋Š”๊ฐ€? +- ๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ์€ ํ™”๋ฉด ์ ‘๊ทผ๋ฟ ์•„๋‹ˆ๋ผ action/service ๋‹จ๊ณ„์—์„œ๋„ ๊ถŒํ•œ์„ ํ™•์ธํ•˜๋Š”๊ฐ€? +- ์ฐจ๋‹จ ๊ด€๊ณ„, ์ •์ง€ ์‚ฌ์šฉ์ž, ์‚ญ์ œ๋œ ์ฝ˜ํ…์ธ , ๋‚˜๊ฐ„ ์ฑ„ํŒ… ์ฐธ์—ฌ์ž ๊ฐ™์€ ๋น„์ •์ƒ ์ƒํƒœ๊ฐ€ service ๊ณ„์ธต์—์„œ ๋ฐฉ์–ด๋˜๋Š”๊ฐ€? + +## 5. ํ•จ๊ป˜ ๋ณด๋Š” ๋ฌธ์„œ + +- [ํ…Œ์ŠคํŠธ ์ „๋žต](./testing-strategy.md) +- [์‹ ๊ณ  ์ฒ˜๋ฆฌ์™€ ์ œ์žฌ ์šด์˜ ์ •์ฑ…](./report-moderation-policy.md) +- [๋ณด์•ˆ ํ—ค๋” / CSP ์šด์˜ ์ •์ฑ…](./security-headers-csp-policy.md) +- [์ง๊ฑฐ๋ž˜ ์•ฝ์† ์ˆ˜๋ฝ๊ณผ ์ƒํ’ˆ ์ƒํƒœ ์›์ž์  ์ „ํ™˜](../troubleshooting/troubleshooting-appointment-atomic-transition.md) +- [๊ฒŒ์‹œ๊ธ€ ๋™์˜์ƒ Cloudflare ์›นํ›… ์ƒํƒœ ์ „ํ™˜](../troubleshooting/troubleshooting-post-video-cloudflare-webhook.md) From 89b99e331e370e1d70bd5f9d99d294cbdd803668 Mon Sep 17 00:00:00 2001 From: DoHeonLim Date: Sat, 27 Jun 2026 17:00:25 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E2=98=94=EF=B8=8F=20Test=20:=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=C2=B7=EC=83=81=ED=92=88=C2=B7=EB=B0=A9=EC=86=A1=20E2E?= =?UTF-8?q?=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [๐Ÿ’ฌ Chat] ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ ํ›„ ํ™•์ • ์ƒํƒœ์™€ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ ์œ ์ง€ ๊ฒ€์ฆ ์ˆ˜๋ฝ ํ›„ ์ƒˆ๋กœ๊ณ ์นจ๊ณผ ์ƒํ’ˆ ๋ชฉ๋ก/์ƒ์„ธ ์ง„์ž…์—์„œ๋„ ์˜ˆ์•ฝ ์ƒํƒœ ํ™•์ธ [๐Ÿ›๏ธ Product] ์ƒํ’ˆ ๋ชฉ๋ก์—์„œ ๋ชจ๋‹ฌ ์ƒ์„ธ ์ง„์ž… ํ›„ ์ˆ˜์ • ์ €์žฅ ๊ฒ€์ฆ ์ˆ˜์ • ํ›„ ์›๋ž˜ ๋ชฉ๋ก ๊ฒ€์ƒ‰ ๋ฌธ๋งฅ ๋ณต๊ท€์™€ ๋ณ€๊ฒฝ ๋‚ด์šฉ ๋ฐ˜์˜ ํ™•์ธ [๐ŸŽฅ Stream] ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ž ๊ธˆ ํ•ด์ œ ๊ฒ€์ฆ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก ๋…ธ์ถœ๊ณผ VOD ์ƒ์„ธ ์ ‘๊ทผ๊นŒ์ง€ ์ด์–ด์ง€๋Š” ์ƒํƒœ ์ˆ˜๋ ด ํ™•์ธ [๐Ÿงช E2E Seed] ์•ฝ์† ์ˆ˜๋ฝ ์ƒํ’ˆ ์ƒํƒœ ๋ณต์› seed ๋ณด๊ฐ• ๋ชจ๋‹ฌ ์ˆ˜์ • ์ƒํ’ˆ๊ณผ ํŒ”๋กœ์›Œ ์ „์šฉ VOD seed ์ถ”๊ฐ€ E2E ๊ณ„์ • ๊ฐ„ follow ๊ด€๊ณ„ cleanup ์ถ”๊ฐ€ ์ƒํƒœ ๋ณ€๊ฒฝํ˜• E2E ๋ถ„๋ฆฌ ์‹คํ–‰ ์‹œ seed ์žฌ์‹คํ–‰ ๊ธฐ์ค€ ๋ฌธ์„œํ™” [โœ… Verification] git diff --check npx tsc --noEmit npm run lint npm run test # 23 files / 107 tests E2E_SEEDED=1 npm run test:e2e -- tests/e2e/chat-appointment.spec.ts tests/e2e/product-modal-edit.spec.ts tests/e2e/stream-follow-access.spec.ts --project=chromium # 3 passed E2E_SEEDED=1 npm run test:e2e -- --project=chromium # 28 passed npm run cleanup:e2e --- docs/operations/testing-strategy.md | 1 + scripts/cleanup-e2e.ts | 11 +++ scripts/seed-e2e.ts | 84 +++++++++++++++++- tests/e2e/README.md | 7 +- tests/e2e/chat-appointment.spec.ts | 33 +++++++- tests/e2e/product-modal-edit.spec.ts | 113 +++++++++++++++++++++++++ tests/e2e/stream-follow-access.spec.ts | 87 +++++++++++++++++++ 7 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/product-modal-edit.spec.ts create mode 100644 tests/e2e/stream-follow-access.spec.ts diff --git a/docs/operations/testing-strategy.md b/docs/operations/testing-strategy.md index 6281c274..7b957679 100644 --- a/docs/operations/testing-strategy.md +++ b/docs/operations/testing-strategy.md @@ -86,6 +86,7 @@ npm run install:e2e DB ์ƒํƒœ๊ฐ€ ํ•„์š”ํ•œ E2E๋Š” `npm run seed:e2e`๋กœ `[E2E]` prefix ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋จผ์ € ์ค€๋น„ํ•œ ๋’ค ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ Playwright ์‹คํ–‰์—์„œ๋Š” seed ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋ฅผ skipํ•˜๊ณ , seed๋ฅผ ์‹คํ–‰ํ•œ ๋’ค `E2E_SEEDED=1`์„ ์ง€์ •ํ–ˆ์„ ๋•Œ๋งŒ ํ•จ๊ป˜ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. seed ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๋๋‚œ ๋’ค์—๋Š” `npm run cleanup:e2e`๋กœ `[E2E]` prefix ์ฝ˜ํ…์ธ ์™€ ํ…Œ์ŠคํŠธ ๊ณ„์ • ์•Œ๋ฆผ์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +ํŠน์ • spec์„ ๋จผ์ € ์‹คํ–‰ํ•œ ๋’ค ์ „์ฒด suite๋ฅผ ๋‹ค์‹œ ์‹คํ–‰ํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ Playwright ์‹คํ–‰์„ ๋‚˜๋ˆŒ ๋•Œ๋Š” ๊ฐ ์‹คํ–‰ ์ „์— `npm run seed:e2e`๋ฅผ ๋‹ค์‹œ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. ์•ฝ์† ์ˆ˜๋ฝ, ์ƒํ’ˆ ์ˆ˜์ •, ํŒ”๋กœ์šฐ ํ…Œ์ŠคํŠธ๋Š” seed ๋ฐ์ดํ„ฐ๋ฅผ ์‹ค์ œ๋กœ ๋ณ€๊ฒฝํ•˜๋ฏ€๋กœ ์‹คํ–‰ ๋‹จ์œ„๋งˆ๋‹ค ๊ธฐ์ค€ ์ƒํƒœ๋ฅผ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค. ```powershell npm run seed:e2e diff --git a/scripts/cleanup-e2e.ts b/scripts/cleanup-e2e.ts index ca11ec50..7f746636 100644 --- a/scripts/cleanup-e2e.ts +++ b/scripts/cleanup-e2e.ts @@ -10,6 +10,7 @@ * 2026.05.26 ์ž„๋„ํ—Œ Modified E2E ๋ณด๋“œ๊ฒŒ์ž„ ๋„๊ฐ seed cleanup ๊ธฐ์ค€ ์ถ”๊ฐ€ * 2026.05.26 ์ž„๋„ํ—Œ Modified E2E ๋ฐฉ์†ก/VOD seed cleanup ๊ธฐ์ค€ ์ถ”๊ฐ€ * 2026.05.26 ์ž„๋„ํ—Œ Modified E2E ์‹ ๊ณ  ์ฒ˜๋ฆฌ seed์™€ ๊ฐ์‚ฌ ๋กœ๊ทธ cleanup ๊ธฐ์ค€ ์ถ”๊ฐ€ + * 2026.06.26 ์ž„๋„ํ—Œ Modified E2E ๊ณ„์ • ๊ฐ„ ํŒ”๋กœ์šฐ ๊ด€๊ณ„ cleanup ์ถ”๊ฐ€ */ import { existsSync, readFileSync } from "node:fs"; @@ -98,6 +99,15 @@ async function cleanupE2EData() { }); const e2eUserIds = e2eUsers.map((user) => user.id); + const followResult = await db.follow.deleteMany({ + where: { + OR: [ + { followerId: { in: e2eUserIds } }, + { followingId: { in: e2eUserIds } }, + ], + }, + }); + const notificationResult = await db.notification.deleteMany({ where: { OR: [ @@ -179,6 +189,7 @@ async function cleanupE2EData() { }); console.log("[E2E cleanup] removed test data"); + console.log(`- follows : ${followResult.count}`); console.log(`- notifications: ${notificationResult.count}`); console.log(`- reviews : ${reviewResult.count}`); console.log(`- reports : ${reportResult.count}`); diff --git a/scripts/seed-e2e.ts b/scripts/seed-e2e.ts index 7d429e79..ff6de9ff 100644 --- a/scripts/seed-e2e.ts +++ b/scripts/seed-e2e.ts @@ -41,9 +41,12 @@ const E2E_CHAT_MESSAGE = `${E2E_PREFIX} ์ฑ„ํŒ… ๋ชฉ๋ก ํšŒ๊ท€ ๋ฉ”์‹œ์ง€`; const E2E_APPOINTMENT_PRODUCT_TITLE = `${E2E_PREFIX} ์•ฝ์† ์ˆ˜๋ฝ ์ƒํ’ˆ`; const E2E_APPOINTMENT_MESSAGE = `${E2E_PREFIX} ์•ฝ์† ์ˆ˜๋ฝ ํšŒ๊ท€ ์ œ์•ˆ`; const E2E_DELETE_PRODUCT_TITLE = `${E2E_PREFIX} ์ƒํ’ˆ ์‚ญ์ œ ๋ณต๊ท€ ํ…Œ์ŠคํŠธ`; +const E2E_MODAL_EDIT_PRODUCT_TITLE = `${E2E_PREFIX} ๋ชจ๋‹ฌ ์ˆ˜์ • ๋ณต๊ท€ ์ƒํ’ˆ`; +const E2E_MODAL_EDIT_PRODUCT_DESCRIPTION = `${E2E_MODAL_EDIT_PRODUCT_TITLE} ์„ค๋ช…์ž…๋‹ˆ๋‹ค.`; const E2E_REPORT_DESCRIPTION = `${E2E_PREFIX} ๊ด€๋ฆฌ์ž ์‹ ๊ณ  ์ฒ˜๋ฆฌ ํšŒ๊ท€ ๋Œ€์ƒ`; const E2E_BOARDGAME_TITLE = `${E2E_PREFIX} ํ•ญํ•ด์ž์˜ ๋„๊ฐ`; const E2E_VOD_TITLE = `${E2E_PREFIX} ๋‹ค์‹œ๋ณด๊ธฐ ํšŒ๊ท€ ๋ฐฉ์†ก`; +const E2E_FOLLOWERS_VOD_TITLE = `${E2E_PREFIX} ํŒ”๋กœ์›Œ ์ „์šฉ ํšŒ๊ท€ ๋ฐฉ์†ก`; const E2E_USERS = { seller: { @@ -242,6 +245,7 @@ async function createProduct( sellerId: number; categoryId: number; imageUrl?: string; + description?: string; resetTradeState?: boolean; } ) { @@ -268,6 +272,8 @@ async function createProduct( purchase_userId: null, } : {}), + ...(input.description ? { description: input.description } : {}), + completeness: "PERFECT", hidden_at: null, ...E2E_LOCATION, }, @@ -300,7 +306,7 @@ async function createProduct( data: { title: input.title, price: 12000, - description: `${input.title} ์„ค๋ช…์ž…๋‹ˆ๋‹ค.`, + description: input.description ?? `${input.title} ์„ค๋ช…์ž…๋‹ˆ๋‹ค.`, userId: input.sellerId, categoryId: input.categoryId, game_type: "BOARD_GAME", @@ -308,7 +314,7 @@ async function createProduct( max_players: 4, play_time: "30๋ถ„", condition: "GOOD", - completeness: "COMPLETE", + completeness: "PERFECT", has_manual: true, ...E2E_LOCATION, images: input.imageUrl @@ -548,7 +554,15 @@ async function createBoardGameSeed(db: PrismaClient, reviewerId: number) { * * ์™ธ๋ถ€ Cloudflare ์›นํ›… ์—†์ด ์•ฑ์ด ์ด๋ฏธ ์ฒ˜๋ฆฌ ์™„๋ฃŒ๋œ VOD๋ฅผ ์ฝ๋Š” ๊ฒฝ๋กœ๋งŒ ๊ฒ€์ฆ */ -async function createVodSeed(db: PrismaClient, ownerId: number) { +async function createVodSeed( + db: PrismaClient, + input: { ownerId: number; initialVisitorId: number } +) { + const { ownerId, initialVisitorId } = input; + await db.follow.deleteMany({ + where: { followerId: initialVisitorId, followingId: ownerId }, + }); + const liveInput = await db.liveInput.upsert({ where: { userId: ownerId }, update: { @@ -604,6 +618,57 @@ async function createVodSeed(db: PrismaClient, ownerId: number) { ready_at: new Date(), }, }); + + const existingFollowersBroadcast = await db.broadcast.findFirst({ + where: { liveInputId: liveInput.id, title: E2E_FOLLOWERS_VOD_TITLE }, + select: { id: true }, + }); + + const followersBroadcast = existingFollowersBroadcast + ? await db.broadcast.update({ + where: { id: existingFollowersBroadcast.id }, + data: { + description: + "E2E ํšŒ๊ท€ ํ…Œ์ŠคํŠธ์—์„œ ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ ‘๊ทผ ์ˆ˜๋ ด์„ ํ™•์ธํ•˜๋Š” ๋ฐฉ์†ก์ž…๋‹ˆ๋‹ค.", + thumbnail: E2E_PRODUCT_IMAGE_URL, + visibility: "FOLLOWERS", + status: "ENDED", + started_at: new Date(Date.now() - 90 * 60 * 1000), + ended_at: new Date(Date.now() - 45 * 60 * 1000), + }, + select: { id: true }, + }) + : await db.broadcast.create({ + data: { + liveInputId: liveInput.id, + title: E2E_FOLLOWERS_VOD_TITLE, + description: + "E2E ํšŒ๊ท€ ํ…Œ์ŠคํŠธ์—์„œ ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ ‘๊ทผ ์ˆ˜๋ ด์„ ํ™•์ธํ•˜๋Š” ๋ฐฉ์†ก์ž…๋‹ˆ๋‹ค.", + thumbnail: E2E_PRODUCT_IMAGE_URL, + visibility: "FOLLOWERS", + status: "ENDED", + started_at: new Date(Date.now() - 90 * 60 * 1000), + ended_at: new Date(Date.now() - 45 * 60 * 1000), + }, + select: { id: true }, + }); + + await db.vodAsset.upsert({ + where: { provider_asset_id: "e2e-followers-vod-asset-990002" }, + update: { + broadcastId: followersBroadcast.id, + thumbnail_url: E2E_PRODUCT_IMAGE_URL, + duration_sec: 1200, + ready_at: new Date(), + }, + create: { + broadcastId: followersBroadcast.id, + provider_asset_id: "e2e-followers-vod-asset-990002", + thumbnail_url: E2E_PRODUCT_IMAGE_URL, + duration_sec: 1200, + ready_at: new Date(), + }, + }); } /** @@ -719,6 +784,14 @@ async function seedE2EData() { imageUrl: E2E_PRODUCT_IMAGE_URL, resetTradeState: true, }); + await createProduct(db, { + title: E2E_MODAL_EDIT_PRODUCT_TITLE, + sellerId: seller.id, + categoryId: category.id, + imageUrl: E2E_PRODUCT_IMAGE_URL, + description: E2E_MODAL_EDIT_PRODUCT_DESCRIPTION, + resetTradeState: true, + }); await createChatRoomSeed(db, { productId: product.id, sellerId: seller.id, @@ -734,7 +807,10 @@ async function seedE2EData() { targetProductId: deleteProduct.id, }); await createBoardGameSeed(db, admin.id); - await createVodSeed(db, seller.id); + await createVodSeed(db, { + ownerId: seller.id, + initialVisitorId: buyer.id, + }); const [{ _max: maxProductId }, { _max: maxPostId }] = await Promise.all([ db.product.aggregate({ _max: { id: true } }), diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 043118f8..dd4884be 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -50,12 +50,15 @@ Remove-Item Env:E2E_SEEDED npm run cleanup:e2e ``` +ํŠน์ • spec๋งŒ ๋จผ์ € ํ™•์ธํ•œ ๋’ค ๊ฐ™์€ ํ„ฐ๋ฏธ๋„์—์„œ ์ „์ฒด suite๋ฅผ ๋‹ค์‹œ ์‹คํ–‰ํ•  ๋•Œ๋Š” ์ค‘๊ฐ„์— `npm run seed:e2e`๋ฅผ ํ•œ ๋ฒˆ ๋” ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. ์•ฝ์† ์ˆ˜๋ฝ, ์ƒํ’ˆ ์ˆ˜์ •, ํŒ”๋กœ์šฐ์ฒ˜๋Ÿผ seed ์ƒํƒœ๋ฅผ ์‹ค์ œ๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ํ…Œ์ŠคํŠธ๊ฐ€ ์žˆ์œผ๋ฏ€๋กœ, Playwright ์‹คํ–‰ ๋‹จ์œ„๋งˆ๋‹ค seed ๊ธฐ์ค€ ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ๋งž์ถฅ๋‹ˆ๋‹ค. + ## ๋ฐ์ดํ„ฐ ์›์น™ - E2E ๋ฐ์ดํ„ฐ๋Š” ์ œ๋ชฉ, ๋ณธ๋ฌธ, ์•Œ๋ฆผ ๋ฌธ๊ตฌ์— `[E2E]` prefix๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. - `npm run cleanup:e2e`๋Š” `[E2E]` prefix ์ฝ˜ํ…์ธ ์™€ E2E ๊ณ„์ • ์•Œ๋ฆผ์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. - E2E ์ „์šฉ ๊ณ„์ •์€ ๋กœ๊ทธ์ธ ์•ˆ์ •์„ฑ์„ ์œ„ํ•ด ์žฌ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. - `npm run seed:e2e`๋Š” ํ•„์š”ํ•œ ๊ณ„์ •/์ฝ˜ํ…์ธ /์•Œ๋ฆผ์ด ์—†์œผ๋ฉด ์ƒ์„ฑํ•˜๊ณ , ์ด๋ฏธ ์žˆ์œผ๋ฉด ์žฌ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- ์ƒํƒœ ๋ณ€๊ฒฝ E2E๋ฅผ ์—ฌ๋Ÿฌ ๋ฒˆ ๋‚˜๋ˆ„์–ด ์‹คํ–‰ํ•  ๋•Œ๋Š” ๊ฐ Playwright ์‹คํ–‰ ์ „์— `npm run seed:e2e`๋กœ ์ƒํ’ˆ ์ƒํƒœ, ์•ฝ์† ์ƒํƒœ, ํŒ”๋กœ์šฐ ๊ด€๊ณ„๋ฅผ ๊ธฐ์ค€ ์ƒํƒœ๋กœ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค. - ์šด์˜ DB๊ฐ€ ์•„๋‹ˆ๋ผ ๋กœ์ปฌ/ํ…Œ์ŠคํŠธ DB๋ฅผ ๋Œ€์ƒ์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. - seed ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋Š” `E2E_SEEDED=1`์ด ์—†์œผ๋ฉด skip๋ฉ๋‹ˆ๋‹ค. - ์‹ค์ œ ์™ธ๋ถ€ ์„œ๋น„์Šค ํ˜ธ์ถœ์ด ํ•„์š”ํ•œ Cloudflare, Kakao, Push, SMS, Email ์‹œ๋‚˜๋ฆฌ์˜ค๋Š” ๋ณ„๋„ mock ๋˜๋Š” ์ „์šฉ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์ด ์ค€๋น„๋œ ๋’ค ํ™•์žฅํ•ฉ๋‹ˆ๋‹ค. @@ -70,7 +73,9 @@ npm run cleanup:e2e - ์ƒํ’ˆ ์‚ญ์ œ ํ›„ `/products` ๋ชฉ๋ก ๋ณต๊ท€์™€ ์‚ญ์ œ ์ƒ์„ธ ์ž”์ƒ ๋ฐฉ์ง€ - ์ƒํ’ˆ ๋“ฑ๋ก ํผ์˜ ํ•„์ˆ˜ ์ž…๋ ฅ validation - ์ฑ„ํŒ… ๋ชฉ๋ก์—์„œ seed ๋Œ€ํ™” ๋…ธ์ถœ๊ณผ ์ฑ„ํŒ… ์ƒ์„ธ ์ง„์ž… -- ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ ํ›„ ํ™•์ • ์ƒํƒœ์™€ ์„ฑ๊ณต ํ”ผ๋“œ๋ฐฑ +- ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ ํ›„ ํ™•์ • ์ƒํƒœ์™€ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ ์œ ์ง€ +- ์ƒํ’ˆ ๋ชจ๋‹ฌ ์ƒ์„ธ์—์„œ ์ˆ˜์ • ํ›„ ๊ธฐ์กด ๋ชฉ๋ก ๋ฌธ๋งฅ ๋ณต๊ท€์™€ ๋ณ€๊ฒฝ ๋‚ด์šฉ ๋ฐ˜์˜ +- ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ž ๊ธˆ ํ•ด์ œ, ํŒ”๋กœ์ž‰ ๋ชฉ๋ก ๋…ธ์ถœ, ์ƒ์„ธ ์ ‘๊ทผ ์ˆ˜๋ ด - ๋ณด๋“œ๊ฒŒ์ž„ ๋„๊ฐ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๋…ธ์ถœ๊ณผ ์ƒ์„ธ ์ง„์ž… - ๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก์—์„œ seed VOD ๋…ธ์ถœ๊ณผ ๋…นํ™” ์ƒ์„ธ ์ง„์ž… - ์•Œ๋ฆผ ์„ค์ • ํ™”๋ฉด์˜ ์•Œ๋ฆผ ์œ ํ˜•, ๋ฐฉํ•ด ๊ธˆ์ง€ ์‹œ๊ฐ„, ํ‚ค์›Œ๋“œ ๊ด€๋ฆฌ ์ง„์ž…์  ๋ Œ๋”๋ง diff --git a/tests/e2e/chat-appointment.spec.ts b/tests/e2e/chat-appointment.spec.ts index 251ef50f..3db3a7b6 100644 --- a/tests/e2e/chat-appointment.spec.ts +++ b/tests/e2e/chat-appointment.spec.ts @@ -6,6 +6,7 @@ * History * Date Author Status Description * 2026.05.26 ์ž„๋„ํ—Œ Created ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ๊ณผ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ „ํ™˜ ์„ฑ๊ณต ํ”ผ๋“œ๋ฐฑ E2E ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + * 2026.06.26 ์ž„๋„ํ—Œ Modified ์•ฝ์† ์ˆ˜๋ฝ ํ›„ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ๊ฐ€ ๋ชฉ๋ก/์ƒ์„ธ์— ์œ ์ง€๋˜๋Š”์ง€ ๊ฒ€์ฆ ์ถ”๊ฐ€ */ import { expect, test } from "@playwright/test"; @@ -23,7 +24,7 @@ test.describe("seeded chat appointment regressions", () => { "npm run seed:e2e ์‹คํ–‰ ํ›„ E2E_SEEDED=1์ผ ๋•Œ๋งŒ seed ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค." ); - test("์ฑ„ํŒ… ์•ฝ์†์„ ์ˆ˜๋ฝํ•˜๋ฉด ํ™•์ • ์ƒํƒœ์™€ ์„ฑ๊ณต ํ”ผ๋“œ๋ฐฑ์„ ๋ณด์—ฌ์ค€๋‹ค", async ({ + test("์ฑ„ํŒ… ์•ฝ์†์„ ์ˆ˜๋ฝํ•˜๋ฉด ํ™•์ • ์ƒํƒœ์™€ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ๊ฐ€ ์œ ์ง€๋œ๋‹ค", async ({ page, }) => { test.setTimeout(60_000); @@ -53,5 +54,35 @@ test.describe("seeded chat appointment regressions", () => { await expect(page.getByText("ํ™•์ •๋จ").first()).toBeVisible({ timeout: 15_000, }); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByText("ํ™•์ •๋จ").first()).toBeVisible({ + timeout: 15_000, + }); + + await page.goto( + `/products?keyword=${encodeURIComponent("์•ฝ์† ์ˆ˜๋ฝ ์ƒํ’ˆ")}`, + { waitUntil: "domcontentloaded" } + ); + + const productCard = page + .getByRole("link") + .filter({ hasText: E2E_APPOINTMENT_PRODUCT_TITLE }) + .first(); + + await expect(productCard).toBeVisible({ timeout: 15_000 }); + await expect(productCard).toContainText(/์˜ˆ์•ฝ\s*์ค‘|์˜ˆ์•ฝ์ค‘/); + + const productHref = await productCard.getAttribute("href"); + expect(productHref).toMatch(/^\/products\/view\/\d+/); + + await page.goto(productHref!, { waitUntil: "domcontentloaded" }); + + await expect( + page.getByRole("heading", { name: E2E_APPOINTMENT_PRODUCT_TITLE }) + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(/์˜ˆ์•ฝ\s*์ค‘|์˜ˆ์•ฝ์ค‘/).first()).toBeVisible({ + timeout: 15_000, + }); }); }); diff --git a/tests/e2e/product-modal-edit.spec.ts b/tests/e2e/product-modal-edit.spec.ts new file mode 100644 index 00000000..0606ba1e --- /dev/null +++ b/tests/e2e/product-modal-edit.spec.ts @@ -0,0 +1,113 @@ +/** + * File Name : tests/e2e/product-modal-edit.spec.ts + * Description : seed ๊ธฐ๋ฐ˜ ์ƒํ’ˆ ๋ชจ๋‹ฌ ์ˆ˜์ •/๋ณต๊ท€ E2E ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.26 ์ž„๋„ํ—Œ Created ์ƒํ’ˆ ๋ชฉ๋ก ๋ชจ๋‹ฌ ์ƒ์„ธ์—์„œ ์ˆ˜์ • ํ›„ ๋ชฉ๋ก ๋ฌธ๋งฅ ๋ณต๊ท€ ๊ฒ€์ฆ ์ถ”๊ฐ€ + */ + +import { expect, type Page, test } from "@playwright/test"; +import { + E2E_SELLER, + isSeededE2EEnabled, + loginWithEmail, +} from "./helpers/e2eAuth"; + +const E2E_MODAL_EDIT_PRODUCT_TITLE = "[E2E] ๋ชจ๋‹ฌ ์ˆ˜์ • ๋ณต๊ท€ ์ƒํ’ˆ"; + +async function openProductOwnerEditAction(page: Page) { + const menuButton = page.getByLabel("์ƒํ’ˆ ๊ด€๋ฆฌ ๋ฉ”๋‰ด ์—ด๊ธฐ"); + const editAction = page + .getByRole("menuitem", { name: "์ˆ˜์ •ํ•˜๊ธฐ" }) + .or(page.getByRole("button", { name: "์ˆ˜์ •ํ•˜๊ธฐ" })) + .first(); + + await expect(menuButton).toBeVisible({ timeout: 15_000 }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + await menuButton.click(); + try { + await expect(editAction).toBeVisible({ timeout: 3_000 }); + return editAction; + } catch { + await page.waitForTimeout(500); + } + } + + await expect(editAction).toBeVisible({ timeout: 5_000 }); + return editAction; +} + +test.describe("seeded product modal edit regressions", () => { + test.skip( + !isSeededE2EEnabled(), + "npm run seed:e2e ์‹คํ–‰ ํ›„ E2E_SEEDED=1์ผ ๋•Œ๋งŒ seed ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค." + ); + + test("์ƒํ’ˆ ๋ชจ๋‹ฌ ์ƒ์„ธ์—์„œ ์ˆ˜์ • ํ›„ ์›๋ž˜ ๋ชฉ๋ก ๋ฌธ๋งฅ์œผ๋กœ ๋ณต๊ท€ํ•œ๋‹ค", async ({ + page, + }) => { + test.setTimeout(90_000); + + const listPath = `/products?keyword=${encodeURIComponent("๋ชจ๋‹ฌ ์ˆ˜์ • ๋ณต๊ท€")}`; + const nextDescription = `[E2E] ๋ชจ๋‹ฌ ์ˆ˜์ • ๋ณต๊ท€ ์„ค๋ช… ${Date.now()}`; + + await loginWithEmail(page, E2E_SELLER, listPath, { timeout: 30_000 }); + + const productCard = page + .getByRole("link") + .filter({ hasText: E2E_MODAL_EDIT_PRODUCT_TITLE }) + .first(); + + await expect(productCard).toBeVisible({ timeout: 15_000 }); + await productCard.click(); + + const dialog = page.getByRole("dialog", { name: "์ œํ’ˆ ์ƒ์„ธ" }); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await expect( + dialog.getByRole("heading", { name: E2E_MODAL_EDIT_PRODUCT_TITLE }) + ).toBeVisible(); + + const editAction = await openProductOwnerEditAction(page); + await editAction.click(); + + await page.waitForURL(/\/products\/view\/\d+\/edit/, { + timeout: 15_000, + waitUntil: "domcontentloaded", + }); + expect(new URL(page.url()).searchParams.get("flow")).toBe("modal-edit"); + + const descriptionInput = page.getByPlaceholder( + "์ œํ’ˆ์˜ ์ƒํƒœ, ํŠน์ด์‚ฌํ•ญ ๋“ฑ์„ ์ž์„ธํžˆ ์ ์–ด์ฃผ์„ธ์š”." + ); + await expect(descriptionInput).toBeVisible({ timeout: 15_000 }); + await descriptionInput.fill(nextDescription); + + await page.getByRole("button", { name: "์ˆ˜์ •ํ•˜๊ธฐ" }).click(); + + await expect( + page.getByText("์ œํ’ˆ ์ •๋ณด๊ฐ€ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋ณ€๊ฒฝ ๋‚ด์šฉ์ด ์ƒ์„ธ ํŽ˜์ด์ง€์— ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.") + ).toBeVisible({ timeout: 15_000 }); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await expect(dialog.getByText(nextDescription)).toBeVisible({ + timeout: 15_000, + }); + + await page.getByRole("button", { name: "๋‹ซ๊ธฐ" }).click(); + await page.waitForURL( + (url) => + url.pathname === "/products" && + url.searchParams.get("keyword") === "๋ชจ๋‹ฌ ์ˆ˜์ • ๋ณต๊ท€", + { timeout: 15_000, waitUntil: "domcontentloaded" } + ); + + await expect( + page + .getByRole("link") + .filter({ hasText: E2E_MODAL_EDIT_PRODUCT_TITLE }) + .first() + ).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/stream-follow-access.spec.ts b/tests/e2e/stream-follow-access.spec.ts new file mode 100644 index 00000000..541db62f --- /dev/null +++ b/tests/e2e/stream-follow-access.spec.ts @@ -0,0 +1,87 @@ +/** + * File Name : tests/e2e/stream-follow-access.spec.ts + * Description : seed ๊ธฐ๋ฐ˜ ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ ‘๊ทผ ์ˆ˜๋ ด E2E ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.26 ์ž„๋„ํ—Œ Created ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ž ๊ธˆ/๋ชฉ๋ก/์ƒ์„ธ ์ ‘๊ทผ ์ˆ˜๋ ด ๊ฒ€์ฆ ์ถ”๊ฐ€ + */ + +import { expect, test } from "@playwright/test"; +import { + E2E_BUYER, + isSeededE2EEnabled, + loginWithEmail, +} from "./helpers/e2eAuth"; + +const E2E_FOLLOWERS_VOD_TITLE = "[E2E] ํŒ”๋กœ์›Œ ์ „์šฉ ํšŒ๊ท€ ๋ฐฉ์†ก"; +const E2E_STREAMER_USERNAME = "e2e_seller"; + +test.describe("seeded stream follow access regressions", () => { + test.skip( + !isSeededE2EEnabled(), + "npm run seed:e2e ์‹คํ–‰ ํ›„ E2E_SEEDED=1์ผ ๋•Œ๋งŒ seed ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค." + ); + + test("ํŒ”๋กœ์šฐ ํ›„ ํŒ”๋กœ์›Œ ์ „์šฉ VOD ์ž ๊ธˆ๊ณผ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์ด ์ˆ˜๋ ดํ•œ๋‹ค", async ({ + page, + }) => { + test.setTimeout(90_000); + + await loginWithEmail( + page, + E2E_BUYER, + `/profile/${E2E_STREAMER_USERNAME}/channel`, + { timeout: 30_000 } + ); + + const channelVodCard = page + .getByRole("link") + .filter({ hasText: E2E_FOLLOWERS_VOD_TITLE }) + .first(); + + await expect(channelVodCard).toBeVisible({ timeout: 15_000 }); + await expect(channelVodCard).toContainText("ํŒ”๋กœ์›Œ ์ „์šฉ ๋ฐฉ์†ก์ž…๋‹ˆ๋‹ค"); + + const channelFollowButton = page.locator("#channel-follow-button"); + + await expect(channelFollowButton).toBeVisible(); + await expect(channelFollowButton).toHaveAttribute("aria-pressed", "false"); + + await channelFollowButton.click(); + + await expect(channelFollowButton).toHaveAttribute("aria-pressed", "true", { + timeout: 15_000, + }); + await expect(channelFollowButton).toHaveText("ํŒ”๋กœ์šฐ ์ทจ์†Œ"); + await expect( + channelVodCard.getByText("ํŒ”๋กœ์›Œ ์ „์šฉ ๋ฐฉ์†ก์ž…๋‹ˆ๋‹ค") + ).toHaveCount(0, { timeout: 15_000 }); + + await page.goto( + `/streams?mode=recordings&scope=following&keyword=${encodeURIComponent("ํŒ”๋กœ์›Œ ์ „์šฉ ํšŒ๊ท€")}`, + { waitUntil: "domcontentloaded" } + ); + + const followingVodCard = page + .getByRole("link") + .filter({ hasText: E2E_FOLLOWERS_VOD_TITLE }) + .first(); + + await expect(followingVodCard).toBeVisible({ timeout: 15_000 }); + await expect( + followingVodCard.getByText("ํŒ”๋กœ์›Œ ์ „์šฉ ๋ฐฉ์†ก์ž…๋‹ˆ๋‹ค") + ).toHaveCount(0); + + const vodHref = await followingVodCard.getAttribute("href"); + expect(vodHref).toMatch(/^\/streams\/\d+\/recording/); + + await page.goto(vodHref!, { waitUntil: "domcontentloaded" }); + + await expect( + page.getByRole("heading", { level: 1, name: E2E_FOLLOWERS_VOD_TITLE }) + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("heading", { name: "๋Œ“๊ธ€" })).toBeVisible(); + }); +}); From 63b6b84be12df4748c16965fce02a336e8af1104 Mon Sep 17 00:00:00 2001 From: DoHeonLim Date: Sun, 28 Jun 2026 23:55:36 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=90=20Security=20:=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20rate=20limit=EA=B3=BC=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [๐Ÿ” Auth / Rate Limit] - SMS ์ธ์ฆ๋ฒˆํ˜ธ TTL ์ถ”๊ฐ€ - SMS ์žฌ์ „์†ก ์ฟจ๋‹ค์šด๊ณผ IP ๊ธฐ์ค€ ๋ฐœ์†ก ์š”์ฒญ ์ œํ•œ ์ถ”๊ฐ€ - ํšŒ์›๊ฐ€์ž… ์ œ์ถœ IP ๊ธฐ์ค€ ๋‹จ๊ธฐ ์ œํ•œ ์ถ”๊ฐ€ - rate limit ์‹๋ณ„์ž๋Š” RATE_LIMIT_SALT ๊ธฐ๋ฐ˜ HMAC hash๋กœ ์ €์žฅ - kind/keyHash ๋‹จ์œ„ advisory lock์œผ๋กœ rate limit ๊ธฐ๋ก ๊ฒฝํ•ฉ ์ง๋ ฌํ™” [๐Ÿ“ฑ SMS] - ๋กœ๊ทธ์ธ SMS ํ† ํฐ ๊ต์ฒด๋ฅผ ์กฐ๊ฑด๋ถ€ updateMany ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌ - provider ์‹คํŒจ ์‹œ ๊ธฐ์กด ํ† ํฐ ๋ณต๊ตฌ ๋˜๋Š” ์‹ ๊ทœ ํ† ํฐ ์‚ญ์ œ - ๋งŒ๋ฃŒ๋œ SMS ์ธ์ฆ๋ฒˆํ˜ธ ๊ฒ€์ฆ ๊ฑฐ์ ˆ ๋ฐ ์ง€์—ฐ ์ •๋ฆฌ - ํ”„๋กœํ•„ ์ „ํ™”๋ฒˆํ˜ธ ์ธ์ฆ์—๋„ ๋™์ผํ•œ TTL/์ฟจ๋‹ค์šด/IP ์ œํ•œ/์‹คํŒจ ๋ณต๊ตฌ ์ •์ฑ… ์ ์šฉ [โ˜๏ธ Upload] - Cloudflare Images direct upload URL ๋ฐœ๊ธ‰ ์ „ ์„ธ์…˜ ํ™•์ธ - ์‚ฌ์šฉ์ž ์ƒํƒœ ๊ฒ€์ฆ ํ›„ Cloudflare API ํ˜ธ์ถœ - ๋น„๋กœ๊ทธ์ธ/์ •์ง€ ์‚ฌ์šฉ์ž upload URL ๋ฐœ๊ธ‰ ๊ฑฐ์ ˆ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ [๐Ÿ—„๏ธ Database] - SMSToken expires_at ์ถ”๊ฐ€ ๋ฐ ๊ธฐ์กด ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ backfill - AuthRateLimitEvent ๋ชจ๋ธ ์ถ”๊ฐ€ - rate limit ์กฐํšŒ/cleanup์šฉ ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ [๐Ÿ“š Docs] - .env.example ์ถ”๊ฐ€ - README ํ•„์ˆ˜ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํ‘œ ์ •๋ฆฌ - Rate Limit ์šด์˜ ์ •์ฑ… ๋ฌธ์„œ ์ถ”๊ฐ€ - ๊ถŒํ•œ ๋งคํŠธ๋ฆญ์Šค์™€ docs ์ธ๋ฑ์Šค์— rate limit ๋ฌธ์„œ ์—ฐ๊ฒฐ [โ˜”๏ธ Test] - SMS TTL/์ฟจ๋‹ค์šด/IP ์ œํ•œ/๋™์‹œ ์š”์ฒญ/rollback ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - ํ”„๋กœํ•„ ์ „ํ™”๋ฒˆํ˜ธ SMS ๋ณดํ˜ธ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - rate limit hash/advisory lock ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - Cloudflare Images upload URL ์ธ์ฆ ๊ฒฝ๊ณ„ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ [โœ… Verification] - npx prisma migrate dev - npx prisma generate - git diff --check - npm run lint - npm run test # 27 files / 125 tests - npx tsc --noEmit - npm run build - npx prisma validate --- .env.example | 46 ++++ README.md | 67 ++++-- docs/README.md | 1 + docs/operations/access-control-matrix.md | 1 + docs/operations/rate-limit-policy.md | 123 ++++++++++ features/auth/actions/register.ts | 23 +- features/auth/actions/sms.ts | 7 +- features/auth/constants.ts | 21 ++ features/auth/service/rateLimit.test.ts | 154 ++++++++++++ features/auth/service/rateLimit.ts | 179 ++++++++++++++ features/auth/service/sms.test.ts | 221 ++++++++++++++++++ features/auth/service/sms.ts | 163 +++++++++++-- features/user/actions/phone.ts | 7 +- features/user/service/phone.test.ts | 194 +++++++++++++++ features/user/service/phone.ts | 119 +++++++++- lib/cloudflareImages.test.ts | 113 +++++++++ lib/cloudflareImages.ts | 28 +++ .../migration.sql | 22 ++ .../migration.sql | 2 + prisma/schema.prisma | 16 ++ 20 files changed, 1447 insertions(+), 60 deletions(-) create mode 100644 .env.example create mode 100644 docs/operations/rate-limit-policy.md create mode 100644 features/auth/service/rateLimit.test.ts create mode 100644 features/auth/service/rateLimit.ts create mode 100644 features/auth/service/sms.test.ts create mode 100644 features/user/service/phone.test.ts create mode 100644 lib/cloudflareImages.test.ts create mode 100644 prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql create mode 100644 prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b0a579e2 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# App / Security +NEXT_PUBLIC_APP_URL= +COOKIE_PASSWORD= +RATE_LIMIT_SALT= +CRON_SECRET= + +# Database +# PostgreSQL ๊ธฐ์ค€์ž…๋‹ˆ๋‹ค. +# DATABASE_URL: ๋Ÿฐํƒ€์ž„ ์—ฐ๊ฒฐ URL +# DIRECT_URL: migration / Prisma CLI์šฉ ์ง์ ‘ ์—ฐ๊ฒฐ URL +DATABASE_URL= +DIRECT_URL= + +# OAuth +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +KAKAO_CLIENT_ID= +KAKAO_CLIENT_SECRET= +KAKAO_REDIRECT_URI= + +# Supabase Realtime +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY= + +# Cloudflare Media / Webhook +NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH= +NEXT_PUBLIC_CLOUDFLARE_STREAM_DOMAIN= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_WEBHOOK_SECRET= +CLOUDFLARE_STREAM_WEBHOOK_SECRET= + +# SMS (CoolSMS) +COOLSMS_API_KEY= +COOLSMS_API_SECRET= +COOLSMS_SENDER_NUMBER= + +# Email (Resend) +RESEND_API_KEY= + +# Push +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + +# Maps +NEXT_PUBLIC_KAKAO_MAP_API_KEY= diff --git a/README.md b/README.md index ddb16af8..63814380 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ prisma/ Prisma schema, seed, migration npm install ``` -2. `.env.local`์— ์•„๋ž˜ ํ•„์ˆ˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์ฑ„์›๋‹ˆ๋‹ค. +2. `.env.example`์„ ์ฐธ๊ณ ํ•ด `.env.local`์— ํ•„์ˆ˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์ฑ„์›๋‹ˆ๋‹ค. 3. ๋กœ์ปฌ ๋˜๋Š” ๊ฐœ๋ฐœ DB์— Prisma ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. @@ -241,15 +241,21 @@ npm run dev ์‹ค์ œ ๊ฐ’์€ `.env` ๋˜๋Š” Vercel Environment Variables์— ์„ค์ •ํ•˜๊ณ  ์ €์žฅ์†Œ์—๋Š” ์ปค๋ฐ‹ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. -#### Core +#### App / Security -| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | -| --------------------- | ---------------------------------------------- | -| `DATABASE_URL` | Prisma ๊ธฐ๋ณธ DB ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด | -| `DIRECT_URL` | Prisma ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜/์ง์ ‘ ์—ฐ๊ฒฐ์šฉ DB ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด | -| `COOKIE_PASSWORD` | iron-session ์ฟ ํ‚ค ์•”ํ˜ธํ™” ํ‚ค | -| `NEXT_PUBLIC_APP_URL` | ๋Œ€ํ‘œ URL, ์ธ์ฆ ์ฝœ๋ฐฑ, ๊ณต์œ  ๋งํฌ ๊ธฐ์ค€ URL | -| `CRON_SECRET` | Vercel Cron ํ˜ธ์ถœ ์ธ์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค | +| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | +| --------------------- | --------------------------------------------- | +| `NEXT_PUBLIC_APP_URL` | ๋Œ€ํ‘œ URL, ์ธ์ฆ ์ฝœ๋ฐฑ, ๊ณต์œ  ๋งํฌ ๊ธฐ์ค€ URL | +| `COOKIE_PASSWORD` | iron-session ์ฟ ํ‚ค ์•”ํ˜ธํ™” ํ‚ค | +| `RATE_LIMIT_SALT` | IP ๊ธฐ๋ฐ˜ rate limit hash ์ƒ์„ฑ์šฉ ์„œ๋ฒ„ ์‹œํฌ๋ฆฟ ํ‚ค | +| `CRON_SECRET` | Vercel Cron ํ˜ธ์ถœ ์ธ์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค | + +#### Database + +| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | +| -------------- | ------------------------------------------ | +| `DATABASE_URL` | ๋Ÿฐํƒ€์ž„ PostgreSQL ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด | +| `DIRECT_URL` | Prisma ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜/CLI์šฉ ์ง์ ‘ ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด | #### Supabase @@ -258,17 +264,29 @@ npm run dev | `NEXT_PUBLIC_SUPABASE_URL` | Supabase ํ”„๋กœ์ ํŠธ URL | | `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Supabase ๊ณต๊ฐœ anon ํ‚ค | -#### Auth / SMS / Email +#### OAuth + +| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | +| ---------------------- | --------------------------------- | +| `GITHUB_CLIENT_ID` | GitHub OAuth ํด๋ผ์ด์–ธํŠธ ID | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ํ‚ค | +| `KAKAO_CLIENT_ID` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ID | +| `KAKAO_CLIENT_SECRET` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ํ‚ค | +| `KAKAO_REDIRECT_URI` | Kakao OAuth ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URI | + +#### SMS (CoolSMS) -| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | -| ----------------------- | -------------------------------- | -| `KAKAO_CLIENT_ID` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ID | -| `KAKAO_CLIENT_SECRET` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ํ‚ค | -| `KAKAO_REDIRECT_URI` | Kakao OAuth ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URI | -| `COOLSMS_API_KEY` | CoolSMS API ํ‚ค | -| `COOLSMS_API_SECRET` | CoolSMS API ์‹œํฌ๋ฆฟ ํ‚ค | -| `COOLSMS_SENDER_NUMBER` | CoolSMS ๋ฐœ์‹  ๋ฒˆํ˜ธ | -| `RESEND_API_KEY` | Resend ์ด๋ฉ”์ผ ๋ฐœ์†ก API ํ‚ค | +| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | +| ----------------------- | --------------------- | +| `COOLSMS_API_KEY` | CoolSMS API ํ‚ค | +| `COOLSMS_API_SECRET` | CoolSMS API ์‹œํฌ๋ฆฟ ํ‚ค | +| `COOLSMS_SENDER_NUMBER` | CoolSMS ๋ฐœ์‹  ๋ฒˆํ˜ธ | + +#### Email (Resend) + +| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | +| ---------------- | ------------------------- | +| `RESEND_API_KEY` | Resend ์ด๋ฉ”์ผ ๋ฐœ์†ก API ํ‚ค | #### Cloudflare @@ -281,12 +299,17 @@ npm run dev | `CLOUDFLARE_WEBHOOK_SECRET` | Cloudflare ์›นํ›… ์š”์ฒญ ๊ฒ€์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค | | `CLOUDFLARE_STREAM_WEBHOOK_SECRET` | Cloudflare Stream ์›นํ›… ์„œ๋ช… ๊ฒ€์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค | -#### Push / ์ง€๋„ +#### Push + +| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | +| ------------------------------ | --------------------- | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Web Push VAPID ๊ณต๊ฐœํ‚ค | +| `VAPID_PRIVATE_KEY` | Web Push VAPID ๊ฐœ์ธํ‚ค | + +#### Maps | ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… | | ------------------------------- | ----------------------------- | -| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Web Push VAPID ๊ณต๊ฐœํ‚ค | -| `VAPID_PRIVATE_KEY` | Web Push VAPID ๊ฐœ์ธํ‚ค | | `NEXT_PUBLIC_KAKAO_MAP_API_KEY` | ์นด์นด์˜ค ์ง€๋„ JavaScript SDK ํ‚ค | ## Scripts diff --git a/docs/README.md b/docs/README.md index 95d3b59f..e260dee7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,7 @@ ### Operations - [๊ถŒํ•œ / ์ ‘๊ทผ ์ œ์–ด ๋งคํŠธ๋ฆญ์Šค](./operations/access-control-matrix.md) +- [Rate Limit / ๋‚จ์šฉ ๋ฐฉ์ง€ ์šด์˜ ๊ธฐ์ค€](./operations/rate-limit-policy.md) - [๋ณด์•ˆ ํ—ค๋” / CSP ์šด์˜ ์ •์ฑ…](./operations/security-headers-csp-policy.md) - [๋ณด๋“œ๊ฒŒ์ž„ ๋ฐ์ดํ„ฐ import ์šด์˜ ๊ธฐ์ค€](./operations/boardgame-data-import-runbook.md) - [์‹ ๊ณ  ์ฒ˜๋ฆฌ์™€ ์ œ์žฌ ์šด์˜ ์ •์ฑ…](./operations/report-moderation-policy.md) diff --git a/docs/operations/access-control-matrix.md b/docs/operations/access-control-matrix.md index a0370f0c..35bece82 100644 --- a/docs/operations/access-control-matrix.md +++ b/docs/operations/access-control-matrix.md @@ -111,6 +111,7 @@ BoardPort์˜ ์ ‘๊ทผ ์ œ์–ด๋Š” middleware ํ•œ ๊ณณ์—๋งŒ ์˜์กดํ•˜์ง€ ์•Š๊ณ , ํŽ˜ ## 5. ํ•จ๊ป˜ ๋ณด๋Š” ๋ฌธ์„œ - [ํ…Œ์ŠคํŠธ ์ „๋žต](./testing-strategy.md) +- [Rate Limit / ๋‚จ์šฉ ๋ฐฉ์ง€ ์šด์˜ ๊ธฐ์ค€](./rate-limit-policy.md) - [์‹ ๊ณ  ์ฒ˜๋ฆฌ์™€ ์ œ์žฌ ์šด์˜ ์ •์ฑ…](./report-moderation-policy.md) - [๋ณด์•ˆ ํ—ค๋” / CSP ์šด์˜ ์ •์ฑ…](./security-headers-csp-policy.md) - [์ง๊ฑฐ๋ž˜ ์•ฝ์† ์ˆ˜๋ฝ๊ณผ ์ƒํ’ˆ ์ƒํƒœ ์›์ž์  ์ „ํ™˜](../troubleshooting/troubleshooting-appointment-atomic-transition.md) diff --git a/docs/operations/rate-limit-policy.md b/docs/operations/rate-limit-policy.md new file mode 100644 index 00000000..237fbbe5 --- /dev/null +++ b/docs/operations/rate-limit-policy.md @@ -0,0 +1,123 @@ +# Rate Limit / ๋‚จ์šฉ ๋ฐฉ์ง€ ์šด์˜ ๊ธฐ์ค€ + +BoardPort์˜ rate limit์€ ๋ชจ๋“  API์— ๊ฐ™์€ ์ˆซ์ž๋ฅผ ์ ์šฉํ•˜๋Š” ๋ฐฉ์‹์ด ์•„๋‹ˆ๋ผ, ์š”์ฒญ ์ฃผ์ฒด, ํ–‰์œ„ ๋น„์šฉ, ๋Œ€์ƒ ๋ฆฌ์†Œ์Šค, ์‹œ๊ฐ„ ์ฐฝ์„ ๋‚˜๋ˆ„์–ด ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. + +์ด ๋ฌธ์„œ๋Š” ์ธ์ฆ, ์—…๋กœ๋“œ, ์ฑ„ํŒ…, ์‹ ๊ณ ์ฒ˜๋Ÿผ ๋ฐ˜๋ณต ํ˜ธ์ถœ์˜ ๋น„์šฉ์ด๋‚˜ ํ”ผํ•ด๊ฐ€ ํฐ ๊ฒฝ๋กœ๋ฅผ ์ ๊ฒ€ํ•˜๊ธฐ ์œ„ํ•œ ์šด์˜ ๊ธฐ์ค€์ž…๋‹ˆ๋‹ค. ์‹ค์ œ ๊ตฌํ˜„์€ ๊ฐ ๋„๋ฉ”์ธ์˜ `actions`, `service`, `route.ts` ํŒŒ์ผ๊ณผ ๋ฐฐํฌ ํ™˜๊ฒฝ์˜ WAF/๋กœ๊ทธ ์ •์ฑ…์„ ํ•จ๊ป˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + +## 1. ๊ธฐ๋ณธ ์›์น™ + +| ์›์น™ | ๊ธฐ์ค€ | +| --------------- | ---------------------------------------------------------------------------------------------- | +| ํ–‰์œ„๋ณ„ ์ œํ•œ | ์กฐํšŒ, ๊ฒ€์ƒ‰, ์ธ์ฆ, ์—…๋กœ๋“œ, ๋ฉ”์‹œ์ง€, ์‹ ๊ณ , ๊ด€๋ฆฌ์ž ์ž‘์—…์€ ๊ฐ™์€ ํ•œ๋„๋ฅผ ์“ฐ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. | +| ๋‹ค์ค‘ ์‹๋ณ„์ž | ๋น„๋กœ๊ทธ์ธ์€ IP, ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋Š” userId, ์ฑ„ํŒ…์€ userId+roomId, ์ธ์ฆ์€ ์ „ํ™”๋ฒˆํ˜ธ/IP ์กฐํ•ฉ์„ ๋ด…๋‹ˆ๋‹ค. | +| ๋น„์šฉ ์šฐ์„  | SMS, ๋ฉ”์ผ, ์—…๋กœ๋“œ URL, ์™ธ๋ถ€ API, ์ƒํƒœ ๋ณ€๊ฒฝ์ฒ˜๋Ÿผ ๋น„์šฉ์ด๋‚˜ ๋ถ€์ž‘์šฉ์ด ํฐ ๊ฒฝ๋กœ๋ฅผ ๋จผ์ € ์ œํ•œํ•ฉ๋‹ˆ๋‹ค. | +| ์ •์ƒ burst ํ—ˆ์šฉ | ์‚ฌ์šฉ์ž์˜ ์งง์€ ํด๋ฆญ ๋ฐ˜๋ณต์€ ์–ด๋А ์ •๋„ ํ—ˆ์šฉํ•˜๋˜, ์ง€์†์ ์ธ ์ž๋™ํ™” ์š”์ฒญ์€ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. | +| ๊ถŒํ•œ๊ณผ ๋ถ„๋ฆฌ | Rate limit์€ ๋‚จ์šฉ ์™„ํ™” ์žฅ์น˜์ด๊ณ , ๊ถŒํ•œ ํŒ๋‹จ์€ ์„ธ์…˜, ์†Œ์œ ๊ถŒ, ๊ด€๊ณ„, DB ์ƒํƒœ๋กœ ๋ณ„๋„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. | +| ์žฌ์‹œ๋„ ์•ˆ๋‚ด | ์ œํ•œ ์‘๋‹ต์€ 429์™€ `Retry-After` ๋˜๋Š” ๋‚จ์€ ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ํ•จ๊ป˜ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. | +| ๋กœ๊ทธ ๊ธฐ๋ฐ˜ ์กฐ์ • | ์กฐํšŒ/๊ฒ€์ƒ‰ ๊ณ„์—ด์€ ์šด์˜ ๋กœ๊ทธ์™€ P99 ์‚ฌ์šฉ๋Ÿ‰์„ ๋ณธ ๋’ค ์กฐ์ •ํ•ฉ๋‹ˆ๋‹ค. | + +## 2. ์šฐ์„  ์ ์šฉ ๋Œ€์ƒ + +| ์˜์—ญ | ์ดˆ๊ธฐ ๊ธฐ์ค€ | ์‹๋ณ„์ž | ์ด์œ  | +| ---------------- | ------------------------ | --------------------- | ------------------------------------------------------------ | +| SMS ์ธ์ฆ ๋ฐœ์†ก | ์ „ํ™”๋ฒˆํ˜ธ๋ณ„ 1ํšŒ/60์ดˆ | phone | CoolSMS ๋น„์šฉ๊ณผ ๋ฐ˜๋ณต ๋ฐœ์†ก ํ”ผํ•ด๋ฅผ ์ค„์ž…๋‹ˆ๋‹ค. | +| SMS ๋ฐœ์†ก ์š”์ฒญ | IP 10ํšŒ/1์‹œ๊ฐ„ | IP hash | ์—ฌ๋Ÿฌ ๋ฒˆํ˜ธ๋กœ ๋ถ„์‚ฐํ•˜๋Š” ๋ฐœ์†ก ์‹œ๋„ ๋‚จ์šฉ์„ ์ค„์ž…๋‹ˆ๋‹ค. | +| ํšŒ์›๊ฐ€์ž… ์‹œ๋„ | IP 10ํšŒ/10๋ถ„ | IP hash | ์œ ํšจํ•œ ๊ฐ€์ž… ์ œ์ถœ์˜ ์งง์€ ์‹œ๊ฐ„ ์ž๋™ํ™” ํญ์ฃผ๋ฅผ ์™„ํ™”ํ•ฉ๋‹ˆ๋‹ค. | +| ์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ | ๋กœ๊ทธ์ธ ์„ธ์…˜ ํ•„์ˆ˜ | userId | Cloudflare API ํ† ํฐ์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฏผ๊ฐ ์„œ๋ฒ„ ๊ฒฝ๊ณ„๋ฅผ ๋ณดํ˜ธํ•ฉ๋‹ˆ๋‹ค. | +| ๋ผ์ด๋ธŒ ์ฑ„ํŒ… | ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜ ๊ธฐ๋ฐ˜ ์ œํ•œ | userId + streamRoomId | ์‹ค์‹œ๊ฐ„ ๋„๋ฐฐ๋ฅผ ์™„ํ™”ํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ ์„œ๋น„์Šค ๊ณ„์ธต์—์„œ ์นด์šดํŠธํ•ฉ๋‹ˆ๋‹ค. | + +์ดˆ๊ธฐ๊ฐ’์€ ์™ธ๋ถ€ ํ”Œ๋žซํผ์˜ ๊ณต๊ฐœ ํ•œ๋„๋ฅผ ๊ทธ๋Œ€๋กœ ๊ฐ€์ ธ์˜ค์ง€ ์•Š๊ณ , BoardPort์˜ ์ผ๋ฐ˜ ์‚ฌ์šฉ๋Ÿ‰๊ณผ ์ž๋™ํ™” ์š”์ฒญ ์‚ฌ์ด์— ๋‘์—ˆ์Šต๋‹ˆ๋‹ค. + +SMS ์ธ์ฆ์€ ์‹ค์ œ ๋ฐœ์†ก ์„ฑ๊ณต ํšŸ์ˆ˜๋ณด๋‹ค ๋ฐœ์†ก ์š”์ฒญ ์‹œ๋„, ํ† ํฐ ์ˆ˜๋ช…, ๋ฐœ์†ก ์‹คํŒจ ์ •ํ•ฉ์„ฑ์„ ๋จผ์ € ๋งž์ถฅ๋‹ˆ๋‹ค. ์ธ์ฆ๋ฒˆํ˜ธ๋Š” TTL์„ ๊ฐ€์ง€๋ฉฐ, ์™ธ๋ถ€ SMS ๋ฐœ์†ก์ด ์‹คํŒจํ•˜๋ฉด ๊ธฐ์กด ์œ ํšจ ํ† ํฐ์„ ์ง€์šฐ๊ฑฐ๋‚˜ ์‹คํŒจํ•œ ํ† ํฐ๋งŒ ๋‚จ๊ธฐ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์žฌ์š”์ฒญ ์ œํ•œ๊ณผ ํ† ํฐ ๊ต์ฒด๋Š” ๋™์‹œ์— ๋“ค์–ด์˜จ ์š”์ฒญ ์ค‘ ํ•˜๋‚˜๋งŒ ์ƒˆ ๋ฐœ์†ก ์Šฌ๋กฏ์„ ํ™•๋ณดํ•˜๋„๋ก ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## 3. ํ›„์† ์ ์šฉ ํ›„๋ณด + +| ์˜์—ญ | ๊ถŒ์žฅ ๊ธฐ์ค€ | ๋น„๊ณ  | +| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------- | +| ๋Œ“๊ธ€ ์ž‘์„ฑ | ์‚ฌ์šฉ์ž๋ณ„ 10ํšŒ/๋ถ„, 60ํšŒ/์‹œ๊ฐ„ | ๋‚™๊ด€ UI์™€ ์—๋Ÿฌ ํ† ์ŠคํŠธ ์ •์ฑ…๊นŒ์ง€ ํ•จ๊ป˜ ํ™•์ธํ•œ ๋’ค ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. | +| 1:1 ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€ | ์‚ฌ์šฉ์ž๋ณ„ 5๊ฑด/10์ดˆ, ๋ฐฉ๋ณ„ 15๊ฑด/๋ถ„ | Realtime ์ค‘๋ณต ์ „์†ก, ๋ฐฉ ๋‹จ์œ„ UX์™€ ํ•จ๊ป˜ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. | +| ์‹ ๊ณ  ์ƒ์„ฑ | ์‚ฌ์šฉ์ž๋ณ„ 5ํšŒ/์‹œ๊ฐ„, ๋™์ผ ๋Œ€์ƒ 1ํšŒ/์ผ | ์ค‘๋ณต ์‹ ๊ณ  unique์™€ ๊ด€๋ฆฌ์ž ๊ฒ€ํ†  ๋น„์šฉ์„ ํ•จ๊ป˜ ๊ณ ๋ คํ•ฉ๋‹ˆ๋‹ค. | +| SMS ์ธ์ฆ ๋ฐœ์†ก ์„ธ๋ถ€ bucket | ์ „ํ™”๋ฒˆํ˜ธ+IP 3ํšŒ/30๋ถ„ | IP ์ƒํ•œ ์šด์˜ ํ›„ ํŠน์ • ๋ฒˆํ˜ธ ๋ฐ˜๋ณต ๋ฐœ์†ก์ด ๊ด€์ฐฐ๋˜๋ฉด ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. | +| SMS ์ธ์ฆ ๊ฒ€์ฆ ์‹คํŒจ | ์ „ํ™”๋ฒˆํ˜ธ+IP 5ํšŒ/10๋ถ„ | 6์ž๋ฆฌ ์ธ์ฆ๋ฒˆํ˜ธ ๋ฐ˜๋ณต ๋Œ€์ž… ๋ฐฉ์ง€ ํ›„๋ณด์ž…๋‹ˆ๋‹ค. | +| ์ƒํ’ˆ/๊ฒŒ์‹œ๊ธ€ ๋“ฑ๋ก | ์‚ฌ์šฉ์ž๋ณ„ 5ํšŒ/10๋ถ„, 20ํšŒ/์ผ | ์ •์ƒ ํŒ๋งค์ž์™€ ์ŠคํŒธ ๋“ฑ๋ก์„ ๊ตฌ๋ถ„ํ•˜๊ธฐ ์œ„ํ•ด ์šด์˜ ๋กœ๊ทธ๋ฅผ ๋จผ์ € ๋ด…๋‹ˆ๋‹ค. | +| ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต | IP 5ํšŒ/24์‹œ๊ฐ„ | ๊ณต์œ ๋ง ์ •์ƒ ์‚ฌ์šฉ์ž ์˜คํƒ ๊ฐ€๋Šฅ์„ฑ์ด ์ปค์„œ ์šด์˜ ๋กœ๊ทธ์™€ ์ •์ฑ… ์„ค๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. | +| ์†Œ์ผ“ ์—ฐ๊ฒฐ/์žฌ์—ฐ๊ฒฐ | ์‚ฌ์šฉ์ž๋ณ„ 3ํšŒ/10์ดˆ, ๋™์‹œ ์—ฐ๊ฒฐ 2๊ฐœ | ํƒญ ์ค‘๋ณต, ๋„คํŠธ์›Œํฌ ์žฌ์—ฐ๊ฒฐ, Realtime provider ์ •์ฑ…๊ณผ ํ•จ๊ป˜ ๊ฒ€ํ† ํ•ฉ๋‹ˆ๋‹ค. | +| ๊ฒ€์ƒ‰/๋ชฉ๋ก ์กฐํšŒ | ๊ด€์ฐฐ ๋ชจ๋“œ ํ›„ ๊ฒฐ์ • | ์ •์ƒ ํƒ์ƒ‰์„ ๋ง‰๊ธฐ ์‰ฌ์šฐ๋ฏ€๋กœ ๋กœ๊ทธ ๊ธฐ๋ฐ˜์œผ๋กœ ์กฐ์ •ํ•ฉ๋‹ˆ๋‹ค. | +| ๊ด€๋ฆฌ์ž ๋ณ€๊ฒฝ ์ž‘์—… | ๊ด€๋ฆฌ์ž๋ณ„ 20ํšŒ/๋ถ„ | ์ผ๊ด„ ์ฒ˜๋ฆฌ๋‚˜ ์Šคํฌ๋ฆฝํŠธ ์˜ค์ž‘๋™์„ ์™„ํ™”ํ•ฉ๋‹ˆ๋‹ค. | + +## 4. ์ €์žฅ ๊ธฐ์ค€ + +### IP ์‹๋ณ„ + +ํ”„๋ก์‹œ ํ™˜๊ฒฝ์—์„œ๋Š” ๋‹ค์Œ ์ˆœ์„œ๋กœ IP ํ›„๋ณด๋ฅผ ์ฝ์Šต๋‹ˆ๋‹ค. ์ด ๊ฐ’์€ ์‹ ๋ขฐ ๊ฐ€๋Šฅํ•œ ํ”„๋ก์‹œ ๋˜๋Š” ๋ฐฐํฌ ํ”Œ๋žซํผ์ด ์„ค์ •ํ•œ๋‹ค๋Š” ์ „์ œ์—์„œ๋งŒ rate limit ์‹๋ณ„์ž๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +1. `x-forwarded-for`์˜ ์ฒซ ๋ฒˆ์งธ IP +2. `x-real-ip` +3. `cf-connecting-ip` + +IP ์›๋ฌธ์€ ์ €์žฅํ•˜์ง€ ์•Š๊ณ  `HMAC-SHA-256(ip, RATE_LIMIT_SALT)`์ฒ˜๋Ÿผ ์„œ๋ฒ„ ๋น„๋ฐ€ํ‚ค๊ฐ€ ํฌํ•จ๋œ hash๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” `COOKIE_PASSWORD` ์žฌ์‚ฌ์šฉ๋ณด๋‹ค ๋ณ„๋„ `RATE_LIMIT_SALT`๋ฅผ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์„ ๊ธฐ์ค€์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค. + +### Rate limit store + +์ดˆ๊ธฐ ๊ตฌํ˜„์€ DB ๊ธฐ๋ฐ˜ ์ •์ฑ…์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + +- ์ธ์ฆ/ํšŒ์›๊ฐ€์ž…์ฒ˜๋Ÿผ ์š”์ฒญ๋Ÿ‰์ด ๋‚ฎ๊ณ  ์ •ํ™•ํ•œ ์นด์šดํŠธ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ๋กœ์— ์ ํ•ฉ +- `kind + keyHash` ๋‹จ์œ„ transaction advisory lock์œผ๋กœ check-and-record ๊ฒฝ์Ÿ์„ ์ง๋ ฌํ™” +- ์„œ๋ฒ„ ์žฌ์‹œ์ž‘, ๋‹ค์ค‘ ์ธ์Šคํ„ด์Šค์—์„œ๋„ ์ƒํƒœ๊ฐ€ ์œ ์ง€๋จ +- ๋งŒ๋ฃŒ๋œ ๋กœ๊ทธ๋Š” ์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์ง€์—ฐ cleanup์œผ๋กœ ์‚ญ์ œ + +์กฐํšŒ/๊ฒ€์ƒ‰/์†Œ์ผ“์ฒ˜๋Ÿผ ์š”์ฒญ๋Ÿ‰์ด ๋งŽ์€ ๊ฒฝ๋กœ๋Š” Redis ๋˜๋Š” WAF ๊ธฐ๋ฐ˜์œผ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค. + +## 5. ์‘๋‹ต ๊ธฐ์ค€ + +์ œํ•œ์— ๊ฑธ๋ฆฐ ์š”์ฒญ์€ ๊ฐ€๋Šฅํ•œ ํ•œ ๋‹ค์Œ ํ˜•ํƒœ๋กœ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. + +- HTTP Route Handler: `429 Too Many Requests` +- Server Action: ํ˜„์žฌ๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ค„ ์ˆ˜ ์žˆ๋Š” ์ผ๋ฐ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ , ํ•„์š” ์‹œ `retryAfterSeconds`๋ฅผ UI์— ์—ฐ๊ฒฐ +- Header: ๊ฐ€๋Šฅํ•˜๋ฉด `Retry-After` +- ๋ฉ”์‹œ์ง€: ๋‚ด๋ถ€ ํ•œ๋„ ์ˆ˜์น˜๋ฅผ ๊ทธ๋Œ€๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š๊ณ  โ€œ์š”์ฒญ์ด ๋งŽ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.โ€์ฒ˜๋Ÿผ ์•ˆ๋‚ด + +๊ถŒํ•œ์ด ์—†๋Š” ์š”์ฒญ์€ 401/403์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ณ , rate limit ์‘๋‹ต๊ณผ ์„ž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +## 6. BoardPort ์ ์šฉ ๋‹จ๊ณ„ + +### 1์ฐจ ๋ณด๊ฐ• + +- SMS ์ธ์ฆ๋ฒˆํ˜ธ TTL +- SMS ์žฌ์ „์†ก ์ฟจ๋‹ค์šด๊ณผ IP ๊ธฐ๋ฐ˜ ์ตœ์†Œ ๋ฐœ์†ก ์š”์ฒญ ์ œํ•œ +- SMS IP hash ๊ธฐ์ค€ ์‹œ๊ฐ„๋‹น ๋ฐœ์†ก ์š”์ฒญ ์ƒํ•œ +- SMS ๋ฐœ์†ก ์‹คํŒจ์™€ ๋™์‹œ ์žฌ์š”์ฒญ ์ •ํ•ฉ์„ฑ ๋ณด๊ฐ• +- ํšŒ์›๊ฐ€์ž… IP hash ๊ธฐ๋ฐ˜ ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ +- Cloudflare ์ด๋ฏธ์ง€ upload URL ๋ฐœ๊ธ‰ ์„ธ์…˜ ๊ฐ€๋“œ +- ๊ด€๋ จ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์™€ ์ •์ฑ… ๋ฌธ์„œํ™” + +### 2์ฐจ ๋ณด๊ฐ• + +- ์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์‚ฌ์šฉ์ž๋ณ„ ํšŸ์ˆ˜ ์ œํ•œ +- SMS ์ธ์ฆ ์‹คํŒจ ํšŸ์ˆ˜ ์ œํ•œ +- ํšŒ์›๊ฐ€์ž… IP hash ๊ธฐ๋ฐ˜ ์„ฑ๊ณต ๊ฐ€์ž… ์žฅ๊ธฐ ์ฟผํ„ฐ ๊ฒ€ํ†  +- ์‹ ๊ณ  ์ƒ์„ฑ ์‚ฌ์šฉ์ž๋ณ„ ์‹œ๊ฐ„ ์ œํ•œ +- ๋Œ“๊ธ€/1:1 ์ฑ„ํŒ… ์ž‘์„ฑ ์ œํ•œ + +### ์šด์˜ ๋‹จ๊ณ„ + +- ๊ฒ€์ƒ‰/๋ชฉ๋ก ์กฐํšŒ ๊ด€์ฐฐ ๋ชจ๋“œ +- WAF/Edge rate limit +- ์†Œ์ผ“ ์—ฐ๊ฒฐ ๋™์‹œ์„ฑ ์ œํ•œ +- endpoint, actor, 429, retry-after, latency ๋กœ๊ทธ ๊ธฐ๋ฐ˜ ์ž„๊ณ„๊ฐ’ ์กฐ์ • + +## 7. ๋ฆด๋ฆฌ์ฆˆ ์ „ ํ™•์ธ ํฌ์ธํŠธ + +- ๋น„์šฉ์„ฑ ์™ธ๋ถ€ API๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๊ฒฝ๋กœ์— ์„œ๋ฒ„ ์„ธ์…˜ ๋˜๋Š” ์š”์ฒญ์ž ๊ฒ€์ฆ์ด ์žˆ๋Š”๊ฐ€? +- SMS/๋ฉ”์ผ/์—…๋กœ๋“œ ticket์ฒ˜๋Ÿผ ๋ฐ˜๋ณต ํ˜ธ์ถœ ๊ฐ€๋Šฅํ•œ ๊ฒฝ๋กœ์— ์ตœ์†Œ ์ฟจ๋‹ค์šด์ด ์žˆ๋Š”๊ฐ€? +- IP ๊ธฐ๋ฐ˜ ์ œํ•œ์ด ๊ณต์œ ๋ง ์ •์ƒ ์‚ฌ์šฉ์ž๋ฅผ ๊ณผ๋„ํ•˜๊ฒŒ ๋ง‰์ง€ ์•Š๋Š”๊ฐ€? +- IP ์›๋ฌธ์„ ์ €์žฅํ•˜์ง€ ์•Š๊ณ  hash/salt ๊ธฐ์ค€์œผ๋กœ ๋‹ค๋ฃจ๋Š”๊ฐ€? +- 429 ๋˜๋Š” action error๊ฐ€ ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ณต๊ตฌ ๊ฐ€๋Šฅํ•œ ๋ฉ”์‹œ์ง€๋กœ ๋…ธ์ถœ๋˜๋Š”๊ฐ€? +- ๊ถŒํ•œ ๊ฒ€์ฆ์„ rate limit์œผ๋กœ ๋Œ€์ฒดํ•˜์ง€ ์•Š์•˜๋Š”๊ฐ€? +- ์šด์˜ ๋กœ๊ทธ๋ฅผ ๋ณด๊ณ  ์ž„๊ณ„๊ฐ’์„ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ๋Š”๊ฐ€? + +## 8. ํ•จ๊ป˜ ๋ณด๋Š” ๋ฌธ์„œ + +- [๊ถŒํ•œ / ์ ‘๊ทผ ์ œ์–ด ๋งคํŠธ๋ฆญ์Šค](./access-control-matrix.md) +- [๋ณด์•ˆ ํ—ค๋” / CSP ์šด์˜ ์ •์ฑ…](./security-headers-csp-policy.md) +- [ํ…Œ์ŠคํŠธ ์ „๋žต](./testing-strategy.md) diff --git a/features/auth/actions/register.ts b/features/auth/actions/register.ts index e8293dfe..7e287734 100644 --- a/features/auth/actions/register.ts +++ b/features/auth/actions/register.ts @@ -13,15 +13,22 @@ * 2026.01.30 ์ž„๋„ํ—Œ Moved app/(auth)/create-account/actions.ts -> features/auth/actions/register.ts * 2026.04.04 ์ž„๋„ํ—Œ Modified ๊ฒ€์ฆ/์—๋Ÿฌ ๋งคํ•‘/์„ธ์…˜ ์ €์žฅ ๋‹จ๊ณ„์˜ ์ธ๋ผ์ธ ์ฃผ์„ ๋ณด๊ฐ• * 2026.05.16 ์ž„๋„ํ—Œ Modified ํ˜„์žฌ actions ๊ณ„์ธต ์—ญํ• ์— ๋งž๊ฒŒ ํŒŒ์ผ ์„ค๋ช… ์ •๋ฆฌ + * 2026.06.27 ์ž„๋„ํ—Œ Modified IP hash ๊ธฐ๋ฐ˜ ํšŒ์›๊ฐ€์ž… ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ ์ถ”๊ฐ€ */ "use server"; +import { headers } from "next/headers"; import { createAccountSchema, type CreateAccountSchema, } from "@/features/auth/schemas/register"; +import { AUTH_ERRORS } from "@/features/auth/constants"; import { saveUserSession } from "@/features/auth/service/authSession"; import { resolvePostAuthRedirectPath } from "@/features/auth/service/onboarding"; +import { + checkAndRecordSignupAttemptByIp, + getClientIpFromHeaders, +} from "@/features/auth/service/rateLimit"; import { createAccount } from "@/features/auth/service/register"; import { sanitizeCallbackUrl } from "@/features/auth/utils/redirect"; import type { ActionState } from "@/features/auth/types"; @@ -30,8 +37,9 @@ import type { ActionState } from "@/features/auth/types"; * ํšŒ์›๊ฐ€์ž… ํผ ์ œ์ถœ์„ ์ฒ˜๋ฆฌ * * 1. Zod ์Šคํ‚ค๋งˆ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ž…๋ ฅ๊ฐ’์„ ๊ฒ€์ฆ - * 2. Service ๊ณ„์ธต์„ ํ˜ธ์ถœํ•˜์—ฌ ๊ณ„์ •์„ ์ƒ์„ฑ - * 3. ์ƒ์„ฑ๋œ ์œ ์ € ID๋กœ ์„ธ์…˜์„ ์ €์žฅํ•˜์—ฌ ์ž๋™ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ + * 2. IP hash ๊ธฐ๋ฐ˜ ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ์„ ํ™•์ธ + * 3. Service ๊ณ„์ธต์„ ํ˜ธ์ถœํ•˜์—ฌ ๊ณ„์ •์„ ์ƒ์„ฑ + * 4. ์ƒ์„ฑ๋œ ์œ ์ € ID๋กœ ์„ธ์…˜์„ ์ €์žฅํ•˜์—ฌ ์ž๋™ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ * * @param {unknown} _prevState - ์ด์ „ ์ƒํƒœ * @param {FormData} formData - ํผ ๋ฐ์ดํ„ฐ @@ -63,7 +71,16 @@ export async function submitCreateAccount( }; } - // 2. ๊ณ„์ • ์ƒ์„ฑ (Service) + // 2. IP hash ๊ธฐ๋ฐ˜ ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ + const signupLimit = await checkAndRecordSignupAttemptByIp( + getClientIpFromHeaders(headers()) + ); + + if (!signupLimit.allowed) { + return { success: false, error: AUTH_ERRORS.SIGNUP_RATE_LIMITED }; + } + + // 3. ๊ณ„์ • ์ƒ์„ฑ (Service) const result = await createAccount(parsed.data); if (!result.success) { diff --git a/features/auth/actions/sms.ts b/features/auth/actions/sms.ts index 1f7ccebc..54a78925 100644 --- a/features/auth/actions/sms.ts +++ b/features/auth/actions/sms.ts @@ -17,10 +17,13 @@ * 2026.01.30 ์ž„๋„ํ—Œ Moved app/(auth)/sms/actions.ts -> features/auth/actions/sms.ts * 2026.04.04 ์ž„๋„ํ—Œ Modified ์ „ํ™”๋ฒˆํ˜ธ/SMS ํ† ํฐ ๊ฒ€์ฆ๊ณผ ์„ธ์…˜ ์ €์žฅ ๋‹จ๊ณ„์˜ ์ธ๋ผ์ธ ์ฃผ์„ ๋ณด๊ฐ• * 2026.05.16 ์ž„๋„ํ—Œ Modified ํ˜„์žฌ actions ๊ณ„์ธต ์—ญํ• ์— ๋งž๊ฒŒ ํŒŒ์ผ ์„ค๋ช… ์ •๋ฆฌ + * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ๋ฐœ์†ก ์‹œ IP hash ๊ธฐ๋ฐ˜ ๋ฐœ์†ก ์ œํ•œ ์ปจํ…์ŠคํŠธ ์ „๋‹ฌ */ "use server"; +import { headers } from "next/headers"; import { phoneSchema, tokenSchema } from "@/features/auth/schemas/sms"; +import { getClientIpFromHeaders } from "@/features/auth/service/rateLimit"; import { saveUserSession } from "@/features/auth/service/authSession"; import { resolvePostAuthRedirectPath } from "@/features/auth/service/onboarding"; import { @@ -51,7 +54,9 @@ export async function sendPhoneToken( } // ์ธ์ฆ๋ฒˆํ˜ธ ์ƒ์„ฑ ๋ฐ ๋ฌธ์ž ๋ฐœ์†ก ์œ„์ž„ - const serviceRes = await createAndSendSmsToken(result.data); + const serviceRes = await createAndSendSmsToken(result.data, { + clientIp: getClientIpFromHeaders(headers()), + }); if (!serviceRes.success) { return { success: false, error: serviceRes.error }; } diff --git a/features/auth/constants.ts b/features/auth/constants.ts index 23e876af..897d79cf 100644 --- a/features/auth/constants.ts +++ b/features/auth/constants.ts @@ -11,6 +11,7 @@ * 2026.02.24 ์ž„๋„ํ—Œ Modified ์นด์นด์˜ค ๋กœ๊ทธ์ธ ๊ด€๋ จ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€ * 2026.04.02 ์ž„๋„ํ—Œ Modified ์ด๋ฉ”์ผ ์ธ์ฆ/๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ •/์ธ์ฆ ํ›„ ๋ณต๊ท€ ์ •์ฑ… ์ƒ์ˆ˜ ์ถ”๊ฐ€ * 2026.05.16 ์ž„๋„ํ—Œ Modified ํƒ€์ž… ์ „์šฉ import๋ฅผ ๋ช…์‹œํ•ด ๋Ÿฐํƒ€์ž„ ์˜์กด์„ฑ ์ œ๊ฑฐ + * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ์ธ์ฆ๋ฒˆํ˜ธ TTL, ์žฌ์ „์†ก ์ฟจ๋‹ค์šด, ์ธ์ฆ IP ์ œํ•œ ์ƒ์ˆ˜ ์ถ”๊ฐ€ */ import type { EmailVerifyState } from "@/features/auth/types"; @@ -48,6 +49,22 @@ export const POST_AUTH_BLOCKED_PREFIXES = [ /** SMS ์ž๋™ ์ƒ์„ฑ ์œ ์ €๋ช…์ฒ˜๋Ÿผ ๋ณด์™„์ด ํ•„์š”ํ•œ ์ž„์‹œ ๋‹‰๋„ค์ž„ ํŒจํ„ด */ export const TEMP_USERNAME_REGEX = /^user_[0-9a-f]{8}$/i; +/** SMS ์ธ์ฆ ์ •์ฑ…๊ฐ’ */ +/** SMS ์žฌ์ „์†ก ์ฟจ๋‹ค์šด(์ดˆ) */ +export const SMS_VERIFY_RESEND_COOLDOWN_SECONDS = 60; +/** SMS ์ธ์ฆ ํ† ํฐ ์œ ํšจ ์‹œ๊ฐ„(ms) */ +export const SMS_VERIFY_TOKEN_TTL_MS = 10 * 60 * 1000; +/** ๊ฐ™์€ IP์—์„œ ํ—ˆ์šฉํ•˜๋Š” SMS ๋ฐœ์†ก ์š”์ฒญ ์ˆ˜ */ +export const SMS_SEND_IP_RATE_LIMIT_MAX = 10; +/** IP ๊ธฐ์ค€ SMS ๋ฐœ์†ก ์š”์ฒญ ์ œํ•œ ์‹œ๊ฐ„ ์ฐฝ(ms) */ +export const SMS_SEND_IP_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; + +/** ํšŒ์›๊ฐ€์ž… ์ œ์ถœ rate limit ์ •์ฑ…๊ฐ’ */ +/** IP ๊ธฐ์ค€ ํšŒ์›๊ฐ€์ž… ์ œ์ถœ ์ œํ•œ ์‹œ๊ฐ„ ์ฐฝ(ms) */ +export const SIGNUP_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; +/** ๊ฐ™์€ IP์—์„œ ํ—ˆ์šฉํ•˜๋Š” ํšŒ์›๊ฐ€์ž… ์ œ์ถœ ์ˆ˜ */ +export const SIGNUP_RATE_LIMIT_MAX = 10; + /** ์ธ์ฆ ๊ด€๋ จ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ชจ์Œ */ export const AUTH_ERRORS = { NOT_LOGGED_IN: "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", @@ -68,6 +85,10 @@ export const AUTH_ERRORS = { // SMS SMS_SEND_FAILED: "SMS ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", SMS_VERIFY_FAILED: "์ธ์ฆ๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + SMS_RATE_LIMITED: "์ธ์ฆ๋ฒˆํ˜ธ๋ฅผ ๋ฐฉ๊ธˆ ๋ฐœ์†กํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", + + // Signup abuse control + SIGNUP_RATE_LIMITED: "์š”์ฒญ์ด ๋งŽ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", // GitHub GITHUB_TOKEN_FAILED: "GitHub ์ธ์ฆ ํ† ํฐ์„ ๋ฐ›์•„์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", diff --git a/features/auth/service/rateLimit.test.ts b/features/auth/service/rateLimit.test.ts new file mode 100644 index 00000000..8b7398f7 --- /dev/null +++ b/features/auth/service/rateLimit.test.ts @@ -0,0 +1,154 @@ +/** + * File Name : features/auth/service/rateLimit.test.ts + * Description : ์ธ์ฆ rate limit ์œ ํ‹ธ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.27 ์ž„๋„ํ—Œ Created ํšŒ์›๊ฐ€์ž… IP hash ๊ธฐ๋ฐ˜ ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + * 2026.06.27 ์ž„๋„ํ—Œ Modified advisory lock ๊ธฐ๋ฐ˜ ์›์ž์  ๊ธฐ๋ก ํ…Œ์ŠคํŠธ ๋ณด๊ฐ• + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + SIGNUP_RATE_LIMIT_MAX, + SIGNUP_RATE_LIMIT_WINDOW_MS, + SMS_SEND_IP_RATE_LIMIT_MAX, + SMS_SEND_IP_RATE_LIMIT_WINDOW_MS, +} from "@/features/auth/constants"; + +const mocks = vi.hoisted(() => ({ + db: { + $transaction: vi.fn(), + $executeRaw: vi.fn(), + authRateLimitEvent: { + deleteMany: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + }, + }, +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/db", () => ({ + default: mocks.db, +})); + +describe("auth rate limit service", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("RATE_LIMIT_SALT", "rate-limit-secret"); + + mocks.db.$transaction.mockImplementation(async (callback) => + callback(mocks.db) + ); + mocks.db.$executeRaw.mockResolvedValue(0); + mocks.db.authRateLimitEvent.deleteMany.mockResolvedValue({ count: 0 }); + mocks.db.authRateLimitEvent.findMany.mockResolvedValue([]); + mocks.db.authRateLimitEvent.create.mockResolvedValue({ id: "event-id" }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("x-forwarded-for์˜ ์ฒซ ๋ฒˆ์งธ IP๋ฅผ ํด๋ผ์ด์–ธํŠธ IP๋กœ ์‚ฌ์šฉํ•œ๋‹ค", async () => { + const { getClientIpFromHeaders } = await import("./rateLimit"); + const headers = new Headers({ + "x-forwarded-for": "203.0.113.10, 10.0.0.1", + "x-real-ip": "203.0.113.20", + }); + + expect(getClientIpFromHeaders(headers)).toBe("203.0.113.10"); + }); + + it("ํšŒ์›๊ฐ€์ž… ์ œ์ถœ ์ œํ•œ ์—ฌ์œ ๊ฐ€ ์žˆ์œผ๋ฉด hash๋งŒ ์ €์žฅํ•˜๊ณ  ํ—ˆ์šฉํ•œ๋‹ค", async () => { + const { checkAndRecordSignupAttemptByIp } = await import("./rateLimit"); + + const result = await checkAndRecordSignupAttemptByIp( + "203.0.113.10", + new Date("2026-06-27T00:00:00.000Z") + ); + + expect(result).toEqual({ allowed: true }); + expect(mocks.db.$transaction).toHaveBeenCalledTimes(1); + expect(mocks.db.$executeRaw).toHaveBeenCalledTimes(1); + expect(mocks.db.authRateLimitEvent.create).toHaveBeenCalledWith({ + data: { + kind: "signup-submit-ip", + keyHash: expect.stringMatching(/^[a-f0-9]{64}$/), + created_at: new Date("2026-06-27T00:00:00.000Z"), + }, + }); + expect(mocks.db.authRateLimitEvent.create.mock.calls[0][0].data.keyHash).not + .toBe("203.0.113.10"); + }); + + it("ํšŒ์›๊ฐ€์ž… ์ œ์ถœ ์ œํ•œ์„ ์ดˆ๊ณผํ•˜๋ฉด ๊ธฐ๋ก์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๊ณ  ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค", async () => { + const { checkAndRecordSignupAttemptByIp } = await import("./rateLimit"); + const oldest = new Date("2026-06-27T00:00:30.000Z"); + + mocks.db.authRateLimitEvent.findMany.mockResolvedValue( + Array.from({ length: SIGNUP_RATE_LIMIT_MAX }, (_, index) => ({ + created_at: new Date(oldest.getTime() + index * 1000), + })) + ); + + const result = await checkAndRecordSignupAttemptByIp( + "203.0.113.10", + new Date("2026-06-27T00:09:00.000Z") + ); + + expect(result).toEqual({ + allowed: false, + retryAfterSeconds: Math.ceil( + (oldest.getTime() + + SIGNUP_RATE_LIMIT_WINDOW_MS - + new Date("2026-06-27T00:09:00.000Z").getTime()) / + 1000 + ), + }); + expect(mocks.db.authRateLimitEvent.create).not.toHaveBeenCalled(); + }); + + it("SMS ๋ฐœ์†ก IP ์ œํ•œ์„ ์ดˆ๊ณผํ•˜๋ฉด ๊ธฐ๋ก์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๊ณ  ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค", async () => { + const { checkAndRecordSmsSendAttemptByIp } = await import("./rateLimit"); + const oldest = new Date("2026-06-27T00:10:00.000Z"); + + mocks.db.authRateLimitEvent.findMany.mockResolvedValue( + Array.from({ length: SMS_SEND_IP_RATE_LIMIT_MAX }, (_, index) => ({ + created_at: new Date(oldest.getTime() + index * 1000), + })) + ); + + const result = await checkAndRecordSmsSendAttemptByIp( + "203.0.113.10", + new Date("2026-06-27T00:55:00.000Z") + ); + + expect(result).toEqual({ + allowed: false, + retryAfterSeconds: Math.ceil( + (oldest.getTime() + + SMS_SEND_IP_RATE_LIMIT_WINDOW_MS - + new Date("2026-06-27T00:55:00.000Z").getTime()) / + 1000 + ), + }); + expect(mocks.db.authRateLimitEvent.create).not.toHaveBeenCalled(); + }); + + it("์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ ์ •๋ฆฌ ์‹คํŒจ๋Š” ํ˜„์žฌ ์š”์ฒญ์„ ๋ง‰์ง€ ์•Š๋Š”๋‹ค", async () => { + const { checkAndRecordSignupAttemptByIp } = await import("./rateLimit"); + + mocks.db.authRateLimitEvent.deleteMany.mockRejectedValue( + new Error("cleanup failed") + ); + + const result = await checkAndRecordSignupAttemptByIp("203.0.113.10"); + + expect(result).toEqual({ allowed: true }); + expect(mocks.db.authRateLimitEvent.create).toHaveBeenCalled(); + }); +}); diff --git a/features/auth/service/rateLimit.ts b/features/auth/service/rateLimit.ts new file mode 100644 index 00000000..b68425a1 --- /dev/null +++ b/features/auth/service/rateLimit.ts @@ -0,0 +1,179 @@ +/** + * File Name : features/auth/service/rateLimit.ts + * Description : ์ธ์ฆ ๊ฒฝ๋กœ ๋‚จ์šฉ ๋ฐฉ์ง€์šฉ rate limit ์œ ํ‹ธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.27 ์ž„๋„ํ—Œ Created ํšŒ์›๊ฐ€์ž… IP hash ๊ธฐ๋ฐ˜ ๋‹จ๊ธฐ ์ œ์ถœ ์ œํ•œ ์ถ”๊ฐ€ + * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ๋ฐœ์†ก IP hash ๊ธฐ๋ฐ˜ ์‹œ๊ฐ„๋‹น ์ œํ•œ ์ถ”๊ฐ€ + * 2026.06.27 ์ž„๋„ํ—Œ Modified kind/keyHash ๋‹จ์œ„ transaction advisory lock ์ ์šฉ + */ + +import "server-only"; +import crypto from "node:crypto"; +import db from "@/lib/db"; +import { + SIGNUP_RATE_LIMIT_MAX, + SIGNUP_RATE_LIMIT_WINDOW_MS, + SMS_SEND_IP_RATE_LIMIT_MAX, + SMS_SEND_IP_RATE_LIMIT_WINDOW_MS, +} from "@/features/auth/constants"; + +const SIGNUP_RATE_LIMIT_KIND = "signup-submit-ip"; +const SMS_SEND_RATE_LIMIT_KIND = "sms-send-ip"; + +type AuthRateLimitResult = + | { allowed: true } + | { allowed: false; retryAfterSeconds: number }; + +/** + * ์š”์ฒญ ํ—ค๋”์—์„œ rate limit ์‹๋ณ„์šฉ ํด๋ผ์ด์–ธํŠธ IP ํ›„๋ณด๋ฅผ ์ถ”์ถœ + * + * @param {Headers} headers - ํ˜„์žฌ ์š”์ฒญ ํ—ค๋” + * @returns {string | null} ์‹๋ณ„ ๊ฐ€๋Šฅํ•œ IP ํ›„๋ณด + */ +export function getClientIpFromHeaders(headers: Headers): string | null { + const forwardedFor = headers + .get("x-forwarded-for") + ?.split(",") + .map((value) => value.trim()) + .find(Boolean); + + return ( + forwardedFor ?? + headers.get("x-real-ip")?.trim() ?? + headers.get("cf-connecting-ip")?.trim() ?? + null + ); +} + +/** + * rate limit ์‹๋ณ„์ž๋ฅผ ์ €์žฅ์šฉ HMAC hash๋กœ ๋ณ€ํ™˜ + * + * @param {string} value - IP ๋“ฑ rate limit ์‹๋ณ„์ž ์›๋ฌธ + * @returns {string | null} ์ €์žฅ ๊ฐ€๋Šฅํ•œ hash ๊ฐ’ + */ +export function hashRateLimitKey(value: string): string | null { + const secret = process.env.RATE_LIMIT_SALT ?? process.env.COOKIE_PASSWORD; + if (!secret) return null; + + return crypto.createHmac("sha256", secret).update(value).digest("hex"); +} + +/** + * ์ธ์ฆ rate limit ์ด๋ฒคํŠธ๋ฅผ ์กฐํšŒํ•˜๊ณ  ํ—ˆ์šฉ ์‹œ ํ˜„์žฌ ์š”์ฒญ์„ ๊ธฐ๋ก + * + * @param {{ kind: string; key: string | null; limit: number; windowMs: number }} input - ์ •์ฑ… ์ข…๋ฅ˜, ์‹๋ณ„์ž, ์ œํ•œ ์ˆ˜, ์‹œ๊ฐ„ ์ฐฝ + * @param {Date} now - ํ…Œ์ŠคํŠธ์™€ ๊ณ„์‚ฐ ๊ธฐ์ค€ ์‹œ๊ฐ + * @returns {Promise} ํ—ˆ์šฉ ์—ฌ๋ถ€์™€ ์ œํ•œ ์‹œ ๋‚จ์€ ๋Œ€๊ธฐ ์‹œ๊ฐ„ + */ +async function checkAndRecordAuthRateLimitEvent( + input: { + kind: string; + key: string | null; + limit: number; + windowMs: number; + }, + now: Date = new Date() +): Promise { + if (!input.key) return { allowed: true }; + + const keyHash = hashRateLimitKey(input.key); + if (!keyHash) return { allowed: true }; + + return db.$transaction(async (tx) => { + // ๊ฐ™์€ ์ •์ฑ…/์‹๋ณ„์ž์— ๋Œ€ํ•œ check-and-record ๊ฒฝ์Ÿ์„ DB transaction ๋‹จ์œ„๋กœ ์ง๋ ฌํ™” + 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, + keyHash, + created_at: { gte: windowStart }, + }, + orderBy: { created_at: "asc" }, + select: { created_at: true }, + }); + + if (recentAttempts.length >= input.limit) { + const resetAt = + recentAttempts[0].created_at.getTime() + input.windowMs; + const retryAfterSeconds = Math.max( + 1, + Math.ceil((resetAt - now.getTime()) / 1000) + ); + + return { allowed: false, retryAfterSeconds }; + } + + await tx.authRateLimitEvent.create({ + data: { + kind: input.kind, + keyHash, + created_at: now, + }, + }); + + return { allowed: true }; + }); +} + +/** + * ํšŒ์›๊ฐ€์ž… ์ œ์ถœ์— ๋Œ€ํ•œ IP ๊ธฐ์ค€ ๋‹จ๊ธฐ rate limit์„ ํ™•์ธํ•˜๊ณ  ๊ธฐ๋ก + * + * @param {string | null} ip - ์š”์ฒญ IP ํ›„๋ณด + * @param {Date} now - ํ…Œ์ŠคํŠธ์™€ ๊ณ„์‚ฐ ๊ธฐ์ค€ ์‹œ๊ฐ + * @returns {Promise} ํ—ˆ์šฉ ์—ฌ๋ถ€์™€ ์ œํ•œ ์‹œ ๋‚จ์€ ๋Œ€๊ธฐ ์‹œ๊ฐ„ + */ +export async function checkAndRecordSignupAttemptByIp( + ip: string | null, + now: Date = new Date() +): Promise { + return checkAndRecordAuthRateLimitEvent( + { + kind: SIGNUP_RATE_LIMIT_KIND, + key: ip, + limit: SIGNUP_RATE_LIMIT_MAX, + windowMs: SIGNUP_RATE_LIMIT_WINDOW_MS, + }, + now + ); +} + +/** + * SMS ์ธ์ฆ ๋ฐœ์†ก์— ๋Œ€ํ•œ IP ๊ธฐ์ค€ rate limit์„ ํ™•์ธํ•˜๊ณ  ๊ธฐ๋ก + * + * @param {string | null} ip - ์š”์ฒญ IP ํ›„๋ณด + * @param {Date} now - ํ…Œ์ŠคํŠธ์™€ ๊ณ„์‚ฐ ๊ธฐ์ค€ ์‹œ๊ฐ + * @returns {Promise} ํ—ˆ์šฉ ์—ฌ๋ถ€์™€ ์ œํ•œ ์‹œ ๋‚จ์€ ๋Œ€๊ธฐ ์‹œ๊ฐ„ + */ +export async function checkAndRecordSmsSendAttemptByIp( + ip: string | null, + now: Date = new Date() +): Promise { + return checkAndRecordAuthRateLimitEvent( + { + kind: SMS_SEND_RATE_LIMIT_KIND, + key: ip, + limit: SMS_SEND_IP_RATE_LIMIT_MAX, + windowMs: SMS_SEND_IP_RATE_LIMIT_WINDOW_MS, + }, + now + ); +} diff --git a/features/auth/service/sms.test.ts b/features/auth/service/sms.test.ts new file mode 100644 index 00000000..66624b62 --- /dev/null +++ b/features/auth/service/sms.test.ts @@ -0,0 +1,221 @@ +/** + * File Name : features/auth/service/sms.test.ts + * Description : SMS ์ธ์ฆ ํ† ํฐ TTL/์ฟจ๋‹ค์šด ์ •ํ•ฉ์„ฑ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.27 ์ž„๋„ํ—Œ Created SMS ๋งŒ๋ฃŒ/์ฟจ๋‹ค์šด/๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_ERRORS } from "@/features/auth/constants"; + +const mocks = vi.hoisted(() => ({ + db: { + sMSToken: { + deleteMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + user: { + update: vi.fn(), + }, + }, + sendSMS: vi.fn(), + generateUniqueSmsToken: vi.fn(), + checkAndRecordSmsSendAttemptByIp: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/db", () => ({ + default: mocks.db, +})); + +vi.mock("@/features/auth/utils/smsSender", () => ({ + sendSMS: mocks.sendSMS, +})); + +vi.mock("@/features/auth/service/token", () => ({ + generateUniqueSmsToken: mocks.generateUniqueSmsToken, +})); + +vi.mock("@/features/auth/service/rateLimit", () => ({ + checkAndRecordSmsSendAttemptByIp: mocks.checkAndRecordSmsSendAttemptByIp, +})); + +describe("SMS verification service", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-27T00:00:00.000Z")); + vi.clearAllMocks(); + + mocks.db.sMSToken.deleteMany.mockResolvedValue({ count: 0 }); + mocks.db.sMSToken.create.mockResolvedValue({ id: 1 }); + mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 1 }); + mocks.db.sMSToken.delete.mockResolvedValue({ id: 1 }); + mocks.sendSMS.mockResolvedValue(undefined); + mocks.generateUniqueSmsToken.mockResolvedValue("654321"); + mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({ + allowed: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("์žฌ์ „์†ก ์ฟจ๋‹ค์šด ์•ˆ์—์„œ๋Š” ์ƒˆ SMS๋ฅผ ๋ฐœ์†กํ•˜์ง€ ์•Š๋Š”๋‹ค", 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-27T00:00:00.000Z"), + expires_at: new Date("2026-06-27T00:10:00.000Z"), + }); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }); + expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled(); + expect(mocks.sendSMS).not.toHaveBeenCalled(); + }); + + it("๋™์‹œ ์žฌ์š”์ฒญ์œผ๋กœ ๋ฐœ์†ก ์Šฌ๋กฏ์„ ํ™•๋ณดํ•˜์ง€ ๋ชปํ•˜๋ฉด SMS๋ฅผ ๋ฐœ์†กํ•˜์ง€ ์•Š๋Š”๋‹ค", 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"), + }); + mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 0 }); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }); + expect(mocks.sendSMS).not.toHaveBeenCalled(); + }); + + it("IP ๊ธฐ์ค€ SMS ๋ฐœ์†ก ์ œํ•œ์— ๊ฑธ๋ฆฌ๋ฉด ์ƒˆ ํ† ํฐ์„ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue(null); + mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({ + allowed: false, + retryAfterSeconds: 120, + }); + + const result = await createAndSendSmsToken("01012345678", { + clientIp: "203.0.113.10", + }); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }); + expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled(); + expect(mocks.db.sMSToken.create).not.toHaveBeenCalled(); + expect(mocks.sendSMS).not.toHaveBeenCalled(); + }); + + it("SMS ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์ด์ „ ์œ ํšจ ํ† ํฐ์„ ๋ณต๊ตฌํ•œ๋‹ค", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + const previous = { + 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"), + }; + + mocks.db.sMSToken.findUnique.mockResolvedValue(previous); + mocks.sendSMS.mockRejectedValue(new Error("provider failed")); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_SEND_FAILED, + code: "SMS_SEND_FAILED", + }); + expect(mocks.db.sMSToken.updateMany).toHaveBeenLastCalledWith({ + where: { + id: previous.id, + token: "654321", + created_at: new Date("2026-06-27T00:00:00.000Z"), + }, + data: { + token: previous.token, + phone: previous.phone, + created_at: previous.created_at, + expires_at: previous.expires_at, + }, + }); + }); + + it("์ตœ์ดˆ SMS ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์ƒˆ๋กœ ๋งŒ๋“  ํ† ํฐ์„ ์ •๋ฆฌํ•œ๋‹ค", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue(null); + mocks.sendSMS.mockRejectedValue(new Error("provider failed")); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_SEND_FAILED, + code: "SMS_SEND_FAILED", + }); + expect(mocks.db.sMSToken.deleteMany).toHaveBeenLastCalledWith({ + where: { phone: "01012345678", token: "654321" }, + }); + }); + + it("๋งŒ๋ฃŒ๋œ SMS ์ธ์ฆ๋ฒˆํ˜ธ๋Š” ๊ฒ€์ฆ์„ ๊ฑฐ๋ถ€ํ•˜๊ณ  ์ •๋ฆฌํ•œ๋‹ค", async () => { + const { verifySmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + userId: 10, + phone: "01012345678", + expires_at: new Date("2026-06-26T23:59:59.000Z"), + user: { + id: 10, + bannedAt: null, + bannedUntil: null, + }, + }); + + const result = await verifySmsToken("01012345678", "123456"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_VERIFY_FAILED, + }); + expect(mocks.db.sMSToken.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + }); +}); diff --git a/features/auth/service/sms.ts b/features/auth/service/sms.ts index 4d12e99b..093b13e0 100644 --- a/features/auth/service/sms.ts +++ b/features/auth/service/sms.ts @@ -10,59 +10,168 @@ * 2026.01.25 ์ž„๋„ํ—Œ Modified ์ฃผ์„ ๋ณด๊ฐ• * 2026.02.08 ์ž„๋„ํ—Œ Modified ๋กœ๊ทธ์ธ ์‹œ ์ •์ง€(Ban) ์ฒดํฌ ๋ฐ ๋งŒ๋ฃŒ ์‹œ ์ž๋™ ํ•ด์ œ ๋กœ์ง ์ถ”๊ฐ€ * 2026.04.04 ์ž„๋„ํ—Œ Modified SMS ํ† ํฐ ๋ฐœ๊ธ‰/์†Œ๋ชจ ๋‹จ๊ณ„์˜ ์ธ๋ผ์ธ ์ฃผ์„ ๋ณด๊ฐ• + * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ํ† ํฐ TTL, ์žฌ์ „์†ก/IP ์ฟจ๋‹ค์šด, ๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€ */ import "server-only"; import crypto from "crypto"; import { sendSMS } from "@/features/auth/utils/smsSender"; import { generateUniqueSmsToken } from "@/features/auth/service/token"; -import { AUTH_ERRORS } from "@/features/auth/constants"; +import { checkAndRecordSmsSendAttemptByIp } from "@/features/auth/service/rateLimit"; +import { + AUTH_ERRORS, + SMS_VERIFY_RESEND_COOLDOWN_SECONDS, + SMS_VERIFY_TOKEN_TTL_MS, +} from "@/features/auth/constants"; import db from "@/lib/db"; +import { isUniqueConstraintError } from "@/lib/errors"; import type { ServiceResult } from "@/lib/types"; +type SmsSendFailureCode = "SMS_SEND_FAILED" | "SMS_RATE_LIMITED"; + /** * ์ „ํ™”๋ฒˆํ˜ธ๋กœ ์ธ์ฆ ํ† ํฐ ์ƒ์„ฑ ๋ฐ SMS ๋ฐœ์†ก์„ ์ˆ˜ํ–‰ - * ๊ธฐ์กด ํ† ํฐ์ด ์žˆ๋‹ค๋ฉด ์‚ญ์ œํ•˜๊ณ  ์ƒˆ๋กœ ์ƒ์„ฑ + * ๊ธฐ์กด ์œ ํšจ ํ† ํฐ์ด ์žˆ๋‹ค๋ฉด ์ฟจ๋‹ค์šด์„ ํ™•์ธํ•˜๊ณ , ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์ด์ „ ํ† ํฐ ์ƒํƒœ๋ฅผ ๋ณต๊ตฌ * * @param {string} phone - ๊ฒ€์ฆ๋œ ์ „ํ™”๋ฒˆํ˜ธ (ํ•˜์ดํ”ˆ ์—†๋Š” ์ˆซ์ž) - * @returns {Promise} ์„ฑ๊ณต ์—ฌ๋ถ€ + * @param {{ clientIp?: string | null }} [options] - SMS IP rate limit ๊ณ„์‚ฐ์— ์‚ฌ์šฉํ•  ์š”์ฒญ ์ปจํ…์ŠคํŠธ + * @returns {Promise>} ์„ฑ๊ณต ์—ฌ๋ถ€ */ export async function createAndSendSmsToken( - phone: string -): Promise> { + phone: string, + options: { clientIp?: string | null } = {} +): Promise> { + const now = new Date(); + const cooldownCutoff = new Date( + now.getTime() - SMS_VERIFY_RESEND_COOLDOWN_SECONDS * 1000 + ); + try { + await db.sMSToken.deleteMany({ + where: { expires_at: { lt: now } }, + }); + + const previousToken = await db.sMSToken.findUnique({ + where: { phone }, + select: { + id: true, + token: true, + phone: true, + userId: true, + created_at: true, + expires_at: true, + }, + }); + + if (previousToken && previousToken.created_at > cooldownCutoff) { + return { + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }; + } + + const ipLimit = await checkAndRecordSmsSendAttemptByIp( + options.clientIp ?? null + ); + if (!ipLimit.allowed) { + return { + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }; + } + // ์ค‘๋ณต ์—†๋Š” 6์ž๋ฆฌ ์ธ์ฆ ํ† ํฐ ์ƒ์„ฑ const token = await generateUniqueSmsToken(); + const expiresAt = new Date(now.getTime() + SMS_VERIFY_TOKEN_TTL_MS); + let createdNewToken = false; - // ๊ฐ™์€ ๋ฒˆํ˜ธ์˜ ๊ธฐ์กด ๋ฏธ์‚ฌ์šฉ ํ† ํฐ ์ •๋ฆฌ - await db.sMSToken.deleteMany({ - where: { user: { phone } }, - }); + if (previousToken) { + const updateResult = await db.sMSToken.updateMany({ + where: { + id: previousToken.id, + created_at: { lte: cooldownCutoff }, + }, + data: { + token, + phone, + created_at: now, + expires_at: expiresAt, + }, + }); - // ํ† ํฐ ์ €์žฅ ๋ฐ phone ๊ธฐ์ค€ ์ž„์‹œ ๊ณ„์ • ์—ฐ๊ฒฐ - await db.sMSToken.create({ - data: { - token, - phone, - user: { - connectOrCreate: { - where: { phone }, - create: { - username: `user_${crypto.randomBytes(4).toString("hex")}`, - phone, + if (updateResult.count === 0) { + return { + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }; + } + } else { + // ํ† ํฐ ์ €์žฅ ๋ฐ 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; + } // ํ† ํฐ ์ €์žฅ ํ›„ ์‹ค์ œ SMS ๋ฐœ์†ก - await sendSMS(phone, token); + try { + await sendSMS(phone, token); + } catch (error) { + if (previousToken) { + await db.sMSToken.updateMany({ + where: { + id: previousToken.id, + token, + created_at: now, + }, + data: { + token: previousToken.token, + phone: previousToken.phone, + created_at: previousToken.created_at, + expires_at: previousToken.expires_at, + }, + }); + } else if (createdNewToken) { + await db.sMSToken.deleteMany({ + where: { phone, token }, + }); + } + + throw error; + } return { success: true }; } catch (error) { + if (isUniqueConstraintError(error, ["phone"])) { + return { + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + code: "SMS_RATE_LIMITED", + }; + } + console.error("SMS Send Error:", error); - return { success: false, error: AUTH_ERRORS.SMS_SEND_FAILED }; + return { + success: false, + error: AUTH_ERRORS.SMS_SEND_FAILED, + code: "SMS_SEND_FAILED", + }; } } @@ -84,6 +193,7 @@ export async function verifySmsToken( id: true, userId: true, phone: true, + expires_at: true, user: { select: { id: true, bannedAt: true, bannedUntil: true }, }, @@ -95,6 +205,11 @@ export async function verifySmsToken( return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED }; } + if (verifiedToken.expires_at < new Date()) { + await db.sMSToken.delete({ where: { id: verifiedToken.id } }); + return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED }; + } + const user = verifiedToken.user; // ์ •์ง€ ์ƒํƒœ ํ™•์ธ ๋ฐ ๋งŒ๋ฃŒ ์‹œ ์ง€์—ฐ ํ•ด์ œ diff --git a/features/user/actions/phone.ts b/features/user/actions/phone.ts index 7ac5c101..3032af09 100644 --- a/features/user/actions/phone.ts +++ b/features/user/actions/phone.ts @@ -6,11 +6,14 @@ * History * Date Author Status Description * 2026.01.24 ์ž„๋„ํ—Œ Created Action ์ •์˜ + * 2026.06.27 ์ž„๋„ํ—Œ Modified ํ”„๋กœํ•„ SMS ๋ฐœ์†ก์— IP rate limit ์ปจํ…์ŠคํŠธ ์ „๋‹ฌ */ "use server"; +import { headers } from "next/headers"; import getSession from "@/lib/session"; import { phoneSchema, tokenSchema } from "@/features/auth/schemas/sms"; +import { getClientIpFromHeaders } from "@/features/auth/service/rateLimit"; import { sendProfilePhoneTokenService, verifyProfilePhoneTokenService, @@ -31,7 +34,9 @@ export async function sendProfilePhoneTokenAction(formData: FormData) { } // 2. Service ํ˜ธ์ถœ (์ค‘๋ณต ํ™•์ธ ๋ฐ SMS ๋ฐœ์†ก) - return await sendProfilePhoneTokenService(session.id, parsed.data); + return await sendProfilePhoneTokenService(session.id, parsed.data, { + clientIp: getClientIpFromHeaders(headers()), + }); } /** diff --git a/features/user/service/phone.test.ts b/features/user/service/phone.test.ts new file mode 100644 index 00000000..0d4d3858 --- /dev/null +++ b/features/user/service/phone.test.ts @@ -0,0 +1,194 @@ +/** + * File Name : features/user/service/phone.test.ts + * Description : ํ”„๋กœํ•„ ํœด๋Œ€ํฐ ์ธ์ฆ SMS ๋ณดํ˜ธ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.27 ์ž„๋„ํ—Œ Created ํ”„๋กœํ•„ SMS ์ฟจ๋‹ค์šด/IP ์ œํ•œ/๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_ERRORS } from "@/features/auth/constants"; + +const mocks = vi.hoisted(() => ({ + db: { + sMSToken: { + deleteMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }, + user: { + findFirst: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, + sendSMS: vi.fn(), + generateUniqueSmsToken: vi.fn(), + checkAndRecordSmsSendAttemptByIp: vi.fn(), + validateUserStatus: vi.fn(), + onVerificationUpdate: vi.fn(), + revalidatePath: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => ({ + revalidatePath: mocks.revalidatePath, +})); + +vi.mock("@/lib/db", () => ({ + default: mocks.db, +})); + +vi.mock("@/features/auth/utils/smsSender", () => ({ + sendSMS: mocks.sendSMS, +})); + +vi.mock("@/features/auth/service/token", () => ({ + generateUniqueSmsToken: mocks.generateUniqueSmsToken, +})); + +vi.mock("@/features/auth/service/rateLimit", () => ({ + checkAndRecordSmsSendAttemptByIp: mocks.checkAndRecordSmsSendAttemptByIp, +})); + +vi.mock("@/features/user/service/admin", () => ({ + validateUserStatus: mocks.validateUserStatus, +})); + +vi.mock("./badge", () => ({ + badgeChecks: { + onVerificationUpdate: mocks.onVerificationUpdate, + }, +})); + +describe("profile phone verification service", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-27T00:00:00.000Z")); + vi.clearAllMocks(); + + mocks.validateUserStatus.mockResolvedValue({ success: true }); + mocks.db.user.findFirst.mockResolvedValue(null); + mocks.db.sMSToken.deleteMany.mockResolvedValue({ count: 0 }); + mocks.db.sMSToken.findFirst.mockResolvedValue(null); + mocks.db.sMSToken.create.mockResolvedValue({ id: 1 }); + mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 1 }); + mocks.sendSMS.mockResolvedValue(undefined); + mocks.generateUniqueSmsToken.mockResolvedValue("654321"); + mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({ + allowed: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("์žฌ์ „์†ก ์ฟจ๋‹ค์šด ์•ˆ์—์„œ๋Š” ํ”„๋กœํ•„ SMS๋ฅผ ๋ฐœ์†กํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + const { sendProfilePhoneTokenService } = await import("./phone"); + + mocks.db.sMSToken.findFirst.mockResolvedValue({ + id: 1, + token: "123456", + phone: "01012345678", + userId: 10, + created_at: new Date("2026-06-27T00:00:00.000Z"), + expires_at: new Date("2026-06-27T00:10:00.000Z"), + }); + + const result = await sendProfilePhoneTokenService(10, "01012345678"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + }); + expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled(); + expect(mocks.sendSMS).not.toHaveBeenCalled(); + }); + + it("IP ๊ธฐ์ค€ SMS ๋ฐœ์†ก ์ œํ•œ์— ๊ฑธ๋ฆฌ๋ฉด ํ”„๋กœํ•„ ํ† ํฐ์„ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค", async () => { + const { sendProfilePhoneTokenService } = await import("./phone"); + + mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({ + allowed: false, + retryAfterSeconds: 120, + }); + + const result = await sendProfilePhoneTokenService(10, "01012345678", { + clientIp: "203.0.113.10", + }); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + }); + expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled(); + expect(mocks.db.sMSToken.create).not.toHaveBeenCalled(); + expect(mocks.sendSMS).not.toHaveBeenCalled(); + }); + + it("๋™์‹œ ์žฌ์š”์ฒญ์œผ๋กœ ํ”„๋กœํ•„ SMS ๋ฐœ์†ก ์Šฌ๋กฏ์„ ํ™•๋ณดํ•˜์ง€ ๋ชปํ•˜๋ฉด ๋ฐœ์†กํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + const { sendProfilePhoneTokenService } = await import("./phone"); + + mocks.db.sMSToken.findFirst.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"), + }); + mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 0 }); + + const result = await sendProfilePhoneTokenService(10, "01012345678"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_RATE_LIMITED, + }); + expect(mocks.sendSMS).not.toHaveBeenCalled(); + }); + + it("ํ”„๋กœํ•„ SMS ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์ด์ „ ์œ ํšจ ํ† ํฐ์„ ๋ณต๊ตฌํ•œ๋‹ค", async () => { + const { sendProfilePhoneTokenService } = await import("./phone"); + + const previous = { + 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"), + }; + + mocks.db.sMSToken.findFirst.mockResolvedValue(previous); + mocks.sendSMS.mockRejectedValue(new Error("provider failed")); + + const result = await sendProfilePhoneTokenService(10, "01012345678"); + + expect(result).toEqual({ + success: false, + error: + "์ธ์ฆ๋ฒˆํ˜ธ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", + }); + expect(mocks.db.sMSToken.updateMany).toHaveBeenLastCalledWith({ + where: { + id: previous.id, + token: "654321", + created_at: new Date("2026-06-27T00:00:00.000Z"), + }, + data: { + token: previous.token, + phone: previous.phone, + userId: previous.userId, + created_at: previous.created_at, + expires_at: previous.expires_at, + }, + }); + }); +}); diff --git a/features/user/service/phone.ts b/features/user/service/phone.ts index d3adfb10..bf48130e 100644 --- a/features/user/service/phone.ts +++ b/features/user/service/phone.ts @@ -15,6 +15,7 @@ * 2026.03.05 ์ž„๋„ํ—Œ Modified ํœด๋Œ€ํฐ ์ธ์ฆ ์™„๋ฃŒ ์‹œ์˜ ๊ฐœ์ธํ™” ์บ์‹œ ํƒœ๊ทธ ๋ฌดํšจํ™” ๋กœ์ง ์ œ๊ฑฐ ๋ฐ `revalidatePath` ๊ธฐ๋ฐ˜ ๋‹จ์ˆœํ™” ์ ์šฉ * 2026.03.07 ์ž„๋„ํ—Œ Modified ์ธ์ฆ ์‹คํŒจ ๋ฌธ๊ตฌ๋ฅผ ๊ตฌ์ฒดํ™”(v1.2) * 2026.03.07 ์ž„๋„ํ—Œ Modified ์ •์ง€ ์œ ์ € ๊ฐ€๋“œ ๋ฐ SMS ๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ๋ณด๊ฐ• + * 2026.06.27 ์ž„๋„ํ—Œ Modified ํ”„๋กœํ•„ ํœด๋Œ€ํฐ ์ธ์ฆ ํ† ํฐ TTL, ์žฌ์ „์†ก/IP ์ฟจ๋‹ค์šด, ๋ฐœ์†ก ์‹คํŒจ ๋กค๋ฐฑ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€ */ import "server-only"; @@ -22,6 +23,12 @@ import { revalidatePath } from "next/cache"; import db from "@/lib/db"; import { sendSMS } from "@/features/auth/utils/smsSender"; import { generateUniqueSmsToken } from "@/features/auth/service/token"; +import { checkAndRecordSmsSendAttemptByIp } from "@/features/auth/service/rateLimit"; +import { + AUTH_ERRORS, + SMS_VERIFY_RESEND_COOLDOWN_SECONDS, + SMS_VERIFY_TOKEN_TTL_MS, +} from "@/features/auth/constants"; import { badgeChecks } from "./badge"; import { isUniqueConstraintError } from "@/lib/errors"; import { validateUserStatus } from "@/features/user/service/admin"; @@ -29,10 +36,16 @@ import type { ServiceResult } from "@/lib/types"; /** * ํ”„๋กœํ•„ ์ธ์ฆ์šฉ SMS ๋ฐœ์†ก + * + * @param {number} userId - ์ธ์ฆ์„ ์š”์ฒญํ•œ ์‚ฌ์šฉ์ž ID + * @param {string} phone - ๊ฒ€์ฆํ•  ์ „ํ™”๋ฒˆํ˜ธ + * @param {{ clientIp?: string | null }} [options] - SMS IP rate limit ๊ณ„์‚ฐ์— ์‚ฌ์šฉํ•  ์š”์ฒญ ์ปจํ…์ŠคํŠธ + * @returns {Promise} ๋ฐœ์†ก ์„ฑ๊ณต ์—ฌ๋ถ€ */ export async function sendProfilePhoneTokenService( userId: number, - phone: string + phone: string, + options: { clientIp?: string | null } = {} ): Promise { const userStatus = await validateUserStatus(userId); if (!userStatus.success) { @@ -48,25 +61,100 @@ export async function sendProfilePhoneTokenService( return { success: false, error: "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์ „ํ™”๋ฒˆํ˜ธ์ž…๋‹ˆ๋‹ค." }; } - // 2. ๊ธฐ์กด ํ† ํฐ ์ •๋ฆฌ (ํ•ด๋‹น ๋ฒˆํ˜ธ ๋˜๋Š” ์œ ์ €์—๊ฒŒ ๋ฐœ์†ก๋œ ๋ฏธ์‚ฌ์šฉ ํ† ํฐ ์‚ญ์ œ) + const now = new Date(); + const cooldownCutoff = new Date( + now.getTime() - SMS_VERIFY_RESEND_COOLDOWN_SECONDS * 1000 + ); + await db.sMSToken.deleteMany({ + where: { expires_at: { lt: now } }, + }); + + const previousToken = await db.sMSToken.findFirst({ where: { OR: [{ phone }, { userId }] }, + orderBy: { created_at: "desc" }, + select: { + id: true, + token: true, + phone: true, + userId: true, + created_at: true, + expires_at: true, + }, }); + if (previousToken && previousToken.created_at > cooldownCutoff) { + return { success: false, error: AUTH_ERRORS.SMS_RATE_LIMITED }; + } + + const ipLimit = await checkAndRecordSmsSendAttemptByIp( + options.clientIp ?? null + ); + if (!ipLimit.allowed) { + return { success: false, error: AUTH_ERRORS.SMS_RATE_LIMITED }; + } + // 3. ์ƒˆ ํ† ํฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ const token = await generateUniqueSmsToken(); - await db.sMSToken.create({ - data: { token, phone, userId }, - }); + const expiresAt = new Date(now.getTime() + SMS_VERIFY_TOKEN_TTL_MS); + let createdNewToken = false; + + if (previousToken) { + const updateResult = await db.sMSToken.updateMany({ + where: { + id: previousToken.id, + created_at: { lte: cooldownCutoff }, + }, + data: { + token, + phone, + userId, + created_at: now, + expires_at: expiresAt, + }, + }); + + if (updateResult.count === 0) { + return { success: false, error: AUTH_ERRORS.SMS_RATE_LIMITED }; + } + } else { + await db.sMSToken.create({ + data: { + token, + phone, + userId, + expires_at: expiresAt, + }, + }); + createdNewToken = true; + } // 4. SMS ๋ฐœ์†ก try { await sendSMS(phone, token); } catch (error) { console.error("sendProfilePhoneTokenService error:", error); - await db.sMSToken.deleteMany({ - where: { token, phone, userId }, - }); + if (previousToken) { + await db.sMSToken.updateMany({ + where: { + id: previousToken.id, + token, + created_at: now, + }, + data: { + token: previousToken.token, + phone: previousToken.phone, + userId: previousToken.userId, + created_at: previousToken.created_at, + expires_at: previousToken.expires_at, + }, + }); + } else if (createdNewToken) { + await db.sMSToken.deleteMany({ + where: { token, phone, userId }, + }); + } + return { success: false, error: @@ -82,6 +170,11 @@ export async function sendProfilePhoneTokenService( * * ํŠธ๋žœ์žญ์…˜์„ ์ ์šฉํ•˜์—ฌ ํ† ํฐ ์‚ญ์ œ์™€ ์œ ์ € ์ •๋ณด ๊ฐฑ์‹ ์ด ๋™์‹œ์— ์„ฑ๊ณตํ•˜๊ฑฐ๋‚˜, * ๋™์‹œ์— ์‹คํŒจ(๋กค๋ฐฑ)ํ•˜๋„๋ก ๋ณด์žฅ + * + * @param {number} userId - ์ธ์ฆ์„ ์™„๋ฃŒํ•  ์‚ฌ์šฉ์ž ID + * @param {string} phone - ๊ฒ€์ฆํ•  ์ „ํ™”๋ฒˆํ˜ธ + * @param {string} token - ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ์ธ์ฆ๋ฒˆํ˜ธ + * @returns {Promise} ์ „ํ™”๋ฒˆํ˜ธ ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต ์—ฌ๋ถ€ */ export async function verifyProfilePhoneTokenService( userId: number, @@ -96,7 +189,7 @@ export async function verifyProfilePhoneTokenService( // 1. ํ† ํฐ ์กฐํšŒ (๋ฒˆํ˜ธ, ํ† ํฐ, ์œ ์ € ์ผ์น˜ ์—ฌ๋ถ€) const verified = await db.sMSToken.findFirst({ where: { token, phone, userId }, - select: { id: true }, + select: { id: true, expires_at: true }, }); if (!verified) { @@ -106,6 +199,14 @@ export async function verifyProfilePhoneTokenService( }; } + if (verified.expires_at < new Date()) { + await db.sMSToken.delete({ where: { id: verified.id } }); + return { + success: false, + error: "์ „ํ™”๋ฒˆํ˜ธ์™€ ์ธ์ฆ๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + }; + } + try { // 2. ํŠธ๋žœ์žญ์…˜ ์‹คํ–‰ (ํ† ํฐ ์†Œ๋ชจ + ์œ ์ € ์ •๋ณด ์—…๋ฐ์ดํŠธ) await db.$transaction(async (tx) => { diff --git a/lib/cloudflareImages.test.ts b/lib/cloudflareImages.test.ts new file mode 100644 index 00000000..5bb0ebb5 --- /dev/null +++ b/lib/cloudflareImages.test.ts @@ -0,0 +1,113 @@ +/** + * File Name : lib/cloudflareImages.test.ts + * Description : Cloudflare ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์ธ์ฆ ๊ฒฝ๊ณ„ ํ…Œ์ŠคํŠธ + * Author : ์ž„๋„ํ—Œ + * + * History + * Date Author Status Description + * 2026.06.27 ์ž„๋„ํ—Œ Created ์ด๋ฏธ์ง€ direct upload URL ๋ฐœ๊ธ‰ ์„ธ์…˜/์‚ฌ์šฉ์ž ์ƒํƒœ ๊ฐ€๋“œ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + validateUserStatus: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/session", () => ({ + default: mocks.getSession, +})); + +vi.mock("@/features/user/service/admin", () => ({ + validateUserStatus: mocks.validateUserStatus, +})); + +describe("getUploadUrl", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("๋น„๋กœ๊ทธ์ธ ์š”์ฒญ์€ Cloudflare upload URL ๋ฐœ๊ธ‰์„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => { + const { getUploadUrl } = await import("./cloudflareImages"); + + mocks.getSession.mockResolvedValue(null); + + const result = await getUploadUrl(); + + expect(result).toEqual({ + success: false, + error: "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }); + expect(mocks.validateUserStatus).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("์ •์ง€ ๋“ฑ ์‚ฌ์šฉ์ž ์ƒํƒœ ๊ฐ€๋“œ์— ์‹คํŒจํ•˜๋ฉด Cloudflare ์š”์ฒญ์„ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค", async () => { + const { getUploadUrl } = await import("./cloudflareImages"); + + mocks.getSession.mockResolvedValue({ id: 10 }); + mocks.validateUserStatus.mockResolvedValue({ + success: false, + error: "์šด์˜ ์ •์ฑ…์— ์˜ํ•ด ์ด์šฉ์ด ์ œํ•œ๋œ ๊ณ„์ •์ž…๋‹ˆ๋‹ค.", + }); + + const result = await getUploadUrl(); + + expect(result).toEqual({ + success: false, + error: "์šด์˜ ์ •์ฑ…์— ์˜ํ•ด ์ด์šฉ์ด ์ œํ•œ๋œ ๊ณ„์ •์ž…๋‹ˆ๋‹ค.", + }); + expect(mocks.validateUserStatus).toHaveBeenCalledWith(10); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("์ •์ƒ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋Š” Cloudflare upload URL์„ ๋ฐœ๊ธ‰๋ฐ›์„ ์ˆ˜ ์žˆ๋‹ค", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "account-id"); + vi.stubEnv("CLOUDFLARE_API_TOKEN", "api-token"); + + const { getUploadUrl } = await import("./cloudflareImages"); + + mocks.getSession.mockResolvedValue({ id: 10 }); + mocks.validateUserStatus.mockResolvedValue({ success: true }); + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + result: { + uploadURL: "https://upload.example.test", + id: "image-id", + }, + }), + { status: 200 } + ) + ); + + const result = await getUploadUrl(); + + expect(result).toEqual({ + success: true, + result: { + uploadURL: "https://upload.example.test", + id: "image-id", + }, + }); + expect(fetch).toHaveBeenCalledWith( + "https://api.cloudflare.com/client/v4/accounts/account-id/images/v2/direct_upload", + { + method: "POST", + headers: { + Authorization: "Bearer api-token", + }, + } + ); + }); +}); diff --git a/lib/cloudflareImages.ts b/lib/cloudflareImages.ts index 23cc5153..96402a08 100644 --- a/lib/cloudflareImages.ts +++ b/lib/cloudflareImages.ts @@ -9,15 +9,43 @@ * 2025.06.12 ์ž„๋„ํ—Œ Modified Cloudflare ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์šฉ URL ์š”์ฒญ ํ•จ์ˆ˜๋ฅผ lib๋กœ ์˜ฎ๊น€ * 2025.08.22 ์ž„๋„ํ—Œ Modified DirectUploadURLResult ํƒ€์ž… ๋„์ž… ๋ฐ ์‘๋‹ต ํ‘œ์ค€ํ™”, ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€ * 2026.01.16 ์ž„๋„ํ—Œ Renamed lib/cloudflare/getUploadUrl -> lib/cloudflareImages.ts + * 2026.06.27 ์ž„๋„ํ—Œ Modified ์ด๋ฏธ์ง€ direct upload URL ๋ฐœ๊ธ‰ ์ „ ์„ธ์…˜/์‚ฌ์šฉ์ž ์ƒํƒœ ๊ฐ€๋“œ ์ถ”๊ฐ€ */ "use server"; +import getSession from "@/lib/session"; +import { validateUserStatus } from "@/features/user/service/admin"; + type DirectUploadURLResult = | { success: true; result: { uploadURL: string; id: string } } | { success: false; error: string }; +/** + * Cloudflare Images direct upload URL์„ ๋ฐœ๊ธ‰ + * + * ๋กœ๊ทธ์ธ ์„ธ์…˜๊ณผ ์‚ฌ์šฉ์ž ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ ๋’ค Cloudflare API๋ฅผ ํ˜ธ์ถœํ•˜๊ณ , + * ํด๋ผ์ด์–ธํŠธ์—๋Š” Cloudflare API token ๋Œ€์‹  direct upload URL๋งŒ ๋ฐ˜ํ™˜ + * + * @returns {Promise} ์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ๊ฒฐ๊ณผ + */ export async function getUploadUrl(): Promise { try { + const session = await getSession(); + if (!session?.id) { + return { + success: false, + error: "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }; + } + + const userStatus = await validateUserStatus(session.id); + if (!userStatus.success) { + return { + success: false, + error: userStatus.error, + }; + } + const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!; const API_TOKEN = process.env.CLOUDFLARE_API_TOKEN!; diff --git a/prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql b/prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql new file mode 100644 index 00000000..3f6f25cb --- /dev/null +++ b/prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql @@ -0,0 +1,22 @@ +-- Add SMS token TTL so old verification codes cannot remain valid indefinitely. +ALTER TABLE "SMSToken" ADD COLUMN "expires_at" TIMESTAMP(3); + +UPDATE "SMSToken" +SET "expires_at" = "created_at" + INTERVAL '10 minutes' +WHERE "expires_at" IS NULL; + +ALTER TABLE "SMSToken" ALTER COLUMN "expires_at" SET NOT NULL; + +CREATE INDEX "SMSToken_expires_at_idx" ON "SMSToken"("expires_at"); + +-- Store hashed actor keys for low-volume auth abuse controls. +CREATE TABLE "AuthRateLimitEvent" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuthRateLimitEvent_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "AuthRateLimitEvent_kind_keyHash_created_at_idx" ON "AuthRateLimitEvent"("kind", "keyHash", "created_at"); diff --git a/prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql b/prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql new file mode 100644 index 00000000..82cb07a1 --- /dev/null +++ b/prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql @@ -0,0 +1,2 @@ +-- Optimize stale auth rate limit event cleanup by policy kind. +CREATE INDEX "AuthRateLimitEvent_kind_created_at_idx" ON "AuthRateLimitEvent"("kind", "created_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48b76405..1627f919 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,7 @@ * 2026.04.28 ์ž„๋„ํ—Œ Modified ๋ณด๋“œ๊ฒŒ์ž„ ์ธ๊ธฐ๋„/์ถ”์ฒœ ์ธ์›/์‹œ๋ฆฌ์ฆˆ ์š”์•ฝ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ•„๋“œ ์ถ”๊ฐ€ * 2026.04.29 ์ž„๋„ํ—Œ Modified ๋ณด๋“œ๊ฒŒ์ž„ ํ•œ๊ตญ์–ด ๊ฒ€์ˆ˜์ž ๊ด€๊ณ„์™€ taxonomy slug ๊ณ ์œ  ์ œ์•ฝ ์ถ”๊ฐ€ * 2026.04.29 ์ž„๋„ํ—Œ Modified ๋ณด๋“œ๊ฒŒ์ž„ ์นดํƒˆ๋กœ๊ทธ ๋„๋ฉ”์ธ ๋ชจ๋ธ/ํ•„๋“œ ์ฃผ์„ ๋ณด๊ฐ• + * 2026.06.27 ์ž„๋„ํ—Œ Modified SMS ์ธ์ฆ๋ฒˆํ˜ธ ๋งŒ๋ฃŒ ์‹œ๊ฐ ๋ฐ ์ธ์ฆ rate limit ์ด๋ฒคํŠธ ๋ชจ๋ธ ์ถ”๊ฐ€ */ generator client { provider = "prisma-client" @@ -187,9 +188,12 @@ model SMSToken { phone String? @unique // ๋Œ€์ƒ ์ „ํ™”๋ฒˆํ˜ธ created_at DateTime @default(now()) updated_at DateTime @updatedAt + expires_at DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int + + @@index([expires_at]) } /// ์ด๋ฉ”์ผ ์ธ์ฆ ํ† ํฐ ์ €์žฅ์†Œ @@ -207,6 +211,18 @@ model EmailToken { @@index([userId]) } +/// ์ธ์ฆ ๊ฒฝ๋กœ rate limit ์ด๋ฒคํŠธ +model AuthRateLimitEvent { + id String @id @default(cuid()) + kind String + keyHash String + created_at DateTime @default(now()) + + @@index([kind, keyHash, created_at]) + @@index([kind, created_at]) + @@map("AuthRateLimitEvent") +} + /// ๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ • ํ† ํฐ ์ €์žฅ์†Œ model PasswordResetToken { id String @id @default(cuid()) From 3a6a311a61921fcd416ff4ee06a43cdd4d3ec17d Mon Sep 17 00:00:00 2001 From: DoHeonLim Date: Mon, 29 Jun 2026 18:30:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=94=90=20Security=20:=20SMS=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=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 b68425a1..ca06565b 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 66624b62..d2eeddb3 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 093b13e0..27bc1edf 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 00000000..8f02183e --- /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 1627f919..37fd1d4c 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]) }