Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions features/auth/service/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions features/auth/service/sms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({
},
user: {
update: vi.fn(),
upsert: vi.fn(),
},
},
sendSMS: vi.fn(),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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();
});
});
35 changes: 22 additions & 13 deletions features/auth/service/sms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -96,6 +97,7 @@ export async function createAndSendSmsToken(
data: {
token,
phone,
userId: null,
created_at: now,
expires_at: expiresAt,
},
Expand All @@ -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;
Expand All @@ -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,
},
Expand Down Expand Up @@ -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 },
},
},
});
Expand All @@ -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) {
Expand All @@ -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 } };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- SMS login tokens should not reserve User.phone before verification succeeds.
ALTER TABLE "SMSToken" ALTER COLUMN "userId" DROP NOT NULL;
5 changes: 3 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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])
}
Expand Down