Revert "Add Turnstile captcha to login"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s

This reverts commit 5aa1be27bb.
This commit is contained in:
Cauê Faleiros
2026-07-28 11:52:46 -03:00
parent 302b55b346
commit a7c732e139
9 changed files with 14 additions and 197 deletions

View File

@@ -20,13 +20,6 @@ ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
JWT_SECRET=super_secret_jwt_key_123 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) --- # --- Frontend Configuration (Optional) ---
# If you need to override the API URL for the frontend # If you need to override the API URL for the frontend
# VITE_API_URL=/api # VITE_API_URL=/api
# Cloudflare Turnstile site key shown on the login page
VITE_TURNSTILE_SITE_KEY=

View File

@@ -1,8 +1,6 @@
# Build stage # Build stage
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
ARG VITE_TURNSTILE_SITE_KEY
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .

View File

@@ -112,14 +112,8 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL ADMIN_EMAIL
ADMIN_PASSWORD ADMIN_PASSWORD
JWT_SECRET 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 ## Validation
```bash ```bash

View File

@@ -7,6 +7,5 @@ module.exports = {
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123', ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123', JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb', 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 || ''
}; };

View File

@@ -1,49 +1,12 @@
const express = require('express'); const express = require('express');
const { login } = require('../auth'); const { login } = require('../auth');
const { TURNSTILE_SECRET_KEY } = require('../config');
const router = express.Router(); const router = express.Router();
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
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) => { router.post('/login', async (req, res, next) => {
const { email, password, captchaToken } = req.body; const { email, password } = req.body;
try { 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); const authResult = await login(email, password);
if (!authResult) { if (!authResult) {

View File

@@ -27,7 +27,6 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123} - JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp} - N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
@@ -35,8 +34,6 @@ services:
frontend: frontend:
build: build:
context: . context: .
args:
VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-}
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
container_name: graph_frontend container_name: graph_frontend
ports: ports:

View File

@@ -55,30 +55,27 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
end: formatDateParam(dateRange.end) end: formatDateParam(dateRange.end)
}); });
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error'; export const login = async (email: string, password: string): Promise<boolean> => {
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => {
try { try {
const response = await fetch(`${API_URL}/login`, { const response = await fetch(`${API_URL}/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, password, captchaToken }), body: JSON.stringify({ email, password }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json() as { token?: string; user?: AuthUser }; const data = await response.json() as { token?: string; user?: AuthUser };
if (!data.token || !data.user) return 'server_error'; if (!data.token || !data.user) return false;
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user)); localStorage.setItem('auth_user', JSON.stringify(data.user));
return 'success'; return true;
} }
if (response.status === 403) return 'captcha_failed'; return false;
return 'invalid_credentials';
} catch (error) { } catch (error) {
console.error('Login failed', error); console.error('Login failed', error);
return 'server_error'; return false;
} }
}; };

View File

@@ -1,134 +1,29 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import { login } from '../dataService'; 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 Login = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [captchaReady, setCaptchaReady] = useState(!turnstileSiteKey);
const captchaContainerRef = useRef<HTMLDivElement | null>(null);
const captchaWidgetIdRef = useRef<string | null>(null);
const navigate = useNavigate(); 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) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (captchaEnabled && !captchaToken) {
setError('Confirme a verificação anti-bot antes de entrar.');
return;
}
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const result = await login(email, password, captchaToken); const success = await login(email, password);
if (result === 'success') { if (success) {
navigate('/graph'); navigate('/graph');
} 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 { } else {
setError('Erro ao conectar ao servidor.'); setError('E-mail ou senha incorretos.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} }
} catch { } catch {
setError('Erro ao conectar ao servidor.'); setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -169,20 +64,11 @@ const Login = () => {
/> />
</div> </div>
{captchaEnabled && (
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
<div ref={captchaContainerRef} className="flex justify-center" />
{!captchaReady && (
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação anti-bot indisponível.</p>
)}
</div>
)}
{error && <p className="text-red-500 text-sm font-medium">{error}</p>} {error && <p className="text-red-500 text-sm font-medium">{error}</p>}
<button <button
type="submit" type="submit"
disabled={isLoading || !captchaReady || (captchaEnabled && !captchaToken)} disabled={isLoading}
className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer" className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer"
> >
{isLoading ? 'Entrando...' : 'Entrar'} {isLoading ? 'Entrando...' : 'Entrar'}

10
src/vite-env.d.ts vendored
View File

@@ -1,10 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_TURNSTILE_SITE_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}