From 85e06567f3054b6fc7349aa1f65809db47c9034a Mon Sep 17 00:00:00 2001 From: yyamashita Date: Fri, 19 Jun 2026 23:26:55 +0900 Subject: Require REGISTER_TOKEN to create a passkey Prevents anyone who finds /auth/register from registering. Token is set via REGISTER_TOKEN env var on the server. Co-Authored-By: Claude Sonnet 4.6 --- app/routes/auth.register.tsx | 68 +++++++++++++++++++++++++++----------------- docker-compose.yml | 1 + 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx index cc4e6a9..c064eff 100644 --- a/app/routes/auth.register.tsx +++ b/app/routes/auth.register.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link, redirect } from "react-router"; +import { redirect } from "react-router"; import { generateRegistrationOptions, verifyRegistrationResponse, @@ -8,14 +8,13 @@ import { } from "@simplewebauthn/server"; import type { Route } from "./+types/auth.register"; import { getSession, commitSession } from "~/lib/session.server"; -import { listCredentials, saveCredential } from "~/lib/db.server"; +import { saveCredential } from "~/lib/db.server"; import { rpID, rpName, origin } from "~/lib/passkey.server"; export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); if (session.get("authenticated")) return redirect("/"); - const hasCredentials = listCredentials().length > 0; - return { hasCredentials }; + return {}; } export async function action({ request }: Route.ActionArgs) { @@ -24,6 +23,12 @@ export async function action({ request }: Route.ActionArgs) { const session = await getSession(request.headers.get("Cookie")); if (intent === "options") { + const body = await request.json() as { token?: string }; + const registerToken = process.env.REGISTER_TOKEN; + if (!registerToken || body.token !== registerToken) { + return Response.json({ error: "トークンが違います" }, { status: 403 }); + } + const options = await generateRegistrationOptions({ rpName, rpID, @@ -74,8 +79,8 @@ export async function action({ request }: Route.ActionArgs) { return Response.json({ error: "不正なリクエスト" }, { status: 400 }); } -export default function Register({ loaderData }: Route.ComponentProps) { - const { hasCredentials } = loaderData; +export default function Register() { + const [token, setToken] = useState(""); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); const [errorMsg, setErrorMsg] = useState(""); @@ -85,8 +90,15 @@ export default function Register({ loaderData }: Route.ComponentProps) { try { const { startRegistration } = await import("@simplewebauthn/browser"); - const optRes = await fetch("/auth/register?intent=options", { method: "POST" }); - if (!optRes.ok) throw new Error("オプション取得に失敗しました"); + const optRes = await fetch("/auth/register?intent=options", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!optRes.ok) { + const err = await optRes.json(); + throw new Error(err.error ?? "オプション取得に失敗しました"); + } const options = await optRes.json(); const regResponse = await startRegistration({ optionsJSON: options }); @@ -115,24 +127,28 @@ export default function Register({ loaderData }: Route.ComponentProps) {

パスキーを登録

- {hasCredentials ? ( - <> -

すでにパスキーが登録されています。

- - ログインへ - - - ) : ( - <> -

- このデバイスの生体認証(Touch ID / Face ID など)でパスキーを作成します。 - 一度登録すると、次回からはパスキーでログインできます。 -

- - {status === "error" &&

{errorMsg}

} - +

登録トークンを入力してパスキーを作成します。

+ setToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + style={{ + width: "100%", + marginBottom: ".75rem", + padding: ".5rem .75rem", + border: "1px solid #d1d5db", + borderRadius: "4px", + fontSize: ".875rem", + fontFamily: "ui-monospace, monospace", + }} + /> + + {status === "error" && ( +

{errorMsg}

)}
diff --git a/docker-compose.yml b/docker-compose.yml index bd00608..b617cbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - NODE_ENV=production - DB_PATH=/app/data/microblog.db - SESSION_SECRET=${SESSION_SECRET} + - REGISTER_TOKEN=${REGISTER_TOKEN} - WEBAUTHN_RP_ID=blog.yyamashita.com - WEBAUTHN_ORIGIN=https://blog.yyamashita.com restart: unless-stopped -- cgit v1.2.3