diff --git a/index.html b/index.html index a491e19..700da76 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,22 @@ - - - - - Leo's Typing Tutor - - - - - -
- - - + + + + + + カンポス家のお宿題 + + + + + + +
+ + + + \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts index 1bacfd6..af16322 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -3,12 +3,12 @@ import { verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, -} from "@simplewebauthn/server" -import { isoBase64URL } from "@simplewebauthn/server/helpers" +} from "@simplewebauthn/server"; +import { isoBase64URL } from "@simplewebauthn/server/helpers"; import type { RegistrationResponseJSON, AuthenticationResponseJSON, -} from "@simplewebauthn/server" +} from "@simplewebauthn/server"; import { createUser, getUserByUsername, @@ -19,198 +19,212 @@ import { createSession, getSession, deleteSession, -} from "./db" +} from "./db"; -const RP_NAME = "Leo's Typing Tutor" +const RP_NAME = "shukudai"; // Temporary challenge store (in-memory, keyed by random ID → challenge + metadata) -const challenges = new Map() +const challenges = new Map< + string, + { challenge: string; userId?: number; expires: number } +>(); // Cleanup expired challenges periodically setInterval(() => { - const now = Date.now() + const now = Date.now(); for (const [key, val] of challenges) { - if (val.expires < now) challenges.delete(key) + if (val.expires < now) challenges.delete(key); } -}, 60_000) +}, 60_000); function getRpId(req: Request): string { - const url = new URL(req.url) - return url.hostname + const url = new URL(req.url); + return url.hostname; } function getOrigin(req: Request): string { - const url = new URL(req.url) - return url.origin + const url = new URL(req.url); + return url.origin; } function getCookie(req: Request, name: string): string | undefined { - const cookies = req.headers.get("cookie") - if (!cookies) return undefined - const match = cookies.split(";").find(c => c.trim().startsWith(`${name}=`)) - return match?.split("=").slice(1).join("=").trim() + const cookies = req.headers.get("cookie"); + if (!cookies) return undefined; + const match = cookies.split(";").find((c) => c.trim().startsWith(`${name}=`)); + return match?.split("=").slice(1).join("=").trim(); } function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string { - return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}` + return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`; } function challengeCookie(key: string): string { - return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120` + return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`; } function clearChallengeCookie(): string { - return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0` + return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`; } // ---- Route handlers ---- export async function registerOptions(req: Request): Promise { - const { username } = await req.json() as { username: string } + const { username } = (await req.json()) as { username: string }; if (!username || username.length < 1 || username.length > 32) { - return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 }) + return Response.json( + { error: "Username must be 1-32 characters" }, + { status: 400 }, + ); } // Check if username already taken - const existing = await getUserByUsername(username) + const existing = await getUserByUsername(username); if (existing) { - return Response.json({ error: "Username already taken" }, { status: 409 }) + return Response.json({ error: "Username already taken" }, { status: 409 }); } // Create user - const user = await createUser(username) + const user = await createUser(username); - const rpID = getRpId(req) + const rpID = getRpId(req); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID, userName: username, - userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())), + userID: isoBase64URL.toBuffer( + isoBase64URL.fromUTF8String(user.id.toString()), + ), attestationType: "none", authenticatorSelection: { residentKey: "required", userVerification: "preferred", }, - }) + }); // Store challenge - const challengeKey = crypto.randomUUID() + const challengeKey = crypto.randomUUID(); challenges.set(challengeKey, { challenge: options.challenge, userId: user.id, expires: Date.now() + 120_000, - }) + }); return new Response(JSON.stringify(options), { headers: { "Content-Type": "application/json", "Set-Cookie": challengeCookie(challengeKey), }, - }) + }); } export async function registerVerify(req: Request): Promise { - const body = (await req.json()) as RegistrationResponseJSON - const challengeKey = getCookie(req, "challenge_key") + const body = (await req.json()) as RegistrationResponseJSON; + const challengeKey = getCookie(req, "challenge_key"); if (!challengeKey) { - return Response.json({ error: "No challenge found" }, { status: 400 }) + return Response.json({ error: "No challenge found" }, { status: 400 }); } - const stored = challenges.get(challengeKey) + const stored = challenges.get(challengeKey); if (!stored || stored.expires < Date.now()) { - challenges.delete(challengeKey!) - return Response.json({ error: "Challenge expired" }, { status: 400 }) + challenges.delete(challengeKey!); + return Response.json({ error: "Challenge expired" }, { status: 400 }); } - challenges.delete(challengeKey) + challenges.delete(challengeKey); - const rpID = getRpId(req) - const origin = getOrigin(req) + const rpID = getRpId(req); + const origin = getOrigin(req); - let verification + let verification; try { verification = await verifyRegistrationResponse({ response: body, expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, - }) + }); } catch (err) { - return Response.json({ error: `Verification failed: ${err}` }, { status: 400 }) + return Response.json( + { error: `Verification failed: ${err}` }, + { status: 400 }, + ); } if (!verification.verified || !verification.registrationInfo) { - return Response.json({ error: "Verification failed" }, { status: 400 }) + return Response.json({ error: "Verification failed" }, { status: 400 }); } - const { credential } = verification.registrationInfo + const { credential } = verification.registrationInfo; await storeCredential( credential.id, stored.userId!, isoBase64URL.fromBuffer(credential.publicKey), credential.counter, credential.transports, - ) + ); // Create session - const token = await createSession(stored.userId!) - const user = await getUserById(stored.userId!) + const token = await createSession(stored.userId!); + const user = await getUserById(stored.userId!); - return new Response(JSON.stringify({ verified: true, username: user?.username }), { - headers: { - "Content-Type": "application/json", - "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), + return new Response( + JSON.stringify({ verified: true, username: user?.username }), + { + headers: { + "Content-Type": "application/json", + "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), + }, }, - }) + ); } export async function loginOptions(req: Request): Promise { - const rpID = getRpId(req) + const rpID = getRpId(req); const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", // Empty allowCredentials = discoverable credentials (passkey prompt) - }) + }); - const challengeKey = crypto.randomUUID() + const challengeKey = crypto.randomUUID(); challenges.set(challengeKey, { challenge: options.challenge, expires: Date.now() + 120_000, - }) + }); return new Response(JSON.stringify(options), { headers: { "Content-Type": "application/json", "Set-Cookie": challengeCookie(challengeKey), }, - }) + }); } export async function loginVerify(req: Request): Promise { - const body = (await req.json()) as AuthenticationResponseJSON - const challengeKey = getCookie(req, "challenge_key") + const body = (await req.json()) as AuthenticationResponseJSON; + const challengeKey = getCookie(req, "challenge_key"); if (!challengeKey) { - return Response.json({ error: "No challenge found" }, { status: 400 }) + return Response.json({ error: "No challenge found" }, { status: 400 }); } - const stored = challenges.get(challengeKey) + const stored = challenges.get(challengeKey); if (!stored || stored.expires < Date.now()) { - challenges.delete(challengeKey!) - return Response.json({ error: "Challenge expired" }, { status: 400 }) + challenges.delete(challengeKey!); + return Response.json({ error: "Challenge expired" }, { status: 400 }); } - challenges.delete(challengeKey) + challenges.delete(challengeKey); // Look up credential - const credentialId = body.id - const credential = await getCredentialById(credentialId) + const credentialId = body.id; + const credential = await getCredentialById(credentialId); if (!credential) { - return Response.json({ error: "Unknown credential" }, { status: 400 }) + return Response.json({ error: "Unknown credential" }, { status: 400 }); } - const rpID = getRpId(req) - const origin = getOrigin(req) + const rpID = getRpId(req); + const origin = getOrigin(req); - let verification + let verification; try { verification = await verifyAuthenticationResponse({ response: body, @@ -221,60 +235,73 @@ export async function loginVerify(req: Request): Promise { id: credential.id, publicKey: isoBase64URL.toBuffer(credential.public_key), counter: credential.counter, - transports: credential.transports ? JSON.parse(credential.transports) : undefined, + transports: credential.transports + ? JSON.parse(credential.transports) + : undefined, }, - }) + }); } catch (err) { - return Response.json({ error: `Verification failed: ${err}` }, { status: 400 }) + return Response.json( + { error: `Verification failed: ${err}` }, + { status: 400 }, + ); } if (!verification.verified) { - return Response.json({ error: "Verification failed" }, { status: 400 }) + return Response.json({ error: "Verification failed" }, { status: 400 }); } // Update counter - await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter) + await updateCredentialCounter( + credentialId, + verification.authenticationInfo.newCounter, + ); // Create session - const token = await createSession(credential.user_id) - const user = await getUserById(credential.user_id) + const token = await createSession(credential.user_id); + const user = await getUserById(credential.user_id); - return new Response(JSON.stringify({ verified: true, username: user?.username }), { - headers: { - "Content-Type": "application/json", - "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), + return new Response( + JSON.stringify({ verified: true, username: user?.username }), + { + headers: { + "Content-Type": "application/json", + "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), + }, }, - }) + ); } export async function me(req: Request): Promise { - const token = getCookie(req, "session") + const token = getCookie(req, "session"); if (!token) { - return Response.json({ user: null }) + return Response.json({ user: null }); } - const session = await getSession(token) + const session = await getSession(token); if (!session) { return new Response(JSON.stringify({ user: null }), { headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie("", 0), // clear expired cookie }, - }) + }); } - return Response.json({ user: { id: session.user_id, username: session.username } }) + return Response.json({ + user: { id: session.user_id, username: session.username }, + }); } export async function logout(req: Request): Promise { - const token = getCookie(req, "session") + const token = getCookie(req, "session"); if (token) { - await deleteSession(token) + await deleteSession(token); } return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie("", 0), }, - }) + }); } diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx index b8fde95..b4a3e7b 100644 --- a/src/components/AuthGate.tsx +++ b/src/components/AuthGate.tsx @@ -1,110 +1,113 @@ -import { useState, useEffect, type ReactNode } from 'react' -import { startRegistration, startAuthentication } from '@simplewebauthn/browser' -import '../styles/auth.css' +import { useState, useEffect, type ReactNode } from "react"; +import { + startRegistration, + startAuthentication, +} from "@simplewebauthn/browser"; +import "../styles/auth.css"; -type User = { id: number; username: string } +type User = { id: number; username: string }; type Props = { - children: (user: User, onLogout: () => void) => ReactNode -} + children: (user: User, onLogout: () => void) => ReactNode; +}; export function AuthGate({ children }: Props) { - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) - const [tab, setTab] = useState<'register' | 'login'>('login') - const [username, setUsername] = useState('') - const [error, setError] = useState('') - const [busy, setBusy] = useState(false) + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState<"register" | "login">("login"); + const [username, setUsername] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); // Check existing session useEffect(() => { - fetch('/api/auth/me') - .then(r => r.json()) - .then(data => { - if (data.user) setUser(data.user) + fetch("/api/auth/me") + .then((r) => r.json()) + .then((data) => { + if (data.user) setUser(data.user); }) .catch(() => {}) - .finally(() => setLoading(false)) - }, []) + .finally(() => setLoading(false)); + }, []); const handleRegister = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setBusy(true) + e.preventDefault(); + setError(""); + setBusy(true); try { - const optRes = await fetch('/api/auth/register/options', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const optRes = await fetch("/api/auth/register/options", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username.trim() }), - }) + }); if (!optRes.ok) { - const err = await optRes.json() - throw new Error(err.error || 'Failed to start registration') + const err = await optRes.json(); + throw new Error(err.error || "Failed to start registration"); } - const options = await optRes.json() - const credential = await startRegistration({ optionsJSON: options }) - const verRes = await fetch('/api/auth/register/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const options = await optRes.json(); + const credential = await startRegistration({ optionsJSON: options }); + const verRes = await fetch("/api/auth/register/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(credential), - }) + }); if (!verRes.ok) { - const err = await verRes.json() - throw new Error(err.error || 'Registration failed') + const err = await verRes.json(); + throw new Error(err.error || "Registration failed"); } - const result = await verRes.json() - setUser({ id: 0, username: result.username }) + const result = await verRes.json(); + setUser({ id: 0, username: result.username }); } catch (err) { if (err instanceof Error) { - if (err.name === 'NotAllowedError') { - setError('Passkey creation was cancelled') + if (err.name === "NotAllowedError") { + setError("Passkey creation was cancelled"); } else { - setError(err.message) + setError(err.message); } } } finally { - setBusy(false) + setBusy(false); } - } + }; const handleLogin = async () => { - setError('') - setBusy(true) + setError(""); + setBusy(true); try { - const optRes = await fetch('/api/auth/login/options', { method: 'POST' }) - if (!optRes.ok) throw new Error('Failed to start login') - const options = await optRes.json() - const credential = await startAuthentication({ optionsJSON: options }) - const verRes = await fetch('/api/auth/login/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const optRes = await fetch("/api/auth/login/options", { method: "POST" }); + if (!optRes.ok) throw new Error("Failed to start login"); + const options = await optRes.json(); + const credential = await startAuthentication({ optionsJSON: options }); + const verRes = await fetch("/api/auth/login/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(credential), - }) + }); if (!verRes.ok) { - const err = await verRes.json() - throw new Error(err.error || 'Login failed') + const err = await verRes.json(); + throw new Error(err.error || "Login failed"); } - const result = await verRes.json() - setUser({ id: 0, username: result.username }) + const result = await verRes.json(); + setUser({ id: 0, username: result.username }); } catch (err) { if (err instanceof Error) { - if (err.name === 'NotAllowedError') { - setError('Passkey authentication was cancelled') + if (err.name === "NotAllowedError") { + setError("Passkey authentication was cancelled"); } else { - setError(err.message) + setError(err.message); } } } finally { - setBusy(false) + setBusy(false); } - } + }; const handleLogout = async () => { - await fetch('/api/auth/logout', { method: 'POST' }) - setUser(null) - } + await fetch("/api/auth/logout", { method: "POST" }); + setUser(null); + }; if (loading) { return ( @@ -114,58 +117,71 @@ export function AuthGate({ children }: Props) {
Loading...
- ) + ); } if (user) { - return <>{children(user, handleLogout)} + return <>{children(user, handleLogout)}; } return (
⌨️
-
Leo's Typing Tutor
-
Sign in with a passkey to track your progress
+
カンポス家のお宿題
+
+ Sign in with a passkey to track your progress +
- {tab === 'register' ? ( + {tab === "register" ? (
setUsername(e.target.value)} + onChange={(e) => setUsername(e.target.value)} maxLength={32} autoFocus required /> -
- You'll be asked to create a passkey using your device's fingerprint, face, or PIN. + You'll be asked to create a passkey using your device's + fingerprint, face, or PIN.
) : (
Your device will show your saved passkeys. Pick yours to sign in. @@ -176,5 +192,5 @@ export function AuthGate({ children }: Props) { {error &&
{error}
}
- ) + ); } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2836d05..77f50cd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,52 +1,63 @@ -import { type ReactNode } from 'react' +import { type ReactNode } from "react"; -type Tab = 'lessons' | 'free' | 'game' | 'stats' +type Tab = "lessons" | "free" | "game" | "stats"; type Props = { - activeTab: Tab - onTabChange: (tab: Tab) => void - username: string - onLogout: () => void - children: ReactNode -} + activeTab: Tab; + onTabChange: (tab: Tab) => void; + username: string; + onLogout: () => void; + children: ReactNode; +}; const TABS: { id: Tab; label: string; icon: string }[] = [ - { id: 'lessons', label: 'Lessons', icon: '📚' }, - { id: 'free', label: 'Free Type', icon: '⌨️' }, - { id: 'game', label: 'Game', icon: '🎮' }, - { id: 'stats', label: 'Stats', icon: '📊' }, -] + { id: "lessons", label: "Lessons", icon: "📚" }, + { id: "free", label: "Free Type", icon: "⌨️" }, + { id: "game", label: "Game", icon: "🎮" }, + { id: "stats", label: "Stats", icon: "📊" }, +]; -export function Layout({ activeTab, onTabChange, username, onLogout, children }: Props) { +export function Layout({ + activeTab, + onTabChange, + username, + onLogout, + children, +}: Props) { return ( -
-
-

- Leo's Typing Tutor +
+
+

+ カンポス家のお宿題

-
-
{children}
- ) + ); }