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 <noreply@anthropic.com>
This commit is contained in:
parent
034ebf2c1e
commit
7454b0eda5
534
server.js
534
server.js
|
|
@ -19,6 +19,50 @@ const { parse } = require('node-html-parser');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
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();
|
const app = express();
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
@ -81,21 +125,37 @@ const KEY_MAP = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
function authMiddleware(req, res, next) {
|
function authMiddleware(req, res, next) {
|
||||||
let key = '';
|
let token = '';
|
||||||
|
|
||||||
// 1. ヘッダーからの取得
|
// 1. ヘッダーからの取得
|
||||||
const auth = req.headers.authorization || '';
|
const auth = req.headers.authorization || '';
|
||||||
if (auth.toLowerCase().startsWith('bearer ')) {
|
if (auth.toLowerCase().startsWith('bearer ')) {
|
||||||
key = auth.substring(7).trim();
|
token = auth.substring(7).trim();
|
||||||
}
|
}
|
||||||
// 2. クエリパラメータからの取得 (Bookmarklet等)
|
// 2. クエリパラメータからの取得 (Bookmarklet等)
|
||||||
else if (req.query.key) {
|
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キーが無効です' });
|
if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' });
|
||||||
req.userId = userId;
|
req.userId = userId;
|
||||||
|
req.authType = 'apikey';
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,6 +485,52 @@ async function initDB() {
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`,
|
`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) {
|
for (const sql of schema) {
|
||||||
await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message));
|
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 DROP CONSTRAINT IF EXISTS site_config_pkey`,
|
||||||
`ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`,
|
`ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`,
|
||||||
// together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理
|
// 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) {
|
for (const sql of migrations) {
|
||||||
await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message));
|
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 });
|
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 <hello@soar-enrich.com>',
|
||||||
|
to: [normalizedEmail],
|
||||||
|
subject: 'Posimai ログインリンク',
|
||||||
|
html: `<p>以下のリンクをクリックして Posimai にログインしてください。</p>
|
||||||
|
<p><a href="${magicLinkUrl}" style="font-size:16px;font-weight:bold;">${magicLinkUrl}</a></p>
|
||||||
|
<p>このリンクは15分間有効です。</p>
|
||||||
|
<p>このメールに心当たりがない場合は無視してください。</p>`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
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) => {
|
r.get('/articles', authMiddleware, async (req, res) => {
|
||||||
const { status, topic, source, q } = req.query;
|
const { status, topic, source, q } = req.query;
|
||||||
|
|
@ -1747,18 +2267,20 @@ app.use('/api', router); // ローカル直接アクセス
|
||||||
// ── 起動 ──────────────────────────────────
|
// ── 起動 ──────────────────────────────────
|
||||||
const PORT = parseInt(process.env.PORT || '8090');
|
const PORT = parseInt(process.env.PORT || '8090');
|
||||||
|
|
||||||
initDB()
|
loadWebauthn()
|
||||||
|
.then(() => initDB())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`\nPosimai Brain API`);
|
console.log(`\nPosimai Brain API`);
|
||||||
console.log(` Port: ${PORT}`);
|
console.log(` Port: ${PORT}`);
|
||||||
console.log(` Gemini: ${genAI ? 'enabled' : 'disabled (no key)'}`);
|
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(` Users: ${Object.values(KEY_MAP).join(', ') || '(none - set API_KEYS)'}`);
|
||||||
console.log(` Local: http://localhost:${PORT}/api/health`);
|
console.log(` Local: http://localhost:${PORT}/api/health`);
|
||||||
console.log(` Public: https://posimai.soar-enrich.com/brain/api/health`);
|
console.log(` Public: https://posimai.soar-enrich.com/brain/api/health`);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('[FATAL] DB init failed:', err.message);
|
console.error('[FATAL] Startup failed:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue