feat: passkey в профиле и входе, кнопка админки в кабинете
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+50
-2
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user