diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
| commit | 874749df46cc291ca61f2f98092ccabfdeffbf16 (patch) | |
| tree | e4cb53b5bfa23974e7935eb2616e0e005954f782 /app/lib | |
| parent | 999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (diff) | |
Single-user auth: any registered passkey logs in as the owner. First
registration needs a one-time token that the app writes to
data/registration-token when no passkey exists yet; once logged in,
more passkeys can be added without a token.
Logged in, /favorites manages a list of terms (e.g. band names).
Events whose title or artist contains a term (NFKC + case-insensitive)
get a ★, and the event list gains an "お気に入りのみ" filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/lib')
| -rw-r--r-- | app/lib/auth.server.ts | 304 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 64 |
2 files changed, 365 insertions, 3 deletions
diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts new file mode 100644 index 0000000..e0e93b8 --- /dev/null +++ b/app/lib/auth.server.ts @@ -0,0 +1,304 @@ +/** + * Single-user passkey auth + favorites. + * + * - どのパスキーでログインしても同じ「オーナー」としてログインする(ユーザーは1人) + * - 未ログイン時のパスキー登録は、DATA_DIR/registration-token に書かれたトークンが必要 + * - パスキーが1つも無ければ起動後の初回アクセス時に自動生成される + * - 全デバイスを失った場合は、このファイルを手で作れば再登録できる + * - ログイン済みなら追加のパスキー(別デバイス)をトークン無しで登録できる + */ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type RegistrationResponseJSON, +} from "@simplewebauthn/server"; +import { DATA_DIR, getDb, normalizeTerm } from "./db.server"; + +const SESSION_COOKIE = "sid"; +const CHALLENGE_COOKIE = "wa_chal"; +const SESSION_DAYS = 90; +const CHALLENGE_MINUTES = 5; +const RP_NAME = "ライブに行くしかない"; +const TOKEN_PATH = path.join(DATA_DIR, "registration-token"); + +// ---------- Request helpers ---------- + +/** Origin/RP ID as seen by the browser. Behind Caddy, the app itself receives plain http. */ +function relyingParty(request: Request): { origin: string; rpID: string } { + const url = new URL(request.url); + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host; + const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(/:$/, ""); + return { origin: `${proto}://${host}`, rpID: host.replace(/:\d+$/, "") }; +} + +function readCookie(request: Request, name: string): string | null { + const header = request.headers.get("cookie"); + if (!header) return null; + for (const part of header.split(";")) { + const [k, ...v] = part.trim().split("="); + if (k === name) return decodeURIComponent(v.join("=")); + } + return null; +} + +function cookie(request: Request, name: string, value: string, maxAgeSec: number): string { + const secure = relyingParty(request).origin.startsWith("https:") ? "; Secure" : ""; + return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`; +} + +function sqlTimeIn(seconds: number): string { + return new Date(Date.now() + seconds * 1000).toISOString().replace("T", " ").slice(0, 19); +} + +// ---------- Sessions ---------- + +export function isLoggedIn(request: Request): boolean { + const sid = readCookie(request, SESSION_COOKIE); + if (!sid) return false; + const row = getDb() + .prepare("SELECT 1 FROM sessions WHERE id = ? AND expires_at > datetime('now')") + .get(sid); + return !!row; +} + +export function requireLogin(request: Request): void { + if (!isLoggedIn(request)) { + const url = new URL(request.url); + throw new Response(null, { + status: 302, + headers: { Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}` }, + }); + } +} + +function createSession(request: Request): string { + const db = getDb(); + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); + const sid = crypto.randomBytes(32).toString("base64url"); + const maxAge = SESSION_DAYS * 86400; + db.prepare("INSERT INTO sessions (id, expires_at) VALUES (?, ?)").run(sid, sqlTimeIn(maxAge)); + return cookie(request, SESSION_COOKIE, sid, maxAge); +} + +export function logout(request: Request): string { + const sid = readCookie(request, SESSION_COOKIE); + if (sid) getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sid); + return cookie(request, SESSION_COOKIE, "", 0); +} + +// ---------- Challenges (one-time, short-lived) ---------- + +function storeChallenge(request: Request, challenge: string): string { + const db = getDb(); + db.prepare("DELETE FROM auth_challenges WHERE expires_at <= datetime('now')").run(); + const id = crypto.randomBytes(16).toString("base64url"); + const maxAge = CHALLENGE_MINUTES * 60; + db.prepare("INSERT INTO auth_challenges (id, challenge, expires_at) VALUES (?, ?, ?)").run( + id, challenge, sqlTimeIn(maxAge) + ); + return cookie(request, CHALLENGE_COOKIE, id, maxAge); +} + +function consumeChallenge(request: Request): string | null { + const id = readCookie(request, CHALLENGE_COOKIE); + if (!id) return null; + const db = getDb(); + const row = db + .prepare("SELECT challenge FROM auth_challenges WHERE id = ? AND expires_at > datetime('now')") + .get(id) as { challenge: string } | undefined; + db.prepare("DELETE FROM auth_challenges WHERE id = ?").run(id); + return row?.challenge ?? null; +} + +// ---------- Registration token ---------- + +function passkeyCount(): number { + return (getDb().prepare("SELECT COUNT(*) AS n FROM passkeys").get() as { n: number }).n; +} + +/** Ensure a registration token exists while no passkey is registered (first-time setup). */ +export function ensureSetupToken(): void { + if (passkeyCount() > 0 || fs.existsSync(TOKEN_PATH)) return; + fs.writeFileSync(TOKEN_PATH, crypto.randomBytes(24).toString("base64url") + "\n", { mode: 0o600 }); + console.log(`[auth] No passkey registered. Registration token written to ${TOKEN_PATH}`); +} + +function checkToken(token: string | null): boolean { + if (!token || !fs.existsSync(TOKEN_PATH)) return false; + const expected = Buffer.from(fs.readFileSync(TOKEN_PATH, "utf8").trim()); + const given = Buffer.from(token.trim()); + return expected.length === given.length && crypto.timingSafeEqual(expected, given); +} + +export function canRegister(request: Request, token: string | null): boolean { + return isLoggedIn(request) || checkToken(token); +} + +// ---------- WebAuthn ceremonies ---------- + +interface PasskeyRow { + id: string; + public_key: Buffer; + counter: number; + transports: string | null; +} + +const OWNER_ID = new TextEncoder().encode("owner"); + +export async function registrationOptions(request: Request) { + const { rpID } = relyingParty(request); + const existing = getDb().prepare("SELECT id, transports FROM passkeys").all() as PasskeyRow[]; + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID, + userID: OWNER_ID, + userName: "owner", + userDisplayName: "オーナー", + attestationType: "none", + excludeCredentials: existing.map((p) => ({ + id: p.id, + transports: p.transports ? JSON.parse(p.transports) : undefined, + })), + authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, + }); + return { options, setCookie: storeChallenge(request, options.challenge) }; +} + +export async function verifyRegistration( + request: Request, + response: RegistrationResponseJSON, + label: string | null, + usedToken: boolean +): Promise<string> { + const expectedChallenge = consumeChallenge(request); + if (!expectedChallenge) throw new Error("challenge expired"); + const { origin, rpID } = relyingParty(request); + const result = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + if (!result.verified) throw new Error("registration not verified"); + + const { credential } = result.registrationInfo; + getDb() + .prepare("INSERT INTO passkeys (id, public_key, counter, transports, label) VALUES (?, ?, ?, ?, ?)") + .run( + credential.id, + Buffer.from(credential.publicKey), + credential.counter, + credential.transports ? JSON.stringify(credential.transports) : null, + label?.slice(0, 60) || null + ); + + // Token is single-use + if (usedToken) fs.rmSync(TOKEN_PATH, { force: true }); + + return createSession(request); +} + +export async function authenticationOptions(request: Request) { + const { rpID } = relyingParty(request); + const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred" }); + return { options, setCookie: storeChallenge(request, options.challenge) }; +} + +export async function verifyAuthentication( + request: Request, + response: AuthenticationResponseJSON +): Promise<string> { + const expectedChallenge = consumeChallenge(request); + if (!expectedChallenge) throw new Error("challenge expired"); + const db = getDb(); + const passkey = db.prepare("SELECT * FROM passkeys WHERE id = ?").get(response.id) as + | PasskeyRow + | undefined; + if (!passkey) throw new Error("unknown passkey"); + + const { origin, rpID } = relyingParty(request); + const result = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: new Uint8Array(passkey.public_key), + counter: passkey.counter, + transports: passkey.transports ? JSON.parse(passkey.transports) : undefined, + }, + requireUserVerification: false, + }); + if (!result.verified) throw new Error("authentication not verified"); + + db.prepare("UPDATE passkeys SET counter = ?, last_used_at = datetime('now') WHERE id = ?").run( + result.authenticationInfo.newCounter, + passkey.id + ); + return createSession(request); +} + +// ---------- Passkey management ---------- + +export interface PasskeyInfo { + id: string; + label: string | null; + created_at: string; + last_used_at: string | null; +} + +export function listPasskeys(): PasskeyInfo[] { + return getDb() + .prepare("SELECT id, label, created_at, last_used_at FROM passkeys ORDER BY created_at") + .all() as PasskeyInfo[]; +} + +/** Refuses to delete the last passkey so the owner can't lock themselves out. */ +export function deletePasskey(id: string): boolean { + if (passkeyCount() <= 1) return false; + return getDb().prepare("DELETE FROM passkeys WHERE id = ?").run(id).changes > 0; +} + +// ---------- Favorites ---------- + +export interface Favorite { + id: number; + term: string; + upcoming_count: number; +} + +export function listFavorites(): Favorite[] { + return getDb() + .prepare( + `SELECT f.id, f.term, + (SELECT COUNT(*) FROM events e + WHERE e.date >= date('now', 'localtime') + AND instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0 + ) AS upcoming_count + FROM favorites f ORDER BY f.term COLLATE NOCASE` + ) + .all() as Favorite[]; +} + +export function addFavorite(term: string): boolean { + const trimmed = term.trim().slice(0, 100); + const norm = normalizeTerm(trimmed); + if (!norm) return false; + return ( + getDb() + .prepare("INSERT OR IGNORE INTO favorites (term, term_norm) VALUES (?, ?)") + .run(trimmed, norm).changes > 0 + ); +} + +export function deleteFavorite(id: number): void { + getDb().prepare("DELETE FROM favorites WHERE id = ?").run(id); +} diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index 82a0025..9d6597d 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -9,11 +9,22 @@ const DB_PATH = process.env.DB_PATH let _db: Database.Database | null = null; -function getDb(): Database.Database { +/** Directory holding the DB file — also used for other runtime state files. */ +export const DATA_DIR = path.dirname(DB_PATH); + +/** Normalization used for favorite matching: full/half-width and case insensitive. */ +export function normalizeTerm(s: string): string { + return s.normalize("NFKC").toLowerCase().trim(); +} + +export function getDb(): Database.Database { if (!_db) { _db = new Database(DB_PATH); _db.pragma("journal_mode = WAL"); _db.pragma("foreign_keys = ON"); + _db.function("nfkc_lower", { deterministic: true }, (s: unknown) => + typeof s === "string" ? normalizeTerm(s) : null + ); initSchema(_db); } return _db; @@ -63,6 +74,36 @@ function initSchema(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_scrape_logs_run_id ON scrape_logs(run_id); CREATE INDEX IF NOT EXISTS idx_scrape_logs_venue_id ON scrape_logs(venue_id); + + -- Single-user auth: any registered passkey logs in as "the" owner + CREATE TABLE IF NOT EXISTS passkeys ( + id TEXT PRIMARY KEY, -- credential ID (base64url) + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, -- JSON array + label TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + term TEXT NOT NULL, + term_norm TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); // Migration: add capacity column to existing venues tables @@ -99,6 +140,8 @@ export interface Event { description: string | null; source_url: string | null; fetched_at: string; + /** 1 if title/artist matches a favorite term. Only computed when with_favorites is set. */ + is_favorite?: number; } export interface EventInput { @@ -173,13 +216,24 @@ export interface QueryEventsParams { keyword?: string; capacity_range?: CapacityRange; area?: string; + /** Compute is_favorite for each row */ + with_favorites?: boolean; + /** Only events matching a favorite term */ + favorites_only?: boolean; limit?: number; offset?: number; } +const FAVORITE_MATCH_SQL = `EXISTS ( + SELECT 1 FROM favorites f + WHERE instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0 +)`; + export function queryEvents(params: QueryEventsParams = {}): Event[] { - const { date_from, date_to, venue_id, keyword, capacity_range, area, limit = 60, offset = 0 } = - params; + const { + date_from, date_to, venue_id, keyword, capacity_range, area, + with_favorites, favorites_only, limit = 60, offset = 0, + } = params; const clauses: string[] = []; const args: unknown[] = []; @@ -211,12 +265,16 @@ export function queryEvents(params: QueryEventsParams = {}): Event[] { clauses.push("v.area = ?"); args.push(area); } + if (favorites_only) { + clauses.push(FAVORITE_MATCH_SQL); + } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; return getDb() .prepare( `SELECT e.*, v.name AS venue_name, v.area AS venue_area + ${with_favorites ? `, ${FAVORITE_MATCH_SQL} AS is_favorite` : ""} FROM events e JOIN venues v ON e.venue_id = v.id ${where} ORDER BY e.date ASC, e.start_time ASC |
