마을 복구 보안: 비밀 암호 도입
- 새 마을 생성 시 닉네임과 함께 비밀 암호(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 { NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { recoverWithSecret, validNickname, validSecret } from "@/lib/villages";
|
||||||
import { ensureTable, validNickname } from "@/lib/villages";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/** 닉네임으로 마을 복구 (토큰 포함 반환 — 추후 비밀코드 보호 예정) */
|
/** 닉네임+비밀 암호로 마을 복구 */
|
||||||
export async function GET(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const nickname = new URL(req.url).searchParams.get("nickname");
|
const { nickname, secret } = await req.json().catch(() => ({}));
|
||||||
if (!validNickname(nickname))
|
if (!validNickname(nickname) || !validSecret(secret))
|
||||||
return NextResponse.json({ data: null }, { status: 400 });
|
return NextResponse.json({ data: null }, { status: 400 });
|
||||||
try {
|
try {
|
||||||
await ensureTable();
|
const r = await recoverWithSecret(nickname, secret);
|
||||||
const r = await db.query(
|
if (r === "wrong_secret")
|
||||||
"SELECT data, token FROM villages WHERE nickname = $1 AND data IS NOT NULL",
|
return NextResponse.json({ data: null, reason: "secret" }, { status: 403 });
|
||||||
[nickname],
|
if (!r) return NextResponse.json({ data: null });
|
||||||
);
|
return NextResponse.json(r);
|
||||||
if (!r.rows[0]) return NextResponse.json({ data: null });
|
|
||||||
return NextResponse.json({ data: r.rows[0].data, token: r.rows[0].token });
|
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "db" }, { status: 503 });
|
return NextResponse.json({ error: "db" }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { register, validNickname } from "@/lib/villages";
|
import { register, validNickname, validSecret } from "@/lib/villages";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
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)
|
if (!validNickname(nickname) || typeof token !== "string" || token.length < 8)
|
||||||
return NextResponse.json({ ok: false }, { status: 400 });
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
if (secret !== undefined && !validSecret(secret))
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
try {
|
try {
|
||||||
return NextResponse.json({ ok: await register(nickname, token) });
|
return NextResponse.json({ ok: await register(nickname, token, secret) });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "db" }, { status: 503 });
|
return NextResponse.json({ error: "db" }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
newSyncToken,
|
newSyncToken,
|
||||||
registerNickname,
|
registerNickname,
|
||||||
recoverVillage,
|
recoverVillage,
|
||||||
|
rememberSecret,
|
||||||
pushSave,
|
pushSave,
|
||||||
} from "@/lib/game/sync";
|
} from "@/lib/game/sync";
|
||||||
import { engine } from "@/lib/game/engine";
|
import { engine } from "@/lib/game/engine";
|
||||||
@@ -18,13 +19,15 @@ type Status =
|
|||||||
| "available"
|
| "available"
|
||||||
| "taken"
|
| "taken"
|
||||||
| "invalid"
|
| "invalid"
|
||||||
| "offline";
|
| "offline"
|
||||||
|
| "wrong_secret";
|
||||||
|
|
||||||
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
||||||
|
|
||||||
export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
||||||
const [mode, setMode] = useState<Mode>("new");
|
const [mode, setMode] = useState<Mode>("new");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [secret, setSecret] = useState("");
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -56,34 +59,46 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
|||||||
};
|
};
|
||||||
}, [trimmed, mode]);
|
}, [trimmed, mode]);
|
||||||
|
|
||||||
|
const secretOk = secret.trim().length >= 2 && secret.trim().length <= 16;
|
||||||
|
|
||||||
// 모드별 결정 가능 조건: 새 마을=사용 가능(서버 불가 시에도 허용), 불러오기=이미 존재
|
// 모드별 결정 가능 조건: 새 마을=사용 가능(서버 불가 시에도 허용), 불러오기=이미 존재
|
||||||
|
// 두 모드 모두 비밀 암호 입력 필수
|
||||||
const canConfirm =
|
const canConfirm =
|
||||||
!submitting &&
|
!submitting &&
|
||||||
|
secretOk &&
|
||||||
(mode === "new"
|
(mode === "new"
|
||||||
? status === "available" || status === "offline"
|
? status === "available" || status === "offline"
|
||||||
: status === "taken");
|
: status === "taken" || status === "wrong_secret");
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
if (!canConfirm) return;
|
if (!canConfirm) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
const sec = secret.trim();
|
||||||
if (mode === "new") {
|
if (mode === "new") {
|
||||||
const token = newSyncToken();
|
const token = newSyncToken();
|
||||||
const ok = await registerNickname(trimmed, token);
|
const ok = await registerNickname(trimmed, token, sec);
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
setStatus("taken"); // 그 사이 누가 선점
|
setStatus("taken"); // 그 사이 누가 선점
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도
|
// 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도 (암호 포함)
|
||||||
|
rememberSecret(sec);
|
||||||
engine.setNickname(trimmed, token);
|
engine.setNickname(trimmed, token);
|
||||||
void pushSave();
|
void pushSave();
|
||||||
} else {
|
} else {
|
||||||
const found = await recoverVillage(trimmed);
|
const found = await recoverVillage(trimmed, sec);
|
||||||
|
if (found === "wrong_secret") {
|
||||||
|
setStatus("wrong_secret");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!found) {
|
if (!found) {
|
||||||
setStatus("available"); // 불러오기 모드에선 "그 이름의 마을은 없는걸?"
|
setStatus("available"); // 불러오기 모드에선 "그 이름의 마을은 없는걸?"
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rememberSecret(sec);
|
||||||
engine.replaceState(found.data);
|
engine.replaceState(found.data);
|
||||||
engine.setNickname(trimmed, found.token);
|
engine.setNickname(trimmed, found.token);
|
||||||
}
|
}
|
||||||
@@ -99,6 +114,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
|||||||
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
|
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
|
||||||
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
|
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
|
||||||
offline: { text: "확인은 못 했지만 일단 시작할 수 있어! (나중에 자동 확인)", cls: "text-[#8a6316]" },
|
offline: { text: "확인은 못 했지만 일단 시작할 수 있어! (나중에 자동 확인)", cls: "text-[#8a6316]" },
|
||||||
|
wrong_secret: { text: "", cls: "" },
|
||||||
},
|
},
|
||||||
recover: {
|
recover: {
|
||||||
idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" },
|
idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" },
|
||||||
@@ -107,6 +123,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
|||||||
available: { text: "그 이름의 마을은 없는걸? 🙀", cls: "text-[#b0472a]" },
|
available: { text: "그 이름의 마을은 없는걸? 🙀", cls: "text-[#b0472a]" },
|
||||||
taken: { text: "마을을 찾았어! 데려올게 ✨", cls: "text-[#5c8a2a]" },
|
taken: { text: "마을을 찾았어! 데려올게 ✨", cls: "text-[#5c8a2a]" },
|
||||||
offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" },
|
offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" },
|
||||||
|
wrong_secret: { text: "앗, 비밀 암호가 달라! 다시 떠올려봐 🙀", cls: "text-[#b0472a]" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,8 +142,8 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-[#6f5c39]">
|
<p className="mt-1 text-sm text-[#6f5c39]">
|
||||||
{mode === "new"
|
{mode === "new"
|
||||||
? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다"
|
? "이름과 함께, 마을을 지킬 비밀 암호도 정해주세요"
|
||||||
: "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}
|
: "마을 이름과 비밀 암호를 입력하면 이어서 할 수 있어요"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -140,6 +157,19 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
|||||||
placeholder={mode === "new" ? "닉네임 입력" : "기존 마을 이름"}
|
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]"
|
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}`}>
|
<p className={`mt-2 h-5 text-xs ${lines[mode][status].cls}`}>
|
||||||
{lines[mode][status].text}
|
{lines[mode][status].text}
|
||||||
</p>
|
</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(
|
export async function registerNickname(
|
||||||
nickname: string,
|
nickname: string,
|
||||||
token: string,
|
token: string,
|
||||||
|
secret?: string,
|
||||||
): Promise<boolean | null> {
|
): Promise<boolean | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/village/register", {
|
const res = await fetch("/api/village/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ nickname, token }),
|
body: JSON.stringify({ nickname, token, secret }),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return ((await res.json()) as { ok: boolean }).ok;
|
return ((await res.json()) as { ok: boolean }).ok;
|
||||||
@@ -51,7 +71,7 @@ export async function pushSave(): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
// 오프라인 상태에서 시작해 서버에 등록되지 않은 닉네임 — 재등록 후 재시도
|
// 오프라인 상태에서 시작해 서버에 등록되지 않은 닉네임 — 재등록 후 재시도
|
||||||
const ok = await registerNickname(s.nickname, s.syncToken);
|
const ok = await registerNickname(s.nickname, s.syncToken, storedSecret());
|
||||||
if (ok)
|
if (ok)
|
||||||
await fetch("/api/village/save", {
|
await fetch("/api/village/save", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -86,14 +106,18 @@ export async function pullSave(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 닉네임으로 기존 마을 복구 */
|
/** 닉네임+비밀 암호로 기존 마을 복구. "wrong_secret" = 암호 불일치 */
|
||||||
export async function recoverVillage(
|
export async function recoverVillage(
|
||||||
nickname: string,
|
nickname: string,
|
||||||
): Promise<{ data: GameState; token: string } | null> {
|
secret: string,
|
||||||
|
): Promise<{ data: GameState; token: string } | "wrong_secret" | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch("/api/village/recover", {
|
||||||
`/api/village/recover?nickname=${encodeURIComponent(nickname)}`,
|
method: "POST",
|
||||||
);
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nickname, secret }),
|
||||||
|
});
|
||||||
|
if (res.status === 403) return "wrong_secret";
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
data: GameState | null;
|
data: GameState | null;
|
||||||
|
|||||||
+63
-5
@@ -1,7 +1,32 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
let tableReady = false;
|
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회) */
|
/** villages 테이블 보장 (최초 1회) */
|
||||||
export async function ensureTable() {
|
export async function ensureTable() {
|
||||||
if (tableReady) return;
|
if (tableReady) return;
|
||||||
@@ -13,6 +38,7 @@ export async function ensureTable() {
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
await db.query("ALTER TABLE villages ADD COLUMN IF NOT EXISTS secret TEXT");
|
||||||
tableReady = true;
|
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();
|
await ensureTable();
|
||||||
|
const secretHash = secret ? hashSecret(secret.trim()) : null;
|
||||||
const r = await db.query(
|
const r = await db.query(
|
||||||
`INSERT INTO villages (nickname, token, data)
|
`INSERT INTO villages (nickname, token, data, secret)
|
||||||
VALUES ($1, $2, NULL)
|
VALUES ($1, $2, NULL, $3)
|
||||||
ON CONFLICT (nickname) DO UPDATE SET token = EXCLUDED.token, updated_at = now()
|
ON CONFLICT (nickname) DO UPDATE
|
||||||
|
SET token = EXCLUDED.token,
|
||||||
|
secret = COALESCE(EXCLUDED.secret, villages.secret),
|
||||||
|
updated_at = now()
|
||||||
WHERE villages.data IS NULL`,
|
WHERE villages.data IS NULL`,
|
||||||
[nickname, token],
|
[nickname, token, secretHash],
|
||||||
);
|
);
|
||||||
return (r.rowCount ?? 0) > 0;
|
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(
|
export async function saveVillage(
|
||||||
nickname: string,
|
nickname: string,
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단