diff --git a/.env.example b/.env.example index 178c22c..ff03510 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,14 @@ ADMIN_EMAIL=admin@site.com ADMIN_PASSWORD=admin ADMIN_NAME=Администратор -# URL сайта (ссылки в письмах) +# URL сайта (ссылки в письмах, WebAuthn origin) SITE_URL=http://localhost:3000 +# Passkey (WebAuthn) — по умолчанию hostname из SITE_URL +# WEBAUTHN_RP_ID=shop.example.com +# WEBAUTHN_RP_NAME=Shop +# WEBAUTHN_ORIGIN=https://shop.example.com,http://localhost:3000 + # SMTP — сброс пароля и уведомления о брони SMTP_HOST=smtp.example.com SMTP_PORT=587 diff --git a/README.md b/README.md index a728c92..9ab9c92 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Каталог товаров с категориями и поиском - Корзина и оформление заказа -- Регистрация, вход, сброс пароля по email +- Регистрация, вход (пароль или passkey), сброс пароля по email - Личный кабинет: профиль, бронирования - Роли клиент / администратор, админ-панель - Согласие на cookies diff --git a/package.json b/package.json index 6fcd634..b4c1e4e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "express": "^4.21.2", "express-session": "^1.18.1", "nodemailer": "^6.9.16", - "pg": "^8.13.1" + "pg": "^8.13.1", + "@simplewebauthn/server": "^13.1.1" } } diff --git a/postgres/init/04_webauthn.sql b/postgres/init/04_webauthn.sql new file mode 100644 index 0000000..7a19f51 --- /dev/null +++ b/postgres/init/04_webauthn.sql @@ -0,0 +1,17 @@ +-- Passkey (WebAuthn) — опциональный вход вместо пароля +ALTER TABLE users ADD COLUMN IF NOT EXISTS passkey_enabled BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + counter BIGINT NOT NULL DEFAULT 0, + device_type VARCHAR(32), + backed_up BOOLEAN NOT NULL DEFAULT false, + transports TEXT, + label TEXT NOT NULL DEFAULT 'Passkey', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_id ON webauthn_credentials(user_id); diff --git a/src/public/css/style.css b/src/public/css/style.css index b8f0386..e7fe33f 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -392,7 +392,9 @@ a:hover { .auth { display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + gap: 1.25rem; padding: 2rem 0; } @@ -402,6 +404,52 @@ a:hover { padding: 1.75rem; } +.passkey-login { + width: 100%; + max-width: 400px; + padding: 1.25rem 1.75rem; +} + +.passkey-login__title { + margin: 0 0 0.5rem; + font-size: 1rem; +} + +.passkey-login__hint { + margin: 0 0 1rem; + font-size: 0.9rem; +} + +.passkey-list { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; +} + +.passkey-list__item { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--border); +} + +.passkey-list__item:last-child { + border-bottom: none; +} + +.passkey-actions { + margin-top: 1rem; +} + +.divider { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + .alert { padding: 0.75rem 1rem; border-radius: 8px; @@ -585,6 +633,25 @@ a:hover { font-weight: 600; } +.btn--admin { + background: rgba(253, 203, 110, 0.15); + border: 1px solid var(--warn); + color: var(--warn); +} + +.btn--admin:hover { + background: rgba(253, 203, 110, 0.28); + color: #fff; + text-decoration: none; +} + +.account-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.25rem; +} + .admin-header { margin-bottom: 1.5rem; } diff --git a/src/public/js/passkey.js b/src/public/js/passkey.js new file mode 100644 index 0000000..266b9da --- /dev/null +++ b/src/public/js/passkey.js @@ -0,0 +1,112 @@ +/** + * WebAuthn (passkey) — требуется современный браузер с parseCreationOptionsFromJSON / toJSON. + */ +(function () { + function supportsPasskey() { + return ( + window.PublicKeyCredential && + typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && + typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function' + ); + } + + function showError(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = false; + } + + function hideError(el) { + if (el) el.hidden = true; + } + + async function postJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || 'Ошибка запроса'); + } + return data; + } + + async function registerPasskey(passwordInput, errorEl, btn) { + if (!supportsPasskey()) { + showError(errorEl, 'Браузер не поддерживает passkey. Обновите браузер или используйте Chrome, Edge, Safari.'); + return; + } + const password = passwordInput?.value; + if (!password) { + showError(errorEl, 'Введите текущий пароль для подтверждения'); + return; + } + + hideError(errorEl); + if (btn) btn.disabled = true; + + try { + const options = await postJson('/webauthn/register/options', { + current_password: password, + }); + + const credential = await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options), + }); + + const result = await postJson('/webauthn/register/verify', credential.toJSON()); + if (result.redirect) { + window.location.href = result.redirect; + } else { + window.location.reload(); + } + } catch (err) { + showError(errorEl, err.message || 'Не удалось привязать passkey'); + } finally { + if (btn) btn.disabled = false; + } + } + + async function loginWithPasskey(emailInput, nextInput, errorEl, btn) { + if (!supportsPasskey()) { + showError(errorEl, 'Браузер не поддерживает passkey'); + return; + } + const email = (emailInput?.value || '').trim(); + if (!email) { + showError(errorEl, 'Сначала укажите email'); + emailInput?.focus(); + return; + } + + hideError(errorEl); + if (btn) btn.disabled = true; + + try { + const options = await postJson('/webauthn/login/options', { email }); + const credential = await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options), + }); + + const result = await postJson('/webauthn/login/verify', { + ...credential.toJSON(), + next: nextInput?.value || '/', + }); + + window.location.href = result.redirect || '/'; + } catch (err) { + showError(errorEl, err.message || 'Не удалось войти по passkey'); + } finally { + if (btn) btn.disabled = false; + } + } + + window.ShopPasskey = { + supportsPasskey, + registerPasskey, + loginWithPasskey, + }; +})(); diff --git a/src/routes/account.js b/src/routes/account.js index f375bd1..d205d3b 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -4,9 +4,10 @@ const { query, formatPrice } = require('../db'); const { getCart, cartCount } = require('../cart'); const { requireAuth } = require('../middleware/auth'); const { requireCookieConsent } = require('../middleware/cookieConsent'); -const { ROLE_LABELS } = require('../constants/roles'); +const { ROLES, ROLE_LABELS } = require('../constants/roles'); const { asyncHandler } = require('../utils/asyncHandler'); const { expireOldReservations } = require('../services/reservations'); +const webauthn = require('../services/webauthn'); const router = express.Router(); @@ -21,7 +22,7 @@ router.use((req, res, next) => { async function loadAccountUser(userId) { const { rows } = await query( - 'SELECT id, email, name, role, created_at FROM users WHERE id = $1', + 'SELECT id, email, name, role, created_at, passkey_enabled FROM users WHERE id = $1', [userId] ); return rows[0]; @@ -44,12 +45,16 @@ function accountRender(res, options) { success, activeTab, formatPrice, + passkeys, + isAdmin, } = options; res.render('account/index', { title: 'Личный кабинет', user, orderCount, reservations: reservations || [], + passkeys: passkeys || [], + isAdmin: Boolean(isAdmin), roleLabels: ROLE_LABELS, formatPrice: formatPrice || res.locals.formatPrice, error: error || null, @@ -78,10 +83,14 @@ router.get( [user.id] ); + const passkeys = await webauthn.getCredentialsForUser(user.id); + accountRender(res, { user, orderCount: countResult.rows[0].n, reservations, + passkeys, + isAdmin: user.role === ROLES.ADMIN, formatPrice, success: req.query.success ? decodeURIComponent(String(req.query.success)) : null, error: req.query.error ? decodeURIComponent(String(req.query.error)) : null, @@ -187,4 +196,43 @@ router.post( }) ); +router.post( + '/passkey/disable', + requireAuth, + asyncHandler(async (req, res) => { + const { current_password } = req.body; + if (!(await verifyPassword(req.session.userId, current_password))) { + return res.redirect( + '/account?tab=passkey&error=' + encodeURIComponent('Неверный пароль') + ); + } + await webauthn.disablePasskeys(req.session.userId); + res.redirect( + '/account?tab=passkey&success=' + encodeURIComponent('Вход по passkey отключён') + ); + }) +); + +router.post( + '/passkey/credentials/:id/delete', + requireAuth, + asyncHandler(async (req, res) => { + const { current_password } = req.body; + if (!(await verifyPassword(req.session.userId, current_password))) { + return res.redirect( + '/account?tab=passkey&error=' + encodeURIComponent('Неверный пароль') + ); + } + const credId = parseInt(req.params.id, 10); + if (!Number.isFinite(credId)) { + return res.redirect('/account?tab=passkey&error=' + encodeURIComponent('Некорректный ключ')); + } + const ok = await webauthn.deleteCredential(req.session.userId, credId); + if (!ok) { + return res.redirect('/account?tab=passkey&error=' + encodeURIComponent('Ключ не найден')); + } + res.redirect('/account?tab=passkey&success=' + encodeURIComponent('Passkey удалён')); + }) +); + module.exports = router; diff --git a/src/routes/passkey.js b/src/routes/passkey.js new file mode 100644 index 0000000..b2b6502 --- /dev/null +++ b/src/routes/passkey.js @@ -0,0 +1,164 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { query, formatPrice } = require('../db'); +const { getCart, cartCount } = require('../cart'); +const { requireAuth } = require('../middleware/auth'); +const { requireCookieConsent } = require('../middleware/cookieConsent'); +const { ROLES } = require('../constants/roles'); +const { asyncHandler } = require('../utils/asyncHandler'); +const webauthn = require('../services/webauthn'); + +const router = express.Router(); + +router.use((req, res, next) => { + const cart = getCart(req); + res.locals.cartCount = cartCount(cart); + res.locals.formatPrice = formatPrice; + next(); +}); + +async function verifyPasswordById(userId, password) { + const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [ + userId, + ]); + if (!rows[0]) return false; + return bcrypt.compareSync(password || '', rows[0].password_hash); +} + +async function loadUserById(userId) { + const { rows } = await query( + 'SELECT id, email, name, role, passkey_enabled FROM users WHERE id = $1', + [userId] + ); + return rows[0]; +} + +function saveChallenge(req, challenge, extra = {}) { + req.session.webauthnChallenge = challenge; + Object.assign(req.session, extra); +} + +function clearChallenge(req) { + delete req.session.webauthnChallenge; + delete req.session.webauthnLoginUserId; +} + +function adminRedirect(user, next) { + if (user.role === ROLES.ADMIN && (next === '/' || next === '/account')) { + return '/admin'; + } + return next.startsWith('/') ? next : '/'; +} + +// --- Регистрация passkey (в профиле, нужен вход) --- + +router.post( + '/register/options', + requireCookieConsent, + requireAuth, + asyncHandler(async (req, res) => { + const { current_password } = req.body || {}; + if (!(await verifyPasswordById(req.session.userId, current_password))) { + return res.status(401).json({ error: 'Неверный пароль' }); + } + + const user = await loadUserById(req.session.userId); + if (!user) return res.status(404).json({ error: 'Пользователь не найден' }); + + webauthn.assertOrigin(req); + const options = await webauthn.generateRegisterOptions(user); + saveChallenge(req, options.challenge); + res.json(options); + }) +); + +router.post( + '/register/verify', + requireCookieConsent, + requireAuth, + asyncHandler(async (req, res) => { + const expectedChallenge = req.session.webauthnChallenge; + if (!expectedChallenge) { + return res.status(400).json({ error: 'Сессия истекла, повторите привязку' }); + } + + const user = await loadUserById(req.session.userId); + if (!user) return res.status(404).json({ error: 'Пользователь не найден' }); + + const origin = webauthn.assertOrigin(req); + const result = await webauthn.verifyRegister( + user, + req.body, + expectedChallenge, + origin + ); + clearChallenge(req); + + if (!result.verified) { + return res.status(400).json({ error: 'Не удалось подтвердить passkey' }); + } + + res.json({ ok: true, redirect: '/account?tab=passkey&success=' + encodeURIComponent('Passkey привязан') }); + }) +); + +// --- Вход по passkey --- + +router.post( + '/login/options', + requireCookieConsent, + asyncHandler(async (req, res) => { + const email = (req.body?.email || '').trim().toLowerCase(); + if (!email) { + return res.status(400).json({ error: 'Укажите email' }); + } + + const { user, options } = await webauthn.generateLoginOptions(email); + if (!user || !options) { + return res.status(404).json({ + error: 'Passkey не настроен для этого аккаунта', + }); + } + + webauthn.assertOrigin(req); + saveChallenge(req, options.challenge, { webauthnLoginUserId: user.id }); + res.json(options); + }) +); + +router.post( + '/login/verify', + requireCookieConsent, + asyncHandler(async (req, res) => { + const expectedChallenge = req.session.webauthnChallenge; + const userId = req.session.webauthnLoginUserId; + if (!expectedChallenge || !userId) { + return res.status(400).json({ error: 'Сессия истекла, начните вход заново' }); + } + + const user = await loadUserById(userId); + if (!user || !user.passkey_enabled) { + clearChallenge(req); + return res.status(400).json({ error: 'Вход по passkey недоступен' }); + } + + const origin = webauthn.assertOrigin(req); + const result = await webauthn.verifyLogin( + user, + req.body, + expectedChallenge, + origin + ); + clearChallenge(req); + + if (!result.verified) { + return res.status(401).json({ error: 'Не удалось войти по passkey' }); + } + + req.session.userId = user.id; + const next = req.body?.next || '/'; + res.json({ ok: true, redirect: adminRedirect(user, next) }); + }) +); + +module.exports = router; diff --git a/src/server.js b/src/server.js index f95ed52..a27bdeb 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,7 @@ const adminRoutes = require('./routes/admin'); const cookiesRoutes = require('./routes/cookies'); const passwordResetRoutes = require('./routes/password-reset'); const reservationsRoutes = require('./routes/reservations'); +const passkeyRoutes = require('./routes/passkey'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -40,6 +41,7 @@ async function start() { app.use(healthRoutes); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true })); + app.use(express.json({ limit: '64kb' })); app.use(cookieParser()); app.use( @@ -68,6 +70,7 @@ async function start() { app.use('/reservations', reservationsRoutes); app.use('/', shopRoutes); app.use('/', authRoutes); + app.use('/webauthn', passkeyRoutes); app.use('/account', accountRoutes); app.use('/admin', adminRoutes); diff --git a/src/services/webauthn.js b/src/services/webauthn.js new file mode 100644 index 0000000..c1dd551 --- /dev/null +++ b/src/services/webauthn.js @@ -0,0 +1,242 @@ +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, +}; diff --git a/src/views/account/index.ejs b/src/views/account/index.ejs index 8116081..402bfb8 100644 --- a/src/views/account/index.ejs +++ b/src/views/account/index.ejs @@ -10,6 +10,7 @@ Профиль Смена email Смена пароля + Passkey Бронирования @@ -29,7 +30,12 @@
+ Passkey — вход по отпечатку, Face ID или PIN устройства. Пароль остаётся доступен. + Включение необязательно: привяжите ключ, когда будете готовы. +
+ + <% if (user.passkey_enabled) { %> +Passkey включён
+ <% } else { %> +Passkey не настроен
+ <% } %> + + <% if (passkeys.length) { %> +Все привязанные ключи будут удалены. Вход только по паролю.
+ + <% } %> +Если в профиле включён passkey — войдите без пароля (нужен тот же email).
+ + +