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)
|
# URL сайта (ссылки в письмах, WebAuthn origin)
|
||||||
SITE_URL=http://localhost:3000
|
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
|
# Passkey (WebAuthn) — по умолчанию hostname из SITE_URL
|
||||||
# WEBAUTHN_RP_ID=shop.example.com
|
# WEBAUTHN_RP_ID=shop.example.com
|
||||||
# WEBAUTHN_RP_NAME=Shop
|
# 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;
|
margin-top: 1.25rem;
|
||||||
font-size: 0.9rem;
|
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 { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||||
const { ROLES } = require('../constants/roles');
|
const { ROLES } = require('../constants/roles');
|
||||||
const { asyncHandler } = require('../utils/asyncHandler');
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
const { verifyCaptcha } = require('../services/captcha');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -28,6 +29,15 @@ router.post(
|
|||||||
const { name, email, password, password2 } = req.body;
|
const { name, email, password, password2 } = req.body;
|
||||||
const values = { name, email };
|
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) {
|
if (!name?.trim() || !email?.trim() || !password) {
|
||||||
return res.status(400).render('register', {
|
return res.status(400).render('register', {
|
||||||
title: 'Регистрация',
|
title: 'Регистрация',
|
||||||
@@ -90,6 +100,16 @@ router.post(
|
|||||||
const next = req.body.next || '/';
|
const next = req.body.next || '/';
|
||||||
const values = { email };
|
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', [
|
const { rows } = await query('SELECT * FROM users WHERE email = $1', [
|
||||||
(email || '').trim().toLowerCase(),
|
(email || '').trim().toLowerCase(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { getCart, cartCount } = require('../cart');
|
|||||||
const { formatPrice } = require('../db');
|
const { formatPrice } = require('../db');
|
||||||
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||||
const { asyncHandler } = require('../utils/asyncHandler');
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
const { verifyCaptcha } = require('../services/captcha');
|
||||||
const { sendPasswordResetEmail, siteUrl } = require('../services/mail');
|
const { sendPasswordResetEmail, siteUrl } = require('../services/mail');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -39,6 +40,16 @@ router.post(
|
|||||||
const genericSuccess =
|
const genericSuccess =
|
||||||
'Если аккаунт с таким email существует, мы отправили ссылку для сброса пароля.';
|
'Если аккаунт с таким 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) {
|
if (!email) {
|
||||||
return res.status(400).render('auth/forgot-password', {
|
return res.status(400).render('auth/forgot-password', {
|
||||||
title: 'Сброс пароля',
|
title: 'Сброс пароля',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { seedAdmin } = require('./seed-admin');
|
|||||||
const { seedPromoCodes } = require('./seed-promo');
|
const { seedPromoCodes } = require('./seed-promo');
|
||||||
const { loadUser } = require('./middleware/auth');
|
const { loadUser } = require('./middleware/auth');
|
||||||
const { loadCookieConsent } = require('./middleware/cookieConsent');
|
const { loadCookieConsent } = require('./middleware/cookieConsent');
|
||||||
|
const { loadCaptchaLocals, rejectYandexCaptcha } = require('./middleware/captcha');
|
||||||
const healthRoutes = require('./routes/health');
|
const healthRoutes = require('./routes/health');
|
||||||
const shopRoutes = require('./routes/shop');
|
const shopRoutes = require('./routes/shop');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
@@ -68,6 +69,8 @@ async function start() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.use(loadCookieConsent);
|
app.use(loadCookieConsent);
|
||||||
|
app.use(loadCaptchaLocals);
|
||||||
|
app.use(rejectYandexCaptcha);
|
||||||
app.use(loadUser);
|
app.use(loadUser);
|
||||||
app.use('/cookies', cookiesRoutes);
|
app.use('/cookies', cookiesRoutes);
|
||||||
app.use('/', passwordResetRoutes);
|
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
|
Email
|
||||||
<input type="email" name="email" class="input" required value="<%= values.email || '' %>" autocomplete="email">
|
<input type="email" name="email" class="input" required value="<%= values.email || '' %>" autocomplete="email">
|
||||||
</label>
|
</label>
|
||||||
|
<%- include('../partials/captcha-widget') %>
|
||||||
<button type="submit" class="btn btn--primary btn--block">Отправить ссылку</button>
|
<button type="submit" class="btn btn--primary btn--block">Отправить ссылку</button>
|
||||||
<p class="form-footer"><a href="/login">← Вход</a></p>
|
<p class="form-footer"><a href="/login">← Вход</a></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
Пароль
|
Пароль
|
||||||
<input type="password" name="password" class="input" required autocomplete="current-password">
|
<input type="password" name="password" class="input" required autocomplete="current-password">
|
||||||
</label>
|
</label>
|
||||||
|
<%- include('partials/captcha-widget') %>
|
||||||
<button type="submit" class="btn btn--primary btn--block">Войти по паролю</button>
|
<button type="submit" class="btn btn--primary btn--block">Войти по паролю</button>
|
||||||
<p class="form-footer">
|
<p class="form-footer">
|
||||||
<a href="/forgot-password">Забыли пароль?</a><br>
|
<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>
|
<input type="password" name="password2" class="input" required>
|
||||||
</label>
|
</label>
|
||||||
|
<%- include('partials/captcha-widget') %>
|
||||||
<button type="submit" class="btn btn--primary btn--block">Создать аккаунт</button>
|
<button type="submit" class="btn btn--primary btn--block">Создать аккаунт</button>
|
||||||
<p class="form-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
<p class="form-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user