전체 코드, 구조 검토 - P2 코드 수정 진행

This commit is contained in:
2026-07-04 15:59:50 +09:00
parent 2231ac4bfe
commit 0a9aee58e0
9 changed files with 83 additions and 25 deletions
+11
View File
@@ -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/`. - Config lives in `auth.ts` (project root), not inside `app/`.
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`. - Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
- API routes must call `auth()` and return 401 if no session. - 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 ## 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-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-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. - 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) ## Planned phases (not yet implemented)
+1 -1
View File
@@ -118,7 +118,7 @@ export default async function ExtensionRequestsPage({
size: "default", size: "default",
className: "h-9", className: "h-9",
})} })}
href="/extension-requests" href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`}
> >
</Link> </Link>
+1 -1
View File
@@ -120,7 +120,7 @@ export default async function UpgradeRequestsPage({
size: "default", size: "default",
className: "h-9", className: "h-9",
})} })}
href="/upgrade-requests" href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`}
> >
</Link> </Link>
+36 -1
View File
@@ -7,6 +7,32 @@ import { logger } from "@/lib/logger";
const AUTH_CTX = "auth"; const AUTH_CTX = "auth";
type AttemptRecord = { count: number; until: number };
const loginAttempts = new Map<string, AttemptRecord>();
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({ const credentialsSchema = z.object({
adminName: z.string().trim().min(1), adminName: z.string().trim().min(1),
password: z.string().min(1), password: z.string().min(1),
@@ -53,6 +79,7 @@ export const {
}, },
session: { session: {
strategy: "jwt", strategy: "jwt",
maxAge: 8 * 60 * 60,
}, },
providers: [ providers: [
Credentials({ Credentials({
@@ -67,8 +94,14 @@ export const {
return null; return null;
} }
const { db } = await import("@/lib/db");
const { adminName, password } = parsedCredentials.data; 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<AdminAuthRow[]>` const admins = await db.$queryRaw<AdminAuthRow[]>`
SELECT AdminID, Name, Email, AccountType, ActivateStatus SELECT AdminID, Name, Email, AccountType, ActivateStatus
FROM admin FROM admin
@@ -81,10 +114,12 @@ export const {
const activateStatus = Number(admin?.ActivateStatus); const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) { if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
recordFailure(adminName);
logger.warn(AUTH_CTX, "login failed", { adminName }); logger.warn(AUTH_CTX, "login failed", { adminName });
return null; return null;
} }
clearAttempts(adminName);
logger.info(AUTH_CTX, "login success", { adminName, adminID }); logger.info(AUTH_CTX, "login success", { adminName, adminID });
return { return {
id: String(adminID), id: String(adminID),
+11 -3
View File
@@ -156,7 +156,7 @@
## 5. 이행 순서 제안 ## 5. 이행 순서 제안
1. **Step 1 (P0, 코드 수정 소규모)** 1. **Step 1 (P0, 코드 수정 소규모) ✅ 완료 (2026-07-04)**
- P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹 - P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹
- P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드 - P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드
- P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일 - P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일
@@ -172,8 +172,16 @@
- API route 2곳: `void sendXxxDoneEmail``await emailSent`, 응답에 `emailSent: boolean` 포함 - API route 2곳: `void sendXxxDoneEmail``await emailSent`, 응답에 `emailSent: boolean` 포함
- 테이블 UI 2곳: 응답 body를 단일 파싱으로 통일, 승인 성공 후 `emailSent === false`이면 인라인 경고 표시 (`router.refresh()` 후에도 client state 유지됨) - 테이블 UI 2곳: 응답 body를 단일 파싱으로 통일, 승인 성공 후 `emailSent === false`이면 인라인 경고 표시 (`router.refresh()` 후에도 client state 유지됨)
- stage 실측(체험→유료, 유료→유료 메일 검증)은 별도 수동 테스트 항목으로 남김 - stage 실측(체험→유료, 유료→유료 메일 검증)은 별도 수동 테스트 항목으로 남김
4. **Step 4 (P2 일괄)** 4. **Step 4 (P2 일괄) ✅ 완료 (2026-07-04)**
- 소규모 항목 묶음 처리 후 AGENTS.md / phase 문서 갱신 - 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의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다. 각 Step 완료 시 AGENTS.md의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다.
+4 -1
View File
@@ -48,7 +48,10 @@ export function withApiHandler<P = Record<string, never>>(
error: String(error), error: String(error),
duration: Date.now() - t0, duration: Date.now() - t0,
}); });
throw error; return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 }
);
} }
}; };
} }
+2 -2
View File
@@ -22,7 +22,7 @@ const extensionRequestSearchSchema = z.object({
q: z.string().trim().catch(""), q: z.string().trim().catch(""),
status: z status: z
.union([z.literal("all"), z.coerce.number().int().min(0)]) .union([z.literal("all"), z.coerce.number().int().min(0)])
.optional() .default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED), .catch(REQUEST_STATUSES.REQUESTED),
}); });
@@ -53,7 +53,7 @@ export type ExtensionRequestListResult = {
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;
q: string; q: string;
status?: number | "all"; status: number | "all";
}; };
export async function getExtensionRequests( export async function getExtensionRequests(
+15 -14
View File
@@ -207,20 +207,21 @@ export async function updateMaestro(
} }
if (payload.availableActivateDateTime !== undefined) { if (payload.availableActivateDateTime !== undefined) {
const newDate = new Date(payload.availableActivateDateTime); const newDate = new Date(payload.availableActivateDateTime);
if (!isNaN(newDate.getTime())) { if (isNaN(newDate.getTime())) {
const prevMin = Math.floor( throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
maestro.AvailableActivateDateTime.getTime() / 60000 }
); const prevMin = Math.floor(
const newMin = Math.floor(newDate.getTime() / 60000); maestro.AvailableActivateDateTime.getTime() / 60000
if (prevMin !== newMin) { );
updates.AvailableActivateDateTime = newDate; const newMin = Math.floor(newDate.getTime() / 60000);
const prevStr = formatDate(maestro.AvailableActivateDateTime); if (prevMin !== newMin) {
const newStr = formatDate(newDate); updates.AvailableActivateDateTime = newDate;
logs.push({ const prevStr = formatDate(maestro.AvailableActivateDateTime);
type: "update_maestro_available_date", const newStr = formatDate(newDate);
remark: `${prevStr} -> ${newStr}`, logs.push({
}); type: "update_maestro_available_date",
} remark: `${prevStr} -> ${newStr}`,
});
} }
} }
if ( if (
+2 -2
View File
@@ -25,7 +25,7 @@ const upgradeRequestSearchSchema = z.object({
q: z.string().trim().catch(""), q: z.string().trim().catch(""),
status: z status: z
.union([z.literal("all"), z.coerce.number().int().min(0)]) .union([z.literal("all"), z.coerce.number().int().min(0)])
.optional() .default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED), .catch(REQUEST_STATUSES.REQUESTED),
}); });
@@ -57,7 +57,7 @@ export type UpgradeRequestListResult = {
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;
q: string; q: string;
status?: number | "all"; status: number | "all";
}; };
export async function getUpgradeRequests( export async function getUpgradeRequests(