From 7454b0eda54b8e7c05eca26b7abd6608cba8af9c Mon Sep 17 00:00:00 2001 From: posimai Date: Thu, 26 Mar 2026 08:31:11 +0900 Subject: [PATCH] feat: add Magic Link + Passkey (WebAuthn) authentication to server.js - Add JWT session auth (jsonwebtoken v9) alongside legacy API key auth - Magic Link: POST /auth/magic-link/send + GET /auth/magic-link/verify - Passkey: register/begin+finish, login/begin+finish endpoints - Session: GET /auth/session/verify, DELETE /auth/session - Passkey management: GET/DELETE /auth/passkeys - New DB tables: magic_link_tokens, passkey_credentials, auth_sessions, magic_link_rate_limit, webauthn_user_handles - Users table: add email + email_verified columns (migration) - Rate limiting on magic link sends (3 per 10min per email) - Resend email integration (stubbed until DNS verified) - SimpleWebAuthn v13 (ESM) loaded via dynamic import - authMiddleware: JWT-first, fallback to API key (backward compat) - WEBAUTHN_RP_ID/ORIGINS/JWT_SECRET configurable via env vars Co-Authored-By: Claude Sonnet 4.6 --- server.js | 534 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 528 insertions(+), 6 deletions(-) diff --git a/server.js b/server.js index 9cf5cc1b..553deac3 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,50 @@ const { parse } = require('node-html-parser'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); + +// ── Auth: WebAuthn (ESM dynamic import) ───────────────────────────── +let webauthn = null; +async function loadWebauthn() { + try { + webauthn = await import('@simplewebauthn/server'); + console.log('[Auth] SimpleWebAuthn loaded'); + } catch (e) { + console.warn('[Auth] SimpleWebAuthn not available:', e.message); + } +} + +// In-memory WebAuthn challenge store (TTL: 5 min) +const webauthnChallenges = new Map(); +setInterval(() => { + const now = Date.now(); + for (const [k, v] of webauthnChallenges) { + if (v.expiresAt < now) webauthnChallenges.delete(k); + } +}, 10 * 60 * 1000); + +// ── Auth: JWT config ──────────────────────────────────────────────── +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-CHANGE-IN-PRODUCTION'; +const JWT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days + +// WebAuthn relying party config (from env) +const WEBAUTHN_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'Posimai'; +const WEBAUTHN_RP_ID = process.env.WEBAUTHN_RP_ID || 'localhost'; +const WEBAUTHN_ORIGINS = (process.env.WEBAUTHN_ORIGINS || 'http://localhost:3000') + .split(',').map(o => o.trim()).filter(Boolean); +const MAGIC_LINK_BASE_URL = process.env.MAGIC_LINK_BASE_URL || 'http://localhost:3000'; + +// ── Auth: session helpers ──────────────────────────────────────────── +async function createSessionJWT(userId) { + const sessionId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + JWT_TTL_SECONDS * 1000); + const tokenHash = crypto.createHash('sha256').update(sessionId).digest('hex'); + await pool.query( + `INSERT INTO auth_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)`, + [sessionId, userId, tokenHash, expiresAt] + ); + return jwt.sign({ userId, sid: sessionId }, JWT_SECRET, { expiresIn: JWT_TTL_SECONDS }); +} const app = express(); app.use(express.json({ limit: '10mb' })); @@ -81,21 +125,37 @@ const KEY_MAP = {}; }); function authMiddleware(req, res, next) { - let key = ''; + let token = ''; // 1. ヘッダーからの取得 const auth = req.headers.authorization || ''; if (auth.toLowerCase().startsWith('bearer ')) { - key = auth.substring(7).trim(); + token = auth.substring(7).trim(); } // 2. クエリパラメータからの取得 (Bookmarklet等) else if (req.query.key) { - key = req.query.key.trim(); + token = req.query.key.trim(); } - const userId = KEY_MAP[key]; + if (!token) return res.status(401).json({ error: '認証エラー: トークンがありません' }); + + // JWT session token (3-part dot format, not pk_ prefix) + if (!token.startsWith('pk_') && token.includes('.')) { + try { + const payload = jwt.verify(token, JWT_SECRET); + req.userId = payload.userId; + req.authType = 'session'; + return next(); + } catch (e) { + return res.status(401).json({ error: '認証エラー: セッションが無効または期限切れです' }); + } + } + + // API key (legacy) + const userId = KEY_MAP[token]; if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' }); req.userId = userId; + req.authType = 'apikey'; next(); } @@ -425,6 +485,52 @@ async function initDB() { created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`, + + // ── Auth ─────────────────────────────────── + `CREATE TABLE IF NOT EXISTS magic_link_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + token VARCHAR(64) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_mlt_token ON magic_link_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_mlt_email ON magic_link_tokens(email, expires_at)`, + `CREATE TABLE IF NOT EXISTS passkey_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + credential_id TEXT UNIQUE NOT NULL, + public_key BYTEA NOT NULL, + counter BIGINT DEFAULT 0, + device_type VARCHAR(32), + transports TEXT[], + display_name VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_pk_user ON passkey_credentials(user_id)`, + `CREATE TABLE IF NOT EXISTS auth_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + token_hash VARCHAR(64) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ DEFAULT NOW(), + user_agent TEXT, + ip_address INET + )`, + `CREATE INDEX IF NOT EXISTS idx_as_token_hash ON auth_sessions(token_hash)`, + `CREATE INDEX IF NOT EXISTS idx_as_user_id ON auth_sessions(user_id)`, + `CREATE TABLE IF NOT EXISTS magic_link_rate_limit ( + email VARCHAR(255) PRIMARY KEY, + attempt_count INT DEFAULT 1, + window_start TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS webauthn_user_handles ( + user_id VARCHAR(50) PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + user_handle TEXT UNIQUE NOT NULL + )`, ]; for (const sql of schema) { await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message)); @@ -441,6 +547,11 @@ async function initDB() { `ALTER TABLE site_config DROP CONSTRAINT IF EXISTS site_config_pkey`, `ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`, // together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理 + + // ── Auth migrations ─────────────────────── + `ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false`, ]; for (const sql of migrations) { await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message)); @@ -470,6 +581,415 @@ function buildRouter() { res.json({ ok: true, userId: req.userId }); }); + // ── Auth: Magic Link ───────────────────────────────────────────── + + // POST /api/auth/magic-link/send + r.post('/auth/magic-link/send', async (req, res) => { + const { email } = req.body; + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: 'メールアドレスが無効です' }); + } + const normalizedEmail = email.toLowerCase().trim(); + try { + // Rate limit: 3 requests per 10 min per email + const rl = await pool.query( + `SELECT attempt_count, window_start FROM magic_link_rate_limit WHERE email = $1`, + [normalizedEmail] + ); + if (rl.rows.length > 0) { + const row = rl.rows[0]; + const windowAgeMinutes = (Date.now() - new Date(row.window_start).getTime()) / 60000; + if (windowAgeMinutes < 10 && row.attempt_count >= 3) { + return res.status(429).json({ error: '送信制限: 10分後に再試行してください' }); + } + if (windowAgeMinutes >= 10) { + await pool.query( + `UPDATE magic_link_rate_limit SET attempt_count = 1, window_start = NOW() WHERE email = $1`, + [normalizedEmail] + ); + } else { + await pool.query( + `UPDATE magic_link_rate_limit SET attempt_count = attempt_count + 1 WHERE email = $1`, + [normalizedEmail] + ); + } + } else { + await pool.query( + `INSERT INTO magic_link_rate_limit (email) VALUES ($1)`, + [normalizedEmail] + ); + } + + // Generate token + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min + await pool.query( + `INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ($1, $2, $3)`, + [normalizedEmail, token, expiresAt] + ); + + // Send email via Resend (if API key is set) + if (process.env.RESEND_API_KEY) { + const magicLinkUrl = `${MAGIC_LINK_BASE_URL}/auth/verify?token=${token}`; + try { + const emailRes = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.RESEND_API_KEY}` + }, + body: JSON.stringify({ + from: 'Posimai ', + to: [normalizedEmail], + subject: 'Posimai ログインリンク', + html: `

以下のリンクをクリックして Posimai にログインしてください。

+

${magicLinkUrl}

+

このリンクは15分間有効です。

+

このメールに心当たりがない場合は無視してください。

` + }) + }); + if (!emailRes.ok) { + const errBody = await emailRes.text(); + console.error('[Auth] Resend API error:', emailRes.status, errBody); + } else { + console.log(`[Auth] Magic link sent to ${normalizedEmail}`); + } + } catch (emailErr) { + console.error('[Auth] Email send failed:', emailErr.message); + } + } else { + // Dev mode: log token to console + console.log(`[Auth] Magic link token (dev): ${token}`); + } + + res.json({ ok: true, message: 'ログインリンクを送信しました' }); + } catch (e) { + console.error('[Auth] Magic link send error:', e); + res.status(500).json({ error: 'サーバーエラーが発生しました' }); + } + }); + + // GET /api/auth/magic-link/verify?token=xxx + r.get('/auth/magic-link/verify', async (req, res) => { + const { token } = req.query; + if (!token) return res.status(400).json({ error: 'トークンが必要です' }); + try { + const result = await pool.query( + `SELECT * FROM magic_link_tokens WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`, + [token] + ); + if (result.rows.length === 0) { + return res.status(401).json({ error: 'トークンが無効または期限切れです' }); + } + const { email } = result.rows[0]; + + // Mark token as used + await pool.query( + `UPDATE magic_link_tokens SET used_at = NOW() WHERE token = $1`, + [token] + ); + + // Find or create user by email + let userResult = await pool.query( + `SELECT user_id FROM users WHERE email = $1`, + [email] + ); + let userId; + if (userResult.rows.length > 0) { + userId = userResult.rows[0].user_id; + await pool.query( + `UPDATE users SET email_verified = true WHERE user_id = $1`, + [userId] + ); + } else { + // Generate user_id from email prefix + const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); + userId = `${baseId}_${Date.now().toString(36)}`; + await pool.query( + `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) + ON CONFLICT (user_id) DO NOTHING`, + [userId, baseId, email] + ); + } + + const sessionToken = await createSessionJWT(userId); + res.json({ ok: true, token: sessionToken, userId }); + } catch (e) { + console.error('[Auth] Magic link verify error:', e); + res.status(500).json({ error: 'サーバーエラーが発生しました' }); + } + }); + + // GET /api/auth/session/verify — check current JWT + r.get('/auth/session/verify', authMiddleware, (req, res) => { + res.json({ ok: true, userId: req.userId, authType: req.authType }); + }); + + // DELETE /api/auth/session — logout (revoke session in DB) + r.delete('/auth/session', authMiddleware, async (req, res) => { + try { + if (req.authType === 'session') { + const auth = req.headers.authorization || ''; + const token = auth.substring(7).trim(); + try { + const payload = jwt.decode(token); + if (payload?.sid) { + await pool.query( + `DELETE FROM auth_sessions WHERE id = $1`, + [payload.sid] + ); + } + } catch (_) { /* ignore decode errors */ } + } + res.json({ ok: true }); + } catch (e) { + console.error('[Auth] Session delete error:', e); + res.status(500).json({ error: 'サーバーエラーが発生しました' }); + } + }); + + // ── Auth: Passkey / WebAuthn ───────────────────────────────────── + + // POST /api/auth/passkey/register/begin (requires existing session) + r.post('/auth/passkey/register/begin', authMiddleware, async (req, res) => { + if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); + const userId = req.userId; + try { + // Get or create stable user handle (random bytes, not user_id) + let handleResult = await pool.query( + `SELECT user_handle FROM webauthn_user_handles WHERE user_id = $1`, + [userId] + ); + let userHandle; + if (handleResult.rows.length > 0) { + userHandle = handleResult.rows[0].user_handle; + } else { + userHandle = crypto.randomBytes(16).toString('base64url'); + await pool.query( + `INSERT INTO webauthn_user_handles (user_id, user_handle) VALUES ($1, $2) + ON CONFLICT (user_id) DO NOTHING`, + [userId, userHandle] + ); + } + + // Get existing credentials (to exclude from registration options) + const existing = await pool.query( + `SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $1`, + [userId] + ); + const excludeCredentials = existing.rows.map(row => ({ + id: row.credential_id, + transports: row.transports || [] + })); + + // Get user info for display name + const userInfo = await pool.query(`SELECT name, email FROM users WHERE user_id = $1`, [userId]); + const displayName = userInfo.rows[0]?.email || userId; + + const options = await webauthn.generateRegistrationOptions({ + rpName: WEBAUTHN_RP_NAME, + rpID: WEBAUTHN_RP_ID, + userID: Buffer.from(userHandle), + userName: userId, + userDisplayName: displayName, + attestationType: 'none', + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred' + } + }); + + // Store challenge (5 min TTL) + webauthnChallenges.set(`reg:${userId}`, { + challenge: options.challenge, + expiresAt: Date.now() + 5 * 60 * 1000 + }); + + res.json(options); + } catch (e) { + console.error('[Auth] Passkey register begin error:', e); + res.status(500).json({ error: 'パスキー登録の開始に失敗しました' }); + } + }); + + // POST /api/auth/passkey/register/finish (requires existing session) + r.post('/auth/passkey/register/finish', authMiddleware, async (req, res) => { + if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); + const userId = req.userId; + const challengeEntry = webauthnChallenges.get(`reg:${userId}`); + if (!challengeEntry || challengeEntry.expiresAt < Date.now()) { + return res.status(400).json({ error: '登録セッションが期限切れです。最初からやり直してください' }); + } + try { + const verification = await webauthn.verifyRegistrationResponse({ + response: req.body, + expectedChallenge: challengeEntry.challenge, + expectedOrigin: WEBAUTHN_ORIGINS, + expectedRPID: WEBAUTHN_RP_ID + }); + + if (!verification.verified || !verification.registrationInfo) { + return res.status(400).json({ error: 'パスキーの検証に失敗しました' }); + } + + webauthnChallenges.delete(`reg:${userId}`); + const { credential, credentialDeviceType } = verification.registrationInfo; + const displayName = req.body.displayName || req.headers['user-agent']?.substring(0, 50) || 'Unknown device'; + + await pool.query( + `INSERT INTO passkey_credentials + (user_id, credential_id, public_key, counter, device_type, transports, display_name) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (credential_id) DO UPDATE + SET counter = $4, last_used_at = NOW()`, + [ + userId, + credential.id, + Buffer.from(credential.publicKey), + credential.counter, + credentialDeviceType, + req.body.response?.transports || [], + displayName + ] + ); + res.json({ ok: true }); + } catch (e) { + console.error('[Auth] Passkey register finish error:', e); + res.status(500).json({ error: 'パスキー登録に失敗しました' }); + } + }); + + // POST /api/auth/passkey/login/begin + r.post('/auth/passkey/login/begin', async (req, res) => { + if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); + const { email } = req.body; + const challengeKey = email ? `login:${email.toLowerCase().trim()}` : `login:anon:${crypto.randomBytes(8).toString('hex')}`; + try { + let allowCredentials = []; + if (email) { + const normalizedEmail = email.toLowerCase().trim(); + const userResult = await pool.query( + `SELECT u.user_id FROM users u WHERE u.email = $1`, + [normalizedEmail] + ); + if (userResult.rows.length > 0) { + const userId = userResult.rows[0].user_id; + const creds = await pool.query( + `SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $1`, + [userId] + ); + allowCredentials = creds.rows.map(row => ({ + id: row.credential_id, + transports: row.transports || [] + })); + } + } + + const options = await webauthn.generateAuthenticationOptions({ + rpID: WEBAUTHN_RP_ID, + allowCredentials, + userVerification: 'preferred' + }); + + webauthnChallenges.set(challengeKey, { + challenge: options.challenge, + email: email ? email.toLowerCase().trim() : null, + expiresAt: Date.now() + 5 * 60 * 1000 + }); + + res.json({ ...options, _challengeKey: challengeKey }); + } catch (e) { + console.error('[Auth] Passkey login begin error:', e); + res.status(500).json({ error: 'パスキーログインの開始に失敗しました' }); + } + }); + + // POST /api/auth/passkey/login/finish + r.post('/auth/passkey/login/finish', async (req, res) => { + if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); + const { _challengeKey, ...assertionResponse } = req.body; + if (!_challengeKey) return res.status(400).json({ error: 'challengeKey が必要です' }); + const challengeEntry = webauthnChallenges.get(_challengeKey); + if (!challengeEntry || challengeEntry.expiresAt < Date.now()) { + return res.status(400).json({ error: 'ログインセッションが期限切れです。最初からやり直してください' }); + } + try { + // Find credential in DB + const credResult = await pool.query( + `SELECT c.*, c.user_id FROM passkey_credentials c WHERE c.credential_id = $1`, + [assertionResponse.id] + ); + if (credResult.rows.length === 0) { + return res.status(401).json({ error: 'パスキーが見つかりません' }); + } + const cred = credResult.rows[0]; + + const verification = await webauthn.verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: challengeEntry.challenge, + expectedOrigin: WEBAUTHN_ORIGINS, + expectedRPID: WEBAUTHN_RP_ID, + credential: { + id: cred.credential_id, + publicKey: new Uint8Array(cred.public_key), + counter: Number(cred.counter), + transports: cred.transports || [] + } + }); + + if (!verification.verified) { + return res.status(401).json({ error: 'パスキーの検証に失敗しました' }); + } + + webauthnChallenges.delete(_challengeKey); + + // Update counter and last_used_at + await pool.query( + `UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`, + [verification.authenticationInfo.newCounter, cred.credential_id] + ); + + const sessionToken = await createSessionJWT(cred.user_id); + res.json({ ok: true, token: sessionToken, userId: cred.user_id }); + } catch (e) { + console.error('[Auth] Passkey login finish error:', e); + res.status(500).json({ error: 'パスキーログインに失敗しました' }); + } + }); + + // GET /api/auth/passkeys — list user's registered passkeys + r.get('/auth/passkeys', authMiddleware, async (req, res) => { + try { + const result = await pool.query( + `SELECT id, credential_id, device_type, transports, display_name, created_at, last_used_at + FROM passkey_credentials WHERE user_id = $1 ORDER BY created_at DESC`, + [req.userId] + ); + res.json({ passkeys: result.rows }); + } catch (e) { + console.error('[Auth] List passkeys error:', e); + res.status(500).json({ error: 'サーバーエラーが発生しました' }); + } + }); + + // DELETE /api/auth/passkeys/:id — remove a passkey + r.delete('/auth/passkeys/:id', authMiddleware, async (req, res) => { + try { + const result = await pool.query( + `DELETE FROM passkey_credentials WHERE id = $1 AND user_id = $2 RETURNING id`, + [req.params.id, req.userId] + ); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'パスキーが見つかりません' }); + } + res.json({ ok: true }); + } catch (e) { + console.error('[Auth] Delete passkey error:', e); + res.status(500).json({ error: 'サーバーエラーが発生しました' }); + } + }); + // 記事一覧取得 r.get('/articles', authMiddleware, async (req, res) => { const { status, topic, source, q } = req.query; @@ -1747,18 +2267,20 @@ app.use('/api', router); // ローカル直接アクセス // ── 起動 ────────────────────────────────── const PORT = parseInt(process.env.PORT || '8090'); -initDB() +loadWebauthn() + .then(() => initDB()) .then(() => { app.listen(PORT, '0.0.0.0', () => { console.log(`\nPosimai Brain API`); console.log(` Port: ${PORT}`); console.log(` Gemini: ${genAI ? 'enabled' : 'disabled (no key)'}`); + console.log(` WebAuthn: rpID=${WEBAUTHN_RP_ID}`); console.log(` Users: ${Object.values(KEY_MAP).join(', ') || '(none - set API_KEYS)'}`); console.log(` Local: http://localhost:${PORT}/api/health`); console.log(` Public: https://posimai.soar-enrich.com/brain/api/health`); }); }) .catch(err => { - console.error('[FATAL] DB init failed:', err.message); + console.error('[FATAL] Startup failed:', err.message); process.exit(1); });