const crypto = require('crypto'); const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const { isoBase64URL } = require('@simplewebauthn/server/helpers'); const { query } = require('../db'); function getRpId() { if (process.env.WEBAUTHN_RP_ID) { return process.env.WEBAUTHN_RP_ID.trim(); } const site = process.env.SITE_URL || 'http://localhost:3000'; try { return new URL(site).hostname; } catch { return 'localhost'; } } function getRpName() { return process.env.WEBAUTHN_RP_NAME || 'Shop'; } function getOrigins() { const list = []; if (process.env.SITE_URL) list.push(process.env.SITE_URL.replace(/\/$/, '')); if (process.env.WEBAUTHN_ORIGIN) { process.env.WEBAUTHN_ORIGIN.split(',').forEach((o) => { const t = o.trim().replace(/\/$/, ''); if (t) list.push(t); }); } if (!list.length) list.push('http://localhost:3000'); const expanded = [...list]; for (const o of list) { if (o.includes('localhost')) { expanded.push(o.replace('localhost', '127.0.0.1')); } } return [...new Set(expanded)]; } function getOriginFromRequest(req) { const proto = req.get('x-forwarded-proto') || req.protocol; const host = req.get('x-forwarded-host') || req.get('host'); return `${proto}://${host}`.replace(/\/$/, ''); } function assertOrigin(req) { const origin = getOriginFromRequest(req); const allowed = getOrigins(); if (!allowed.includes(origin)) { const err = new Error('Недопустимый origin для WebAuthn'); err.status = 400; throw err; } return origin; } function userIdToBuffer(userId) { const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(userId), 0); return new Uint8Array(buf); } async function getCredentialsForUser(userId) { const { rows } = await query( `SELECT id, credential_id, public_key, counter, device_type, backed_up, transports, label, created_at FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at`, [userId] ); return rows; } function rowToAuthenticator(row) { return { id: row.credential_id, publicKey: row.public_key, counter: Number(row.counter), transports: row.transports ? row.transports.split(',') : undefined, }; } async function generateRegisterOptions(user, excludeIds = []) { const credentials = await getCredentialsForUser(user.id); return generateRegistrationOptions({ rpName: getRpName(), rpID: getRpId(), userName: user.email, userDisplayName: user.name, userID: userIdToBuffer(user.id), attestationType: 'none', excludeCredentials: credentials.map((c) => ({ id: c.credential_id, transports: c.transports ? c.transports.split(',') : undefined, })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, }); } async function verifyRegister(user, response, expectedChallenge, expectedOrigin) { const verification = await verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin, expectedRPID: getRpId(), requireUserVerification: false, }); if (!verification.verified || !verification.registrationInfo) { return { verified: false }; } const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; const credentialId = typeof credential.id === 'string' ? credential.id : isoBase64URL.fromBuffer(credential.id); const label = credentialDeviceType === 'singleDevice' ? 'Это устройство' : 'Passkey'; await query( `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, counter, device_type, backed_up, transports, label) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ user.id, credentialId, Buffer.from(credential.publicKey), credential.counter, credentialDeviceType, credentialBackedUp, credential.transports?.join(',') || null, label, ] ); await query('UPDATE users SET passkey_enabled = true WHERE id = $1', [user.id]); return { verified: true }; } async function generateLoginOptions(email) { const { rows } = await query( `SELECT id, email, name, passkey_enabled FROM users WHERE email = $1`, [email.trim().toLowerCase()] ); const user = rows[0]; if (!user || !user.passkey_enabled) { return { user: null, options: null }; } const credentials = await getCredentialsForUser(user.id); if (!credentials.length) { return { user: null, options: null }; } const options = await generateAuthenticationOptions({ rpID: getRpId(), allowCredentials: credentials.map((c) => ({ id: c.credential_id, transports: c.transports ? c.transports.split(',') : undefined, })), userVerification: 'preferred', }); return { user, options }; } async function verifyLogin(user, response, expectedChallenge, expectedOrigin) { const credentialId = response.id; const { rows } = await query( `SELECT * FROM webauthn_credentials WHERE user_id = $1 AND credential_id = $2`, [user.id, credentialId] ); const row = rows[0]; if (!row) { return { verified: false }; } const verification = await verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin, expectedRPID: getRpId(), credential: rowToAuthenticator(row), requireUserVerification: false, }); if (!verification.verified) { return { verified: false }; } const { newCounter } = verification.authenticationInfo; await query('UPDATE webauthn_credentials SET counter = $1 WHERE id = $2', [ newCounter, row.id, ]); return { verified: true }; } async function disablePasskeys(userId) { await query('DELETE FROM webauthn_credentials WHERE user_id = $1', [userId]); await query('UPDATE users SET passkey_enabled = false WHERE id = $1', [userId]); } async function deleteCredential(userId, credentialDbId) { const { rowCount } = await query( 'DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2', [credentialDbId, userId] ); const remaining = await query( 'SELECT COUNT(*)::int AS n FROM webauthn_credentials WHERE user_id = $1', [userId] ); if (remaining.rows[0].n === 0) { await query('UPDATE users SET passkey_enabled = false WHERE id = $1', [userId]); } return rowCount > 0; } module.exports = { getRpId, getOrigins, assertOrigin, getCredentialsForUser, generateRegisterOptions, verifyRegister, generateLoginOptions, verifyLogin, disablePasskeys, deleteCredential, };