From eb950dd7afea710746200565bfc61818701c7e2a Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 14 Aug 2026 11:44:14 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=88=EC=9D=84=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88:=20=EB=B9=84=EB=B0=80=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 마을 생성 시 닉네임과 함께 비밀 암호(2~16자) 필수 지정 - 복구 API: GET → POST 전환, 닉네임+암호 일치 시에만 세이브·토큰 반환 - 서버에는 salt+SHA-256 해시만 저장 (timingSafeEqual 비교) - 암호 도입 전 마을은 최초 복구 시 사용한 암호를 그대로 등록(claim) - 오프라인 시작 시 암호를 로컬 보관 → 자동 재등록에 포함 Co-Authored-By: Claude Fable 5 --- src/app/api/village/recover/route.ts | 23 ++++----- src/app/api/village/register/route.ts | 8 ++-- src/components/game/NicknameModal.tsx | 44 ++++++++++++++--- src/lib/game/sync.ts | 38 ++++++++++++--- src/lib/villages.ts | 68 +++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 35 deletions(-) diff --git a/src/app/api/village/recover/route.ts b/src/app/api/village/recover/route.ts index 014140e..8ca822a 100644 --- a/src/app/api/village/recover/route.ts +++ b/src/app/api/village/recover/route.ts @@ -1,22 +1,19 @@ import { NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { ensureTable, validNickname } from "@/lib/villages"; +import { recoverWithSecret, validNickname, validSecret } from "@/lib/villages"; export const dynamic = "force-dynamic"; -/** 닉네임으로 마을 복구 (토큰 포함 반환 — 추후 비밀코드 보호 예정) */ -export async function GET(req: Request) { - const nickname = new URL(req.url).searchParams.get("nickname"); - if (!validNickname(nickname)) +/** 닉네임+비밀 암호로 마을 복구 */ +export async function POST(req: Request) { + const { nickname, secret } = await req.json().catch(() => ({})); + if (!validNickname(nickname) || !validSecret(secret)) return NextResponse.json({ data: null }, { status: 400 }); try { - await ensureTable(); - const r = await db.query( - "SELECT data, token FROM villages WHERE nickname = $1 AND data IS NOT NULL", - [nickname], - ); - if (!r.rows[0]) return NextResponse.json({ data: null }); - return NextResponse.json({ data: r.rows[0].data, token: r.rows[0].token }); + const r = await recoverWithSecret(nickname, secret); + if (r === "wrong_secret") + return NextResponse.json({ data: null, reason: "secret" }, { status: 403 }); + if (!r) return NextResponse.json({ data: null }); + return NextResponse.json(r); } catch { return NextResponse.json({ error: "db" }, { status: 503 }); } diff --git a/src/app/api/village/register/route.ts b/src/app/api/village/register/route.ts index 10904c6..d07603c 100644 --- a/src/app/api/village/register/route.ts +++ b/src/app/api/village/register/route.ts @@ -1,14 +1,16 @@ import { NextResponse } from "next/server"; -import { register, validNickname } from "@/lib/villages"; +import { register, validNickname, validSecret } from "@/lib/villages"; export const dynamic = "force-dynamic"; export async function POST(req: Request) { - const { nickname, token } = await req.json().catch(() => ({})); + const { nickname, token, secret } = await req.json().catch(() => ({})); if (!validNickname(nickname) || typeof token !== "string" || token.length < 8) return NextResponse.json({ ok: false }, { status: 400 }); + if (secret !== undefined && !validSecret(secret)) + return NextResponse.json({ ok: false }, { status: 400 }); try { - return NextResponse.json({ ok: await register(nickname, token) }); + return NextResponse.json({ ok: await register(nickname, token, secret) }); } catch { return NextResponse.json({ error: "db" }, { status: 503 }); } diff --git a/src/components/game/NicknameModal.tsx b/src/components/game/NicknameModal.tsx index 1c404e0..5ecd88f 100644 --- a/src/components/game/NicknameModal.tsx +++ b/src/components/game/NicknameModal.tsx @@ -7,6 +7,7 @@ import { newSyncToken, registerNickname, recoverVillage, + rememberSecret, pushSave, } from "@/lib/game/sync"; import { engine } from "@/lib/game/engine"; @@ -18,13 +19,15 @@ type Status = | "available" | "taken" | "invalid" - | "offline"; + | "offline" + | "wrong_secret"; const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/; export default function NicknameModal({ onDone }: { onDone: () => void }) { const [mode, setMode] = useState("new"); const [name, setName] = useState(""); + const [secret, setSecret] = useState(""); const [status, setStatus] = useState("idle"); const [submitting, setSubmitting] = useState(false); const timer = useRef | null>(null); @@ -56,34 +59,46 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) { }; }, [trimmed, mode]); + const secretOk = secret.trim().length >= 2 && secret.trim().length <= 16; + // 모드별 결정 가능 조건: 새 마을=사용 가능(서버 불가 시에도 허용), 불러오기=이미 존재 + // 두 모드 모두 비밀 암호 입력 필수 const canConfirm = !submitting && + secretOk && (mode === "new" ? status === "available" || status === "offline" - : status === "taken"); + : status === "taken" || status === "wrong_secret"); const confirm = async () => { if (!canConfirm) return; setSubmitting(true); + const sec = secret.trim(); if (mode === "new") { const token = newSyncToken(); - const ok = await registerNickname(trimmed, token); + const ok = await registerNickname(trimmed, token, sec); if (ok === false) { setStatus("taken"); // 그 사이 누가 선점 setSubmitting(false); return; } - // 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도 + // 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도 (암호 포함) + rememberSecret(sec); engine.setNickname(trimmed, token); void pushSave(); } else { - const found = await recoverVillage(trimmed); + const found = await recoverVillage(trimmed, sec); + if (found === "wrong_secret") { + setStatus("wrong_secret"); + setSubmitting(false); + return; + } if (!found) { setStatus("available"); // 불러오기 모드에선 "그 이름의 마을은 없는걸?" setSubmitting(false); return; } + rememberSecret(sec); engine.replaceState(found.data); engine.setNickname(trimmed, found.token); } @@ -99,6 +114,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) { available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" }, taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" }, offline: { text: "확인은 못 했지만 일단 시작할 수 있어! (나중에 자동 확인)", cls: "text-[#8a6316]" }, + wrong_secret: { text: "", cls: "" }, }, recover: { idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" }, @@ -107,6 +123,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) { available: { text: "그 이름의 마을은 없는걸? 🙀", cls: "text-[#b0472a]" }, taken: { text: "마을을 찾았어! 데려올게 ✨", cls: "text-[#5c8a2a]" }, offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" }, + wrong_secret: { text: "앗, 비밀 암호가 달라! 다시 떠올려봐 🙀", cls: "text-[#b0472a]" }, }, }; @@ -125,8 +142,8 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {

{mode === "new" - ? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다" - : "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"} + ? "이름과 함께, 마을을 지킬 비밀 암호도 정해주세요" + : "마을 이름과 비밀 암호를 입력하면 이어서 할 수 있어요"}

void }) { placeholder={mode === "new" ? "닉네임 입력" : "기존 마을 이름"} className="font-jua mt-4 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-3 text-center text-lg text-[#3f2c14] outline-none focus:border-[#7fb539]" /> + setSecret(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) void confirm(); + }} + type="password" + maxLength={16} + placeholder={ + mode === "new" ? "마을을 지킬 비밀 암호 (2~16자)" : "비밀 암호" + } + className="font-jua mt-2 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-2.5 text-center text-base text-[#3f2c14] outline-none focus:border-[#7fb539]" + />

{lines[mode][status].text}

diff --git a/src/lib/game/sync.ts b/src/lib/game/sync.ts index 4f380c8..aec28ea 100644 --- a/src/lib/game/sync.ts +++ b/src/lib/game/sync.ts @@ -16,16 +16,36 @@ export async function checkNickname( } } +const SECRET_KEY = "cat-village-secret"; + +/** 비밀 암호 로컬 보관 (오프라인 시작 후 자동 재등록용) */ +export function rememberSecret(secret: string) { + try { + localStorage.setItem(SECRET_KEY, secret); + } catch { + /* ignore */ + } +} + +function storedSecret(): string | undefined { + try { + return localStorage.getItem(SECRET_KEY) ?? undefined; + } catch { + return undefined; + } +} + /** 닉네임 선점 등록 */ export async function registerNickname( nickname: string, token: string, + secret?: string, ): Promise { try { const res = await fetch("/api/village/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nickname, token }), + body: JSON.stringify({ nickname, token, secret }), }); if (!res.ok) return null; return ((await res.json()) as { ok: boolean }).ok; @@ -51,7 +71,7 @@ export async function pushSave(): Promise { }); if (res.status === 403) { // 오프라인 상태에서 시작해 서버에 등록되지 않은 닉네임 — 재등록 후 재시도 - const ok = await registerNickname(s.nickname, s.syncToken); + const ok = await registerNickname(s.nickname, s.syncToken, storedSecret()); if (ok) await fetch("/api/village/save", { method: "POST", @@ -86,14 +106,18 @@ export async function pullSave(): Promise { return false; } -/** 닉네임으로 기존 마을 복구 */ +/** 닉네임+비밀 암호로 기존 마을 복구. "wrong_secret" = 암호 불일치 */ export async function recoverVillage( nickname: string, -): Promise<{ data: GameState; token: string } | null> { + secret: string, +): Promise<{ data: GameState; token: string } | "wrong_secret" | null> { try { - const res = await fetch( - `/api/village/recover?nickname=${encodeURIComponent(nickname)}`, - ); + const res = await fetch("/api/village/recover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname, secret }), + }); + if (res.status === 403) return "wrong_secret"; if (!res.ok) return null; const body = (await res.json()) as { data: GameState | null; diff --git a/src/lib/villages.ts b/src/lib/villages.ts index aae00d5..93993ce 100644 --- a/src/lib/villages.ts +++ b/src/lib/villages.ts @@ -1,7 +1,32 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { db } from "@/lib/db"; let tableReady = false; +/** 비밀 암호 해시 (salt$hash) */ +export function hashSecret(secret: string): string { + const salt = randomBytes(12).toString("hex"); + const h = createHash("sha256").update(salt + secret).digest("hex"); + return `${salt}$${h}`; +} + +export function verifySecret(secret: string, stored: string): boolean { + const [salt, h] = stored.split("$"); + if (!salt || !h) return false; + const calc = createHash("sha256").update(salt + secret).digest("hex"); + try { + return timingSafeEqual(Buffer.from(calc), Buffer.from(h)); + } catch { + return false; + } +} + +const SECRET_RE = /^.{2,16}$/; + +export function validSecret(secret: unknown): secret is string { + return typeof secret === "string" && SECRET_RE.test(secret.trim()); +} + /** villages 테이블 보장 (최초 1회) */ export async function ensureTable() { if (tableReady) return; @@ -13,6 +38,7 @@ export async function ensureTable() { updated_at TIMESTAMPTZ DEFAULT now() ) `); + await db.query("ALTER TABLE villages ADD COLUMN IF NOT EXISTS secret TEXT"); tableReady = true; } @@ -33,18 +59,50 @@ export async function isAvailable(nickname: string): Promise { } /** 닉네임 선점 등록. 성공 여부 반환 */ -export async function register(nickname: string, token: string): Promise { +export async function register( + nickname: string, + token: string, + secret?: string, +): Promise { await ensureTable(); + const secretHash = secret ? hashSecret(secret.trim()) : null; const r = await db.query( - `INSERT INTO villages (nickname, token, data) - VALUES ($1, $2, NULL) - ON CONFLICT (nickname) DO UPDATE SET token = EXCLUDED.token, updated_at = now() + `INSERT INTO villages (nickname, token, data, secret) + VALUES ($1, $2, NULL, $3) + ON CONFLICT (nickname) DO UPDATE + SET token = EXCLUDED.token, + secret = COALESCE(EXCLUDED.secret, villages.secret), + updated_at = now() WHERE villages.data IS NULL`, - [nickname, token], + [nickname, token, secretHash], ); return (r.rowCount ?? 0) > 0; } +/** 닉네임+암호로 마을 복구. 암호 없는 옛 마을은 최초 복구 시 암호 등록(claim) */ +export async function recoverWithSecret( + nickname: string, + secret: string, +): Promise<{ data: unknown; token: string } | "wrong_secret" | null> { + await ensureTable(); + const r = await db.query( + "SELECT data, token, secret FROM villages WHERE nickname = $1 AND data IS NOT NULL", + [nickname], + ); + const row = r.rows[0]; + if (!row) return null; + if (row.secret == null) { + // 암호 도입 전 마을 — 이번 복구에 쓴 암호를 그대로 등록 + await db.query("UPDATE villages SET secret = $2 WHERE nickname = $1", [ + nickname, + hashSecret(secret.trim()), + ]); + return { data: row.data, token: row.token }; + } + if (!verifySecret(secret.trim(), row.secret)) return "wrong_secret"; + return { data: row.data, token: row.token }; +} + /** 세이브 저장 (토큰 일치 시에만) */ export async function saveVillage( nickname: string,