diff --git a/.env.example b/.env.example index a4ba843..9cdc42b 100644 --- a/.env.example +++ b/.env.example @@ -23,10 +23,9 @@ 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 is empty, backend CAPTCHA enforcement is disabled. +TURNSTILE_SITE_KEY= TURNSTILE_SECRET= # --- 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 4bd6b7a..4e2b46a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ # 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 . . diff --git a/README.md b/README.md index 87cbfea..443f05f 100644 --- a/README.md +++ b/README.md @@ -112,16 +112,13 @@ N8N_WHATSAPP_TRIGGER_URL ADMIN_EMAIL ADMIN_PASSWORD JWT_SECRET +TURNSTILE_SITE_KEY TURNSTILE_SECRET -VITE_TURNSTILE_SITE_KEY ``` -`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. -Set `VITE_TURNSTILE_SITE_KEY` at frontend build time to show Cloudflare Turnstile -on the login page. - -If `TURNSTILE_SECRET` is set but the login page does not show the verification -widget, rebuild the frontend image with `VITE_TURNSTILE_SITE_KEY` available. +`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set +`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load +the verification widget at runtime. ## Validation diff --git a/backend/config.js b/backend/config.js index 4d86a60..7086dc1 100644 --- a/backend/config.js +++ b/backend/config.js @@ -8,5 +8,6 @@ module.exports = { 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', + TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY || process.env.VITE_TURNSTILE_SITE_KEY || '', TURNSTILE_SECRET: process.env.TURNSTILE_SECRET || process.env.TURNSTILE_SECRET_KEY || '' }; diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 90cc000..95f5093 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,6 +1,6 @@ const express = require('express'); const { login } = require('../auth'); -const { TURNSTILE_SECRET } = require('../config'); +const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config'); const router = express.Router(); const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; @@ -34,6 +34,13 @@ const verifyCaptcha = async (captchaToken, remoteIp) => { } }; +router.get('/login/config', (req, res) => { + res.json({ + captchaRequired: Boolean(TURNSTILE_SECRET), + turnstileSiteKey: TURNSTILE_SITE_KEY + }); +}); + router.post('/login', async (req, res, next) => { const { email, password, captchaToken } = req.body; diff --git a/docker-compose.yml b/docker-compose.yml index ec56bdc..297f917 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_SITE_KEY=${TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY:-}} - TURNSTILE_SECRET=${TURNSTILE_SECRET:-${TURNSTILE_SECRET_KEY:-}} depends_on: - db @@ -35,8 +36,6 @@ 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 d68b3c9..c189342 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -55,8 +55,19 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({ end: formatDateParam(dateRange.end) }); +export type LoginConfig = { + captchaRequired: boolean; + turnstileSiteKey: string; +}; + export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error'; +export const getLoginConfig = async (): Promise => { + const response = await fetch(`${API_URL}/login/config`, { cache: 'no-store' }); + if (!response.ok) throw new Error('Failed to load login config'); + return response.json() as Promise; +}; + export const login = async (email: string, password: string, captchaToken?: string): Promise => { try { const response = await fetch(`${API_URL}/login`, { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 1cba751..4ba48c6 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Lock } from 'lucide-react'; -import { login } from '../dataService'; +import { getLoginConfig, login } from '../dataService'; declare global { interface Window { @@ -21,8 +21,8 @@ declare global { } } -const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY; const turnstileScriptId = 'turnstile-api-script'; +const securityConfigLoadMessage = 'Não foi possível carregar as configurações de segurança. Atualize a página e tente novamente.'; const securityUnavailableMessage = 'Não foi possível carregar a verificação de segurança. Atualize a página e tente novamente.'; const securityConfigurationMessage = 'Verificação de segurança indisponível. Entre em contato com o administrador.'; const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.'; @@ -34,11 +34,47 @@ const Login = () => { const [captchaToken, setCaptchaToken] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [captchaReady, setCaptchaReady] = useState(!turnstileSiteKey); + const [isConfigLoading, setIsConfigLoading] = useState(true); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [captchaRequired, setCaptchaRequired] = useState(false); + const [captchaReady, setCaptchaReady] = useState(true); const captchaContainerRef = useRef(null); const captchaWidgetIdRef = useRef(null); const navigate = useNavigate(); - const captchaEnabled = Boolean(turnstileSiteKey); + const captchaEnabled = Boolean(captchaRequired && turnstileSiteKey); + const securityMisconfigured = captchaRequired && !turnstileSiteKey; + + useEffect(() => { + let isMounted = true; + + const loadLoginConfig = async () => { + try { + const config = await getLoginConfig(); + if (!isMounted) return; + + setCaptchaRequired(config.captchaRequired); + setTurnstileSiteKey(config.turnstileSiteKey); + setCaptchaReady(!config.captchaRequired); + if (config.captchaRequired && !config.turnstileSiteKey) { + setError(securityConfigurationMessage); + } + } catch { + if (!isMounted) return; + setCaptchaReady(false); + setError(securityConfigLoadMessage); + } finally { + if (isMounted) { + setIsConfigLoading(false); + } + } + }; + + void loadLoginConfig(); + + return () => { + isMounted = false; + }; + }, []); useEffect(() => { if (!turnstileSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return; @@ -92,10 +128,15 @@ const Login = () => { document.head.appendChild(script); return () => script.removeEventListener('load', renderCaptcha); - }, [captchaEnabled]); + }, [turnstileSiteKey]); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); + if (securityMisconfigured) { + setError(securityConfigurationMessage); + return; + } + if (captchaEnabled && !captchaToken) { setError(securityRequiredMessage); return; @@ -173,10 +214,10 @@ const Login = () => { /> - {captchaEnabled && ( + {(captchaEnabled || securityMisconfigured) && (
-
- {!captchaReady && ( + {captchaEnabled &&
} + {(securityMisconfigured || !captchaReady) && (

Verificação de segurança indisponível.

)}
@@ -186,10 +227,10 @@ const Login = () => {
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 176f969..c57d674 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,7 +2,6 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; - readonly VITE_TURNSTILE_SITE_KEY?: string; } interface ImportMeta {