diff --git a/.env.example b/.env.example index 3953dc1..87b3a58 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,13 @@ ADMIN_EMAIL=admin@admin.com ADMIN_PASSWORD=admin123 JWT_SECRET=super_secret_jwt_key_123 +# --- CAPTCHA / Bot Protection (Optional) --- +# Create keys in Cloudflare Turnstile and set both values in production. +# When TURNSTILE_SECRET_KEY is empty, backend CAPTCHA enforcement is disabled. +TURNSTILE_SECRET_KEY= + # --- Frontend Configuration (Optional) --- # If you need to override the API URL for the frontend # VITE_API_URL=/api +# Cloudflare Turnstile site key shown on the login page +VITE_TURNSTILE_SITE_KEY= diff --git a/Dockerfile b/Dockerfile index 8dc6e64..4bd6b7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ # Build stage FROM node:20-alpine AS build WORKDIR /app +ARG VITE_TURNSTILE_SITE_KEY +ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY COPY package*.json ./ RUN npm install COPY . . @@ -11,4 +13,4 @@ FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index c75a711..ff00f06 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,14 @@ N8N_WHATSAPP_TRIGGER_URL ADMIN_EMAIL ADMIN_PASSWORD JWT_SECRET +TURNSTILE_SECRET_KEY +VITE_TURNSTILE_SITE_KEY ``` +`TURNSTILE_SECRET_KEY` enables backend CAPTCHA enforcement on `/api/login`. +Set `VITE_TURNSTILE_SITE_KEY` at frontend build time to show Cloudflare Turnstile +on the login page. + ## Validation ```bash diff --git a/backend/config.js b/backend/config.js index ab522b6..1742b54 100644 --- a/backend/config.js +++ b/backend/config.js @@ -7,5 +7,6 @@ module.exports = { ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123', JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123', DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb', - N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp' + N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp', + TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || '' }; diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 723a25b..d224b60 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,12 +1,49 @@ const express = require('express'); const { login } = require('../auth'); +const { TURNSTILE_SECRET_KEY } = require('../config'); const router = express.Router(); +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; -router.post('/login', async (req, res, next) => { - const { email, password } = req.body; +const verifyCaptcha = async (captchaToken, remoteIp) => { + if (!TURNSTILE_SECRET_KEY) return true; + if (!captchaToken || typeof captchaToken !== 'string') return false; try { + const formData = new URLSearchParams({ + secret: TURNSTILE_SECRET_KEY, + response: captchaToken + }); + + if (remoteIp) { + formData.set('remoteip', remoteIp); + } + + const response = await fetch(TURNSTILE_VERIFY_URL, { + method: 'POST', + body: formData + }); + + if (!response.ok) return false; + + const result = await response.json(); + return result.success === true; + } catch (error) { + console.error('Captcha verification failed', error); + return false; + } +}; + +router.post('/login', async (req, res, next) => { + const { email, password, captchaToken } = req.body; + + try { + const captchaValid = await verifyCaptcha(captchaToken, req.ip); + if (!captchaValid) { + res.status(403).json({ error: 'Captcha verification failed' }); + return; + } + const authResult = await login(email, password); if (!authResult) { diff --git a/docker-compose.yml b/docker-compose.yml index 7a42097..d3c50c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123} - N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp} + - TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-} depends_on: - db restart: unless-stopped @@ -34,6 +35,8 @@ services: frontend: build: context: . + args: + VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-} image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest container_name: graph_frontend ports: diff --git a/src/dataService.ts b/src/dataService.ts index 856a7ff..d68b3c9 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -55,27 +55,30 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({ end: formatDateParam(dateRange.end) }); -export const login = async (email: string, password: string): Promise => { +export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error'; + +export const login = async (email: string, password: string, captchaToken?: string): Promise => { try { const response = await fetch(`${API_URL}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, captchaToken }), }); if (response.ok) { const data = await response.json() as { token?: string; user?: AuthUser }; - if (!data.token || !data.user) return false; + if (!data.token || !data.user) return 'server_error'; localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_user', JSON.stringify(data.user)); - return true; + return 'success'; } - return false; + if (response.status === 403) return 'captcha_failed'; + return 'invalid_credentials'; } catch (error) { console.error('Login failed', error); - return false; + return 'server_error'; } }; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 5642f65..f0f2e54 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,29 +1,134 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Lock } from 'lucide-react'; import { login } from '../dataService'; +declare global { + interface Window { + turnstile?: { + render: ( + container: HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + 'expired-callback': () => void; + 'error-callback': () => void; + theme: 'dark' | 'light' | 'auto'; + } + ) => string; + reset: (widgetId?: string) => void; + }; + } +} + +const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY; +const turnstileScriptId = 'turnstile-api-script'; + const Login = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [captchaToken, setCaptchaToken] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [captchaReady, setCaptchaReady] = useState(!turnstileSiteKey); + const captchaContainerRef = useRef(null); + const captchaWidgetIdRef = useRef(null); const navigate = useNavigate(); + const captchaEnabled = Boolean(turnstileSiteKey); + + useEffect(() => { + if (!turnstileSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return; + + const siteKey = turnstileSiteKey; + + const renderCaptcha = () => { + if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return; + + captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, { + sitekey: siteKey, + theme: 'dark', + callback: (token) => { + setCaptchaToken(token); + setCaptchaReady(true); + }, + 'expired-callback': () => { + setCaptchaToken(''); + setCaptchaReady(true); + }, + 'error-callback': () => { + setCaptchaToken(''); + setCaptchaReady(false); + setError('Não foi possível carregar a verificação anti-bot.'); + }, + }); + setCaptchaReady(true); + }; + + if (window.turnstile) { + renderCaptcha(); + return; + } + + const existingScript = document.getElementById(turnstileScriptId); + if (existingScript) { + existingScript.addEventListener('load', renderCaptcha, { once: true }); + return () => existingScript.removeEventListener('load', renderCaptcha); + } + + const script = document.createElement('script'); + script.id = turnstileScriptId; + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async = true; + script.defer = true; + script.addEventListener('load', renderCaptcha, { once: true }); + script.addEventListener('error', () => { + setCaptchaReady(false); + setError('Não foi possível carregar a verificação anti-bot.'); + }, { once: true }); + document.head.appendChild(script); + + return () => script.removeEventListener('load', renderCaptcha); + }, [captchaEnabled]); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); + if (captchaEnabled && !captchaToken) { + setError('Confirme a verificação anti-bot antes de entrar.'); + return; + } + setIsLoading(true); setError(''); try { - const success = await login(email, password); - if (success) { + const result = await login(email, password, captchaToken); + if (result === 'success') { navigate('/graph'); - } else { + } else if (result === 'captcha_failed') { + setError('A verificação anti-bot falhou. Tente novamente.'); + if (captchaEnabled) { + setCaptchaToken(''); + window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined); + } + } else if (result === 'invalid_credentials') { setError('E-mail ou senha incorretos.'); + if (captchaEnabled) { + setCaptchaToken(''); + window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined); + } + } else { + setError('Erro ao conectar ao servidor.'); + if (captchaEnabled) { + setCaptchaToken(''); + window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined); + } } } catch { setError('Erro ao conectar ao servidor.'); + if (captchaEnabled) { + setCaptchaToken(''); + window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined); + } } finally { setIsLoading(false); } @@ -64,11 +169,20 @@ const Login = () => { /> + {captchaEnabled && ( +
+
+ {!captchaReady && ( +

Verificação anti-bot indisponível.

+ )} +
+ )} + {error &&

{error}

}