마을 복구 보안: 비밀 암호 도입
- 새 마을 생성 시 닉네임과 함께 비밀 암호(2~16자) 필수 지정 - 복구 API: GET → POST 전환, 닉네임+암호 일치 시에만 세이브·토큰 반환 - 서버에는 salt+SHA-256 해시만 저장 (timingSafeEqual 비교) - 암호 도입 전 마을은 최초 복구 시 사용한 암호를 그대로 등록(claim) - 오프라인 시작 시 암호를 로컬 보관 → 자동 재등록에 포함 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<Mode>("new");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | 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 }) {
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[#6f5c39]">
|
||||
{mode === "new"
|
||||
? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다"
|
||||
: "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}
|
||||
? "이름과 함께, 마을을 지킬 비밀 암호도 정해주세요"
|
||||
: "마을 이름과 비밀 암호를 입력하면 이어서 할 수 있어요"}
|
||||
</p>
|
||||
|
||||
<input
|
||||
@@ -140,6 +157,19 @@ export default function NicknameModal({ onDone }: { onDone: () => 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]"
|
||||
/>
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
<p className={`mt-2 h-5 text-xs ${lines[mode][status].cls}`}>
|
||||
{lines[mode][status].text}
|
||||
</p>
|
||||
|
||||
+31
-7
@@ -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<boolean | null> {
|
||||
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<void> {
|
||||
});
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
+63
-5
@@ -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<boolean> {
|
||||
}
|
||||
|
||||
/** 닉네임 선점 등록. 성공 여부 반환 */
|
||||
export async function register(nickname: string, token: string): Promise<boolean> {
|
||||
export async function register(
|
||||
nickname: string,
|
||||
token: string,
|
||||
secret?: string,
|
||||
): Promise<boolean> {
|
||||
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,
|
||||
|
||||
새 이슈에서 참조
사용자 차단