feat: капча Google/Cloudflare, блокировка Яндекс SmartCaptcha
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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: 'Сброс пароля',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user