summaryrefslogtreecommitdiff
path: root/app/routes/auth.login.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/auth.login.tsx')
-rw-r--r--app/routes/auth.login.tsx132
1 files changed, 132 insertions, 0 deletions
diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx
new file mode 100644
index 0000000..f8e9028
--- /dev/null
+++ b/app/routes/auth.login.tsx
@@ -0,0 +1,132 @@
+import { useState } from "react";
+import { redirect } from "react-router";
+import {
+ generateAuthenticationOptions,
+ verifyAuthenticationResponse,
+ type AuthenticationResponseJSON,
+ type AuthenticatorTransportFuture,
+} from "@simplewebauthn/server";
+import type { Route } from "./+types/auth.login";
+import { getSession, commitSession } from "~/lib/session.server";
+import { listCredentials, getCredentialById, updateCredentialCounter } from "~/lib/db.server";
+import { rpID, 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("/");
+ if (listCredentials().length === 0) return redirect("/auth/register");
+ return {};
+}
+
+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 credentials = listCredentials();
+ const options = await generateAuthenticationOptions({
+ rpID,
+ allowCredentials: credentials.map((c) => ({
+ id: c.id,
+ transports: c.transports
+ ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[])
+ : undefined,
+ })),
+ 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 AuthenticationResponseJSON;
+ const credential = getCredentialById(body.id);
+ if (!credential) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 });
+
+ const verification = await verifyAuthenticationResponse({
+ response: body,
+ expectedChallenge: challenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ credential: {
+ id: credential.id,
+ publicKey: new Uint8Array(Buffer.from(credential.public_key, "base64url")),
+ counter: credential.counter,
+ transports: credential.transports
+ ? (JSON.parse(credential.transports) as AuthenticatorTransportFuture[])
+ : undefined,
+ },
+ });
+
+ if (!verification.verified) {
+ return Response.json({ error: "認証に失敗しました" }, { status: 400 });
+ }
+
+ updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter);
+ session.unset("challenge");
+ session.set("authenticated", true);
+ return redirect("/", {
+ headers: { "Set-Cookie": await commitSession(session) },
+ });
+ }
+
+ return Response.json({ error: "不正なリクエスト" }, { status: 400 });
+}
+
+export default function Login() {
+ const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
+ const [errorMsg, setErrorMsg] = useState("");
+
+ async function handleLogin() {
+ setStatus("loading");
+ setErrorMsg("");
+ try {
+ const { startAuthentication } = await import("@simplewebauthn/browser");
+
+ const optRes = await fetch("/auth/login?intent=options", { method: "POST" });
+ if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+ const options = await optRes.json();
+
+ const authResponse = await startAuthentication({ optionsJSON: options });
+
+ const verRes = await fetch("/auth/login?intent=verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(authResponse),
+ });
+ 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>
+ <p>登録済みのパスキーで認証します。</p>
+ <button className="btn" onClick={handleLogin} disabled={status === "loading"}>
+ {status === "loading" ? "認証中…" : "パスキーでログイン"}
+ </button>
+ {status === "error" && (
+ <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>
+ )}
+ </div>
+ </div>
+ );
+}