diff --git a/AGENTS.md b/AGENTS.md index cae1705..fbedfe0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone - Config lives in `auth.ts` (project root), not inside `app/`. - Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`. - API routes must call `auth()` and return 401 if no session. +- Session `maxAge` is 8 hours — do not lengthen without review. +- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success. ## Key files @@ -85,6 +87,15 @@ Always record a log entry after a successful DB change. Use these `Type` values: - P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs. - P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing. - P0-3: `docker-compose.yml` and `docker-compose.stage.yml` now set `TZ: Asia/Seoul`; `lib/maestros.ts` date-change log remark uses `formatDate()` instead of `toISOString().slice(0,10)` for KST consistency. + - P1-1/P1-2: `createExtensionRequest` and `approveExtensionRequest` require `ActivateStatus === ACTIVE`; `UpgradeRequestsSection` and `createUpgradeRequest` allow TRIAL same-tier upgrade. + - P1-3: `approveUpgradeRequest` re-verifies upgrade direction against maestro's current `AccountType` at approval time. + - P1-4/P1-5: `createExtensionRequest` wrapped in `db.$transaction`; both create functions write `request_extension_maestro` / `request_upgrade_maestro` log entries. + - P1-6: Extension/upgrade approval routes now `await` email send and include `emailSent: boolean` in response; table UI shows inline amber warning when `emailSent === false`. + - P2-2: `updateMaestro` now returns 400 instead of silently skipping when `availableActivateDateTime` cannot be parsed. + - P2-5: `withApiHandler` catch block returns `{ message: "Internal server error" }` JSON 500 instead of re-throwing (which produced HTML responses). + - P2-6: NextAuth session `maxAge` set to 8 hours (`auth.ts`). + - P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name. + - P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`. ## Planned phases (not yet implemented) diff --git a/app/(admin)/extension-requests/page.tsx b/app/(admin)/extension-requests/page.tsx index 1265d83..484ebe0 100644 --- a/app/(admin)/extension-requests/page.tsx +++ b/app/(admin)/extension-requests/page.tsx @@ -118,7 +118,7 @@ export default async function ExtensionRequestsPage({ size: "default", className: "h-9", })} - href="/extension-requests" + href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`} > 초기화 diff --git a/app/(admin)/upgrade-requests/page.tsx b/app/(admin)/upgrade-requests/page.tsx index 4cc85dc..b3487ae 100644 --- a/app/(admin)/upgrade-requests/page.tsx +++ b/app/(admin)/upgrade-requests/page.tsx @@ -120,7 +120,7 @@ export default async function UpgradeRequestsPage({ size: "default", className: "h-9", })} - href="/upgrade-requests" + href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`} > 초기화 diff --git a/auth.ts b/auth.ts index 66a6cb6..4287d67 100644 --- a/auth.ts +++ b/auth.ts @@ -7,6 +7,32 @@ import { logger } from "@/lib/logger"; const AUTH_CTX = "auth"; +type AttemptRecord = { count: number; until: number }; +const loginAttempts = new Map(); +const MAX_ATTEMPTS = 5; +const LOCKOUT_MS = 15 * 60 * 1000; + +function isLockedOut(adminName: string): boolean { + const record = loginAttempts.get(adminName); + if (!record || record.count < MAX_ATTEMPTS) return false; + if (record.until > Date.now()) return true; + loginAttempts.delete(adminName); + return false; +} + +function recordFailure(adminName: string): void { + const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 }; + record.count += 1; + if (record.count >= MAX_ATTEMPTS) { + record.until = Date.now() + LOCKOUT_MS; + } + loginAttempts.set(adminName, record); +} + +function clearAttempts(adminName: string): void { + loginAttempts.delete(adminName); +} + const credentialsSchema = z.object({ adminName: z.string().trim().min(1), password: z.string().min(1), @@ -53,6 +79,7 @@ export const { }, session: { strategy: "jwt", + maxAge: 8 * 60 * 60, }, providers: [ Credentials({ @@ -67,8 +94,14 @@ export const { return null; } - const { db } = await import("@/lib/db"); const { adminName, password } = parsedCredentials.data; + + if (isLockedOut(adminName)) { + logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName }); + return null; + } + + const { db } = await import("@/lib/db"); const admins = await db.$queryRaw` SELECT AdminID, Name, Email, AccountType, ActivateStatus FROM admin @@ -81,10 +114,12 @@ export const { const activateStatus = Number(admin?.ActivateStatus); if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) { + recordFailure(adminName); logger.warn(AUTH_CTX, "login failed", { adminName }); return null; } + clearAttempts(adminName); logger.info(AUTH_CTX, "login success", { adminName, adminID }); return { id: String(adminID), diff --git a/docs/plan/improvement-plan.md b/docs/plan/improvement-plan.md index 8b65aae..4852418 100644 --- a/docs/plan/improvement-plan.md +++ b/docs/plan/improvement-plan.md @@ -156,7 +156,7 @@ ## 5. 이행 순서 제안 -1. **Step 1 (P0, 코드 수정 소규모)** +1. **Step 1 (P0, 코드 수정 소규모) ✅ 완료 (2026-07-04)** - P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹 - P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드 - P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일 @@ -172,8 +172,16 @@ - API route 2곳: `void sendXxxDoneEmail` → `await emailSent`, 응답에 `emailSent: boolean` 포함 - 테이블 UI 2곳: 응답 body를 단일 파싱으로 통일, 승인 성공 후 `emailSent === false`이면 인라인 경고 표시 (`router.refresh()` 후에도 client state 유지됨) - stage 실측(체험→유료, 유료→유료 메일 검증)은 별도 수동 테스트 항목으로 남김 -4. **Step 4 (P2 일괄)** - - 소규모 항목 묶음 처리 후 AGENTS.md / phase 문서 갱신 +4. **Step 4 (P2 일괄) ✅ 완료 (2026-07-04)** + - P2-2: `updateMaestro`에서 파싱 불가 날짜 silent skip → 400 반환으로 교체 (`lib/maestros.ts`) + - P2-5: `withApiHandler` catch 블록 `throw error` → JSON 500 응답으로 교체 (`lib/api-handler.ts`) + - P2-6: NextAuth `session.maxAge: 8 * 60 * 60` 추가 (`auth.ts`) + - P2-7: 인메모리 브루트포스 방어 추가 (`auth.ts`) — 5회 실패 시 15분 잠금, 성공 시 카운터 초기화 + - P2-8: 연장·업그레이드 신청 목록 `status` 기본값 `undefined`(전체) → `REQUEST_STATUSES.REQUESTED` 변경; 결과 타입 `status?: number | "all"` → `status: number | "all"` 업데이트; 초기화 링크 `?status=1` 포함으로 변경 + - P2-4: 이메일 중복 검사 추가 안 함 (기존 서비스 정책 유지) + - P2-9: SQL 파라미터 PII 로깅 정책 — 이름·이메일 등이 debug 레벨에서 노출됨. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 (별도 작업) + - P2-10: 변경 없음 (신청 시점 스냅샷 유지) + - AGENTS.md 인증 섹션 및 구현 phase 목록 갱신 각 Step 완료 시 AGENTS.md의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다. diff --git a/lib/api-handler.ts b/lib/api-handler.ts index edc0a42..ac6b162 100644 --- a/lib/api-handler.ts +++ b/lib/api-handler.ts @@ -48,7 +48,10 @@ export function withApiHandler

>( error: String(error), duration: Date.now() - t0, }); - throw error; + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ); } }; } diff --git a/lib/extension-requests.ts b/lib/extension-requests.ts index f834462..c62a9a4 100644 --- a/lib/extension-requests.ts +++ b/lib/extension-requests.ts @@ -22,7 +22,7 @@ const extensionRequestSearchSchema = z.object({ q: z.string().trim().catch(""), status: z .union([z.literal("all"), z.coerce.number().int().min(0)]) - .optional() + .default(REQUEST_STATUSES.REQUESTED) .catch(REQUEST_STATUSES.REQUESTED), }); @@ -53,7 +53,7 @@ export type ExtensionRequestListResult = { totalCount: number; totalPages: number; q: string; - status?: number | "all"; + status: number | "all"; }; export async function getExtensionRequests( diff --git a/lib/maestros.ts b/lib/maestros.ts index 2c81951..6b7a251 100644 --- a/lib/maestros.ts +++ b/lib/maestros.ts @@ -207,20 +207,21 @@ export async function updateMaestro( } if (payload.availableActivateDateTime !== undefined) { const newDate = new Date(payload.availableActivateDateTime); - if (!isNaN(newDate.getTime())) { - const prevMin = Math.floor( - maestro.AvailableActivateDateTime.getTime() / 60000 - ); - const newMin = Math.floor(newDate.getTime() / 60000); - if (prevMin !== newMin) { - updates.AvailableActivateDateTime = newDate; - const prevStr = formatDate(maestro.AvailableActivateDateTime); - const newStr = formatDate(newDate); - logs.push({ - type: "update_maestro_available_date", - remark: `${prevStr} -> ${newStr}`, - }); - } + if (isNaN(newDate.getTime())) { + throw new ApiError("유효하지 않은 날짜 형식입니다.", 400); + } + const prevMin = Math.floor( + maestro.AvailableActivateDateTime.getTime() / 60000 + ); + const newMin = Math.floor(newDate.getTime() / 60000); + if (prevMin !== newMin) { + updates.AvailableActivateDateTime = newDate; + const prevStr = formatDate(maestro.AvailableActivateDateTime); + const newStr = formatDate(newDate); + logs.push({ + type: "update_maestro_available_date", + remark: `${prevStr} -> ${newStr}`, + }); } } if ( diff --git a/lib/upgrade-requests.ts b/lib/upgrade-requests.ts index 70cee9a..805cc77 100644 --- a/lib/upgrade-requests.ts +++ b/lib/upgrade-requests.ts @@ -25,7 +25,7 @@ const upgradeRequestSearchSchema = z.object({ q: z.string().trim().catch(""), status: z .union([z.literal("all"), z.coerce.number().int().min(0)]) - .optional() + .default(REQUEST_STATUSES.REQUESTED) .catch(REQUEST_STATUSES.REQUESTED), }); @@ -57,7 +57,7 @@ export type UpgradeRequestListResult = { totalCount: number; totalPages: number; q: string; - status?: number | "all"; + status: number | "all"; }; export async function getUpgradeRequests(