diff options
Diffstat (limited to 'app/routes/auth.register.tsx')
| -rw-r--r-- | app/routes/auth.register.tsx | 140 |
1 files changed, 140 insertions, 0 deletions
diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx new file mode 100644 index 0000000..cc4e6a9 --- /dev/null +++ b/app/routes/auth.register.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { Link, redirect } from "react-router"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + type RegistrationResponseJSON, + type AuthenticatorTransportFuture, +} 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 { 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 }; +} + +export async function action({ request }: Route.ActionArgs) { + const url = new URL(request.url); + const intent = url.searchParams.get("intent"); + const session = await getSession(request.headers.get("Cookie")); + + if (intent === "options") { + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: "admin", + attestationType: "none", + authenticatorSelection: { + residentKey: "required", + userVerification: "required", + }, + }); + session.set("challenge", options.challenge); + return Response.json(options, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "verify") { + const challenge = session.get("challenge") as string | undefined; + if (!challenge) return Response.json({ error: "セッション切れです" }, { status: 400 }); + + const body = (await request.json()) as RegistrationResponseJSON; + const verification = await verifyRegistrationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + + if (!verification.verified || !verification.registrationInfo) { + return Response.json({ error: "登録に失敗しました" }, { status: 400 }); + } + + const { credential } = verification.registrationInfo; + saveCredential({ + id: credential.id, + publicKey: credential.publicKey, + counter: credential.counter, + transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, + }); + + session.unset("challenge"); + session.set("authenticated", true); + return redirect("/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + return Response.json({ error: "不正なリクエスト" }, { status: 400 }); +} + +export default function Register({ loaderData }: Route.ComponentProps) { + const { hasCredentials } = loaderData; + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const [errorMsg, setErrorMsg] = useState(""); + + async function handleRegister() { + setStatus("loading"); + setErrorMsg(""); + try { + const { startRegistration } = await import("@simplewebauthn/browser"); + + const optRes = await fetch("/auth/register?intent=options", { method: "POST" }); + if (!optRes.ok) throw new Error("オプション取得に失敗しました"); + const options = await optRes.json(); + + const regResponse = await startRegistration({ optionsJSON: options }); + + const verRes = await fetch("/auth/register?intent=verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(regResponse), + }); + if (!verRes.ok) { + const err = await verRes.json(); + throw new Error(err.error ?? "登録に失敗しました"); + } + + window.location.href = "/"; + } catch (e) { + setStatus("error"); + setErrorMsg(e instanceof Error ? e.message : "エラーが発生しました"); + } + } + + return ( + <div className="wrap"> + <header className="site-header"> + <h1>log</h1> + </header> + <div className="auth-box"> + <h2>パスキーを登録</h2> + {hasCredentials ? ( + <> + <p>すでにパスキーが登録されています。</p> + <Link to="/auth/login" className="btn" style={{ textDecoration: "none" }}> + ログインへ + </Link> + </> + ) : ( + <> + <p> + このデバイスの生体認証(Touch ID / Face ID など)でパスキーを作成します。 + 一度登録すると、次回からはパスキーでログインできます。 + </p> + <button className="btn" onClick={handleRegister} disabled={status === "loading"}> + {status === "loading" ? "登録中…" : "パスキーを登録"} + </button> + {status === "error" && <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>} + </> + )} + </div> + </div> + ); +} |
