From cba06f7e23417a9aef6c15ae26715385feb225ba Mon Sep 17 00:00:00 2001 From: yyamashita Date: Fri, 19 Jun 2026 19:45:41 +0900 Subject: Add white theme, post delete, and passkey authentication - Rewrite CSS to light/white theme - Add delete button (auth-gated) with confirm dialog - Add passkey register/login/logout routes via @simplewebauthn - Gate compose form and delete behind session authentication - Add credentials table to SQLite schema - Add SESSION_SECRET and WebAuthn env vars to docker-compose Co-Authored-By: Claude Sonnet 4.6 --- app/routes/auth.login.tsx | 132 ++++++++++++++++++++++++++++++++++++++++ app/routes/auth.logout.tsx | 18 ++++++ app/routes/auth.register.tsx | 140 +++++++++++++++++++++++++++++++++++++++++++ app/routes/home.tsx | 121 ++++++++++++++++++++++++++----------- 4 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 app/routes/auth.login.tsx create mode 100644 app/routes/auth.logout.tsx create mode 100644 app/routes/auth.register.tsx (limited to 'app/routes') 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 ( +
+
+

log

+
+
+

ログイン

+

登録済みのパスキーで認証します。

+ + {status === "error" && ( +

{errorMsg}

+ )} +
+
+ ); +} diff --git a/app/routes/auth.logout.tsx b/app/routes/auth.logout.tsx new file mode 100644 index 0000000..3b60231 --- /dev/null +++ b/app/routes/auth.logout.tsx @@ -0,0 +1,18 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/auth.logout"; +import { getSession, destroySession } from "~/lib/session.server"; + +export async function loader() { + return redirect("/"); +} + +export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request.headers.get("Cookie")); + return redirect("/", { + headers: { "Set-Cookie": await destroySession(session) }, + }); +} + +export default function Logout() { + return null; +} 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 ( +
+
+

log

+
+
+

パスキーを登録

+ {hasCredentials ? ( + <> +

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

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

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

+ + {status === "error" &&

{errorMsg}

} + + )} +
+
+ ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 58004c9..7732326 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,31 +1,51 @@ import { useEffect, useRef } from "react"; -import { Form, redirect, useNavigation } from "react-router"; +import { Form, Link, redirect, useNavigation } from "react-router"; import type { Route } from "./+types/home"; -import { createPost, listPosts } from "~/lib/db.server"; +import { createPost, deletePost, listPosts, listCredentials } from "~/lib/db.server"; import { renderMarkdown } from "~/lib/md.server"; +import { getSession, commitSession } from "~/lib/session.server"; -export async function loader() { +export async function loader({ request }: Route.LoaderArgs) { + const session = await getSession(request.headers.get("Cookie")); + const isAuthenticated = session.get("authenticated") === true; + const hasCredentials = listCredentials().length > 0; const posts = listPosts(); - return { posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })) }; + return { + posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })), + isAuthenticated, + hasCredentials, + }; } export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request.headers.get("Cookie")); + if (session.get("authenticated") !== true) { + throw new Response("Unauthorized", { status: 401 }); + } + const form = await request.formData(); - const content = String(form.get("content") ?? "").trim(); + const intent = String(form.get("intent") ?? "create"); - if (!content) { - return { error: "内容を入力してください", content }; - } - if (content.length > 10000) { - return { error: "投稿が長すぎます(最大10,000文字)", content }; + if (intent === "delete") { + const id = String(form.get("id") ?? ""); + if (id) deletePost(id); + return redirect("/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); } + const content = String(form.get("content") ?? "").trim(); + if (!content) return { error: "内容を入力してください", content }; + if (content.length > 10000) return { error: "投稿が長すぎます(最大10,000文字)", content }; + createPost({ id: crypto.randomUUID(), content }); - return redirect("/"); + return redirect("/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); } export default function Home({ loaderData, actionData }: Route.ComponentProps) { - const { posts } = loaderData; + const { posts, isAuthenticated, hasCredentials } = loaderData; const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; const formRef = useRef(null); @@ -43,28 +63,42 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {

log

+
-
-
-