feat: капча Google/Cloudflare, блокировка Яндекс SmartCaptcha

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 14:41:45 +03:00
parent f9f0446c12
commit 9025677fd8
11 changed files with 251 additions and 0 deletions
+8
View File
@@ -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
+25
View File
@@ -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 };
+29
View File
@@ -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;
}
+20
View File
@@ -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(),
]);
+11
View File
@@ -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: 'Сброс пароля',
+3
View File
@@ -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);
+129
View File
@@ -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,
};
+1
View File
@@ -10,6 +10,7 @@
Email
<input type="email" name="email" class="input" required value="<%= values.email || '' %>" autocomplete="email">
</label>
<%- include('../partials/captcha-widget') %>
<button type="submit" class="btn btn--primary btn--block">Отправить ссылку</button>
<p class="form-footer"><a href="/login">← Вход</a></p>
</form>
+1
View File
@@ -13,6 +13,7 @@
Пароль
<input type="password" name="password" class="input" required autocomplete="current-password">
</label>
<%- include('partials/captcha-widget') %>
<button type="submit" class="btn btn--primary btn--block">Войти по паролю</button>
<p class="form-footer">
<a href="/forgot-password">Забыли пароль?</a><br>
+23
View File
@@ -0,0 +1,23 @@
<aside class="captcha-block" aria-label="Защита от ботов">
<p class="captcha-block__yandex-notice">
<%- include('icon', { name: 'shield', iconSize: 14 }) %>
<%= yandexCaptchaBlockedMsg %>
</p>
<% if (captcha && captcha.blocked) { %>
<p class="alert alert--error">Капча недоступна: выбран заблокированный провайдер. В .env укажите <code>CAPTCHA_PROVIDER=google</code> или <code>cloudflare</code>.</p>
<% } else if (captcha && captcha.enabled) { %>
<div class="captcha-widget">
<% if (captcha.provider === 'cloudflare') { %>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="<%= captcha.siteKey %>" data-theme="dark"></div>
<% } else { %>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-sitekey="<%= captcha.siteKey %>" data-theme="dark"></div>
<% } %>
</div>
<p class="muted captcha-block__provider">
<% if (captcha.provider === 'cloudflare') { %>Проверка: Cloudflare Turnstile<% } else { %>Проверка: Google reCAPTCHA<% } %>
</p>
<% } %>
</aside>
+1
View File
@@ -20,6 +20,7 @@
Повторите пароль
<input type="password" name="password2" class="input" required>
</label>
<%- include('partials/captcha-widget') %>
<button type="submit" class="btn btn--primary btn--block">Создать аккаунт</button>
<p class="form-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
</form>