diff --git a/features/auth/service/rateLimit.ts b/features/auth/service/rateLimit.ts index b68425a..ca06565 100644 --- a/features/auth/service/rateLimit.ts +++ b/features/auth/service/rateLimit.ts @@ -8,6 +8,7 @@ * 2026.06.27 임도헌 Created 회원가입 IP hash 기반 단기 제출 제한 추가 * 2026.06.27 임도헌 Modified SMS 발송 IP hash 기반 시간당 제한 추가 * 2026.06.27 임도헌 Modified kind/keyHash 단위 transaction advisory lock 적용 + * 2026.06.29 임도헌 Modified stale event cleanup을 advisory lock transaction 밖으로 분리 */ import "server-only"; @@ -82,25 +83,27 @@ async function checkAndRecordAuthRateLimitEvent( const keyHash = hashRateLimitKey(input.key); if (!keyHash) return { allowed: true }; + const windowStart = new Date(now.getTime() - input.windowMs); + + try { + await db.authRateLimitEvent.deleteMany({ + where: { + kind: input.kind, + created_at: { lt: windowStart }, + }, + }); + } catch (error) { + console.warn("[auth rate limit] stale event cleanup failed:", error); + } + return db.$transaction(async (tx) => { - // 같은 정책/식별자에 대한 check-and-record 경쟁을 DB transaction 단위로 직렬화 + // PostgreSQL advisory lock은 애플리케이션이 정한 숫자 key로 잡는 DB 잠금이다. + // 여기서는 같은 kind/keyHash 요청만 한 줄로 세워, 동시에 limit을 통과하고 + // 각각 기록되는 check-and-record 경쟁을 막는다. await tx.$executeRaw` SELECT pg_advisory_xact_lock(hashtext(${`${input.kind}:${keyHash}`})) `; - const windowStart = new Date(now.getTime() - input.windowMs); - - try { - await tx.authRateLimitEvent.deleteMany({ - where: { - kind: input.kind, - created_at: { lt: windowStart }, - }, - }); - } catch (error) { - console.warn("[auth rate limit] stale event cleanup failed:", error); - } - const recentAttempts = await tx.authRateLimitEvent.findMany({ where: { kind: input.kind, diff --git a/features/auth/service/sms.test.ts b/features/auth/service/sms.test.ts index 66624b6..d2eeddb 100644 --- a/features/auth/service/sms.test.ts +++ b/features/auth/service/sms.test.ts @@ -6,6 +6,7 @@ * History * Date Author Status Description * 2026.06.27 임도헌 Created SMS 만료/쿨다운/발송 실패 롤백 테스트 추가 + * 2026.06.29 임도헌 Modified SMS 인증 전 User.phone 점유, userId 잔존, 목적 혼용 방지 테스트 추가 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({ }, user: { update: vi.fn(), + upsert: vi.fn(), }, }, sendSMS: vi.fn(), @@ -58,6 +60,12 @@ describe("SMS verification service", () => { mocks.db.sMSToken.create.mockResolvedValue({ id: 1 }); mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 1 }); mocks.db.sMSToken.delete.mockResolvedValue({ id: 1 }); + mocks.db.user.upsert.mockResolvedValue({ + id: 10, + phone: "01012345678", + bannedAt: null, + bannedUntil: null, + }); mocks.sendSMS.mockResolvedValue(undefined); mocks.generateUniqueSmsToken.mockResolvedValue("654321"); mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({ @@ -138,6 +146,54 @@ describe("SMS verification service", () => { expect(mocks.sendSMS).not.toHaveBeenCalled(); }); + it("최초 SMS 발송은 인증 전 User를 만들지 않고 토큰만 저장한다", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue(null); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ success: true }); + expect(mocks.db.sMSToken.create).toHaveBeenCalledWith({ + data: { + token: "654321", + phone: "01012345678", + expires_at: new Date("2026-06-27T00:10:00.000Z"), + }, + }); + expect(mocks.db.user.upsert).not.toHaveBeenCalled(); + }); + + it("기존 프로필 인증 토큰을 로그인 SMS로 갱신할 때 userId 연결을 끊는다", async () => { + const { createAndSendSmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + token: "123456", + phone: "01012345678", + userId: 10, + created_at: new Date("2026-06-26T23:58:00.000Z"), + expires_at: new Date("2026-06-27T00:08:00.000Z"), + }); + + const result = await createAndSendSmsToken("01012345678"); + + expect(result).toEqual({ success: true }); + expect(mocks.db.sMSToken.updateMany).toHaveBeenCalledWith({ + where: { + id: 1, + created_at: { lte: new Date("2026-06-26T23:59:00.000Z") }, + }, + data: { + token: "654321", + phone: "01012345678", + userId: null, + created_at: new Date("2026-06-27T00:00:00.000Z"), + expires_at: new Date("2026-06-27T00:10:00.000Z"), + }, + }); + }); + it("SMS 발송 실패 시 이전 유효 토큰을 복구한다", async () => { const { createAndSendSmsToken } = await import("./sms"); @@ -169,6 +225,7 @@ describe("SMS verification service", () => { data: { token: previous.token, phone: previous.phone, + userId: previous.userId, created_at: previous.created_at, expires_at: previous.expires_at, }, @@ -218,4 +275,58 @@ describe("SMS verification service", () => { where: { id: 1 }, }); }); + + it("SMS 인증 성공 시 전화번호 기준 User를 찾거나 생성한 뒤 로그인 ID를 반환한다", async () => { + const { verifySmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + userId: null, + phone: "01012345678", + expires_at: new Date("2026-06-27T00:10:00.000Z"), + user: null, + }); + + const result = await verifySmsToken("01012345678", "123456"); + + expect(mocks.db.user.upsert).toHaveBeenCalledWith({ + where: { phone: "01012345678" }, + update: {}, + create: { + username: expect.stringMatching(/^user_[0-9a-f]{8}$/), + phone: "01012345678", + }, + select: { id: true, phone: true, bannedAt: true, bannedUntil: true }, + }); + expect(mocks.db.sMSToken.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + expect(result).toEqual({ success: true, data: { userId: 10 } }); + }); + + it("프로필 인증 토큰은 SMS 로그인 검증에서 소비하지 않는다", async () => { + const { verifySmsToken } = await import("./sms"); + + mocks.db.sMSToken.findUnique.mockResolvedValue({ + id: 1, + userId: 20, + phone: "01012345678", + expires_at: new Date("2026-06-27T00:10:00.000Z"), + user: { + id: 20, + phone: "01012345678", + bannedAt: null, + bannedUntil: null, + }, + }); + + const result = await verifySmsToken("01012345678", "123456"); + + expect(result).toEqual({ + success: false, + error: AUTH_ERRORS.SMS_VERIFY_FAILED, + }); + expect(mocks.db.user.upsert).not.toHaveBeenCalled(); + expect(mocks.db.sMSToken.delete).not.toHaveBeenCalled(); + }); }); diff --git a/features/auth/service/sms.ts b/features/auth/service/sms.ts index 093b13e..27bc1ed 100644 --- a/features/auth/service/sms.ts +++ b/features/auth/service/sms.ts @@ -11,6 +11,7 @@ * 2026.02.08 임도헌 Modified 로그인 시 정지(Ban) 체크 및 만료 시 자동 해제 로직 추가 * 2026.04.04 임도헌 Modified SMS 토큰 발급/소모 단계의 인라인 주석 보강 * 2026.06.27 임도헌 Modified SMS 토큰 TTL, 재전송/IP 쿨다운, 발송 실패 롤백 처리 추가 + * 2026.06.29 임도헌 Modified SMS 로그인 토큰의 인증 전 User 생성 방지, userId 잔존, 목적 혼용 방지 */ import "server-only"; @@ -96,6 +97,7 @@ export async function createAndSendSmsToken( data: { token, phone, + userId: null, created_at: now, expires_at: expiresAt, }, @@ -109,21 +111,12 @@ export async function createAndSendSmsToken( }; } } else { - // 토큰 저장 및 phone 기준 임시 계정 연결 + // 인증 전에는 User.phone을 점유하지 않고 발송 토큰만 저장 await db.sMSToken.create({ data: { token, phone, expires_at: expiresAt, - user: { - connectOrCreate: { - where: { phone }, - create: { - username: `user_${crypto.randomBytes(4).toString("hex")}`, - phone, - }, - }, - }, }, }); createdNewToken = true; @@ -143,6 +136,7 @@ export async function createAndSendSmsToken( data: { token: previousToken.token, phone: previousToken.phone, + userId: previousToken.userId, created_at: previousToken.created_at, expires_at: previousToken.expires_at, }, @@ -195,7 +189,7 @@ export async function verifySmsToken( phone: true, expires_at: true, user: { - select: { id: true, bannedAt: true, bannedUntil: true }, + select: { id: true, phone: true, bannedAt: true, bannedUntil: true }, }, }, }); @@ -210,7 +204,22 @@ export async function verifySmsToken( return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED }; } - const user = verifiedToken.user; + // 프로필 전화번호 변경용 토큰은 로그인 검증에서 소비하지 않음 + if (verifiedToken.userId !== null) { + return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED }; + } + + const user = + verifiedToken.user ?? + (await db.user.upsert({ + where: { phone }, + update: {}, + create: { + username: `user_${crypto.randomBytes(4).toString("hex")}`, + phone, + }, + select: { id: true, phone: true, bannedAt: true, bannedUntil: true }, + })); // 정지 상태 확인 및 만료 시 지연 해제 if (user.bannedAt) { @@ -233,5 +242,5 @@ export async function verifySmsToken( // 검증 성공 후 토큰 1회 소모 await db.sMSToken.delete({ where: { id: verifiedToken.id } }); - return { success: true, data: { userId: verifiedToken.userId } }; + return { success: true, data: { userId: user.id } }; } diff --git a/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql b/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql new file mode 100644 index 0000000..8f02183 --- /dev/null +++ b/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql @@ -0,0 +1,2 @@ +-- SMS login tokens should not reserve User.phone before verification succeeds. +ALTER TABLE "SMSToken" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1627f91..37fd1d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -75,6 +75,7 @@ * 2026.04.29 임도헌 Modified 보드게임 한국어 검수자 관계와 taxonomy slug 고유 제약 추가 * 2026.04.29 임도헌 Modified 보드게임 카탈로그 도메인 모델/필드 주석 보강 * 2026.06.27 임도헌 Modified SMS 인증번호 만료 시각 및 인증 rate limit 이벤트 모델 추가 + * 2026.06.29 임도헌 Modified SMS 로그인 토큰의 인증 전 User 점유를 막기 위해 userId optional 처리 */ generator client { provider = "prisma-client" @@ -190,8 +191,8 @@ model SMSToken { updated_at DateTime @updatedAt expires_at DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @@index([expires_at]) }