Add Turnstile captcha to login
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
This commit is contained in:
@@ -20,6 +20,13 @@ 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=
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 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 . .
|
||||||
|
|||||||
@@ -112,8 +112,14 @@ 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
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ 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 || ''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
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';
|
||||||
|
|
||||||
router.post('/login', async (req, res, next) => {
|
const verifyCaptcha = async (captchaToken, remoteIp) => {
|
||||||
const { email, password } = req.body;
|
if (!TURNSTILE_SECRET_KEY) return true;
|
||||||
|
if (!captchaToken || typeof captchaToken !== 'string') return false;
|
||||||
|
|
||||||
try {
|
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);
|
const authResult = await login(email, password);
|
||||||
|
|
||||||
if (!authResult) {
|
if (!authResult) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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
|
||||||
@@ -34,6 +35,8 @@ 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:
|
||||||
|
|||||||
@@ -55,27 +55,30 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
|
|||||||
end: formatDateParam(dateRange.end)
|
end: formatDateParam(dateRange.end)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const login = async (email: string, password: string): Promise<boolean> => {
|
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
|
||||||
|
|
||||||
|
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 }),
|
body: JSON.stringify({ email, password, captchaToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
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 false;
|
if (!data.token || !data.user) return 'server_error';
|
||||||
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 true;
|
return 'success';
|
||||||
}
|
}
|
||||||
return false;
|
if (response.status === 403) return 'captcha_failed';
|
||||||
|
return 'invalid_credentials';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed', error);
|
console.error('Login failed', error);
|
||||||
return false;
|
return 'server_error';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,134 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, 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 success = await login(email, password);
|
const result = await login(email, password, captchaToken);
|
||||||
if (success) {
|
if (result === 'success') {
|
||||||
navigate('/graph');
|
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.');
|
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 {
|
} 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);
|
||||||
}
|
}
|
||||||
@@ -64,11 +169,20 @@ 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}
|
disabled={isLoading || !captchaReady || (captchaEnabled && !captchaToken)}
|
||||||
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
Normal file
10
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
readonly VITE_TURNSTILE_SITE_KEY?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user