전체 코드, 구조 검토 - P2 코드 수정 진행
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+3
-2
@@ -207,7 +207,9 @@ 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())) {
|
||||||
|
throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
|
||||||
|
}
|
||||||
const prevMin = Math.floor(
|
const prevMin = Math.floor(
|
||||||
maestro.AvailableActivateDateTime.getTime() / 60000
|
maestro.AvailableActivateDateTime.getTime() / 60000
|
||||||
);
|
);
|
||||||
@@ -222,7 +224,6 @@ export async function updateMaestro(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
payload.allowEditEnterCode !== undefined &&
|
payload.allowEditEnterCode !== undefined &&
|
||||||
payload.allowEditEnterCode !== maestro.AllowEditEnterCode
|
payload.allowEditEnterCode !== maestro.AllowEditEnterCode
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user