diff --git a/.env.example b/.env.example index 233d707..9c91138 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,14 @@ ADMIN_NAME=Администратор # URL сайта (ссылки в письмах, WebAuthn origin) SITE_URL=http://localhost:3000 +# Капча: google (reCAPTCHA) или cloudflare (Turnstile). yandex — заблокирован +CAPTCHA_PROVIDER=google +# CAPTCHA_ENABLED=0 +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= +# TURNSTILE_SITE_KEY= +# TURNSTILE_SECRET_KEY= + # Passkey (WebAuthn) — по умолчанию hostname из SITE_URL # WEBAUTHN_RP_ID=shop.example.com # WEBAUTHN_RP_NAME=Shop diff --git a/src/middleware/captcha.js b/src/middleware/captcha.js new file mode 100644 index 0000000..8624d50 --- /dev/null +++ b/src/middleware/captcha.js @@ -0,0 +1,25 @@ +const { + getCaptchaConfig, + YANDEX_BLOCKED_MSG, + isYandexCaptchaAttempt, +} = require('../services/captcha'); + +function loadCaptchaLocals(req, res, next) { + res.locals.captcha = getCaptchaConfig(); + res.locals.yandexCaptchaBlockedMsg = YANDEX_BLOCKED_MSG; + next(); +} + +/** Блокировка попыток отправить Яндекс-капчу */ +function rejectYandexCaptcha(req, res, next) { + if (req.method === 'POST' && isYandexCaptchaAttempt(req)) { + return res.status(403).render('error', { + title: 'Доступ запрещён', + message: YANDEX_BLOCKED_MSG, + code: 403, + }); + } + next(); +} + +module.exports = { loadCaptchaLocals, rejectYandexCaptcha }; diff --git a/src/public/css/style.css b/src/public/css/style.css index a9f51b8..e2c8b40 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -1307,3 +1307,32 @@ body:has(.cookie-banner) .main { margin-top: 1.25rem; font-size: 0.9rem; } + +.captcha-block { + margin: 1rem 0; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2); +} + +.captcha-block__yandex-notice { + display: flex; + align-items: flex-start; + gap: 0.4rem; + margin: 0 0 0.75rem; + font-size: 0.8rem; + color: var(--warn); + line-height: 1.4; +} + +.captcha-widget { + min-height: 78px; + display: flex; + align-items: center; +} + +.captcha-block__provider { + margin: 0.5rem 0 0; + font-size: 0.75rem; +} diff --git a/src/routes/auth.js b/src/routes/auth.js index 4d7adc6..0d4f553 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -6,6 +6,7 @@ const { requireAuth } = require('../middleware/auth'); const { requireCookieConsent } = require('../middleware/cookieConsent'); const { ROLES } = require('../constants/roles'); const { asyncHandler } = require('../utils/asyncHandler'); +const { verifyCaptcha } = require('../services/captcha'); const router = express.Router(); @@ -28,6 +29,15 @@ router.post( const { name, email, password, password2 } = req.body; const values = { name, email }; + const captchaCheck = await verifyCaptcha(req); + if (!captchaCheck.ok) { + return res.status(400).render('register', { + title: 'Регистрация', + error: captchaCheck.error, + values, + }); + } + if (!name?.trim() || !email?.trim() || !password) { return res.status(400).render('register', { title: 'Регистрация', @@ -90,6 +100,16 @@ router.post( const next = req.body.next || '/'; const values = { email }; + const captchaCheck = await verifyCaptcha(req); + if (!captchaCheck.ok) { + return res.status(400).render('login', { + title: 'Вход', + error: captchaCheck.error, + next, + values, + }); + } + const { rows } = await query('SELECT * FROM users WHERE email = $1', [ (email || '').trim().toLowerCase(), ]); diff --git a/src/routes/password-reset.js b/src/routes/password-reset.js index ded58de..c4e20a6 100644 --- a/src/routes/password-reset.js +++ b/src/routes/password-reset.js @@ -6,6 +6,7 @@ const { getCart, cartCount } = require('../cart'); const { formatPrice } = require('../db'); const { requireCookieConsent } = require('../middleware/cookieConsent'); const { asyncHandler } = require('../utils/asyncHandler'); +const { verifyCaptcha } = require('../services/captcha'); const { sendPasswordResetEmail, siteUrl } = require('../services/mail'); const router = express.Router(); @@ -39,6 +40,16 @@ router.post( const genericSuccess = 'Если аккаунт с таким email существует, мы отправили ссылку для сброса пароля.'; + const captchaCheck = await verifyCaptcha(req); + if (!captchaCheck.ok) { + return res.status(400).render('auth/forgot-password', { + title: 'Сброс пароля', + error: captchaCheck.error, + success: null, + values, + }); + } + if (!email) { return res.status(400).render('auth/forgot-password', { title: 'Сброс пароля', diff --git a/src/server.js b/src/server.js index db35875..14bd084 100644 --- a/src/server.js +++ b/src/server.js @@ -10,6 +10,7 @@ const { seedAdmin } = require('./seed-admin'); const { seedPromoCodes } = require('./seed-promo'); const { loadUser } = require('./middleware/auth'); const { loadCookieConsent } = require('./middleware/cookieConsent'); +const { loadCaptchaLocals, rejectYandexCaptcha } = require('./middleware/captcha'); const healthRoutes = require('./routes/health'); const shopRoutes = require('./routes/shop'); const authRoutes = require('./routes/auth'); @@ -68,6 +69,8 @@ async function start() { ); app.use(loadCookieConsent); + app.use(loadCaptchaLocals); + app.use(rejectYandexCaptcha); app.use(loadUser); app.use('/cookies', cookiesRoutes); app.use('/', passwordResetRoutes); diff --git a/src/services/captcha.js b/src/services/captcha.js new file mode 100644 index 0000000..6344098 --- /dev/null +++ b/src/services/captcha.js @@ -0,0 +1,129 @@ +const YANDEX_BLOCKED_MSG = + 'Яндекс SmartCaptcha (японский сервис) заблокирован администратором сайта. Используйте проверку Google или Cloudflare.'; + +function clientIp(req) { + return ( + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.socket?.remoteAddress || + '' + ); +} + +function isYandexCaptchaAttempt(req) { + const b = req.body || {}; + return Boolean( + b['smart-token'] || + b.smartcaptcha || + b.yandex_captcha || + b['yandex-token'] || + (typeof b.captcha_provider === 'string' && b.captcha_provider.toLowerCase() === 'yandex') + ); +} + +function getCaptchaConfig() { + const raw = (process.env.CAPTCHA_PROVIDER || 'google').toLowerCase().trim(); + + if (raw === 'yandex' || raw === 'yandex-smartcaptcha') { + return { + enabled: true, + provider: 'yandex', + blocked: true, + siteKey: null, + }; + } + + if (process.env.CAPTCHA_ENABLED === '0') { + return { enabled: false, provider: null, blocked: false, siteKey: null }; + } + + if (raw === 'cloudflare' || raw === 'turnstile') { + const siteKey = process.env.TURNSTILE_SITE_KEY || ''; + const secret = process.env.TURNSTILE_SECRET_KEY || ''; + if (!siteKey || !secret) { + return { enabled: false, provider: 'cloudflare', blocked: false, siteKey: null }; + } + return { enabled: true, provider: 'cloudflare', blocked: false, siteKey }; + } + + const siteKey = process.env.RECAPTCHA_SITE_KEY || ''; + const secret = process.env.RECAPTCHA_SECRET_KEY || ''; + if (!siteKey || !secret) { + return { enabled: false, provider: 'google', blocked: false, siteKey: null }; + } + return { enabled: true, provider: 'google', blocked: false, siteKey }; +} + +async function verifyGoogle(token, secret, ip) { + const params = new URLSearchParams({ secret, response: token }); + if (ip) params.set('remoteip', ip); + const res = await fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + const data = await res.json(); + return Boolean(data.success); +} + +async function verifyTurnstile(token, secret, ip) { + const params = new URLSearchParams({ secret, response: token }); + if (ip) params.set('remoteip', ip); + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + const data = await res.json(); + return Boolean(data.success); +} + +async function verifyCaptcha(req) { + if (isYandexCaptchaAttempt(req)) { + return { ok: false, error: YANDEX_BLOCKED_MSG }; + } + + const config = getCaptchaConfig(); + + if (config.blocked) { + return { ok: false, error: YANDEX_BLOCKED_MSG }; + } + + if (!config.enabled) { + return { ok: true }; + } + + const ip = clientIp(req); + const secret = + config.provider === 'cloudflare' + ? process.env.TURNSTILE_SECRET_KEY + : process.env.RECAPTCHA_SECRET_KEY; + + const token = + config.provider === 'cloudflare' + ? req.body?.['cf-turnstile-response'] + : req.body?.['g-recaptcha-response']; + + if (!token) { + return { ok: false, error: 'Подтвердите, что вы не робот (капча)' }; + } + + let valid = false; + if (config.provider === 'cloudflare') { + valid = await verifyTurnstile(token, secret, ip); + } else { + valid = await verifyGoogle(token, secret, ip); + } + + if (!valid) { + return { ok: false, error: 'Проверка капчи не пройдена. Попробуйте снова.' }; + } + + return { ok: true }; +} + +module.exports = { + YANDEX_BLOCKED_MSG, + getCaptchaConfig, + verifyCaptcha, + isYandexCaptchaAttempt, +}; diff --git a/src/views/auth/forgot-password.ejs b/src/views/auth/forgot-password.ejs index 3b75eb2..80b4f53 100644 --- a/src/views/auth/forgot-password.ejs +++ b/src/views/auth/forgot-password.ejs @@ -10,6 +10,7 @@ Email + <%- include('../partials/captcha-widget') %> diff --git a/src/views/login.ejs b/src/views/login.ejs index b5b50b1..321b652 100644 --- a/src/views/login.ejs +++ b/src/views/login.ejs @@ -13,6 +13,7 @@ Пароль + <%- include('partials/captcha-widget') %> diff --git a/src/views/register.ejs b/src/views/register.ejs index b599e96..f99deef 100644 --- a/src/views/register.ejs +++ b/src/views/register.ejs @@ -20,6 +20,7 @@ Повторите пароль + <%- include('partials/captcha-widget') %>