Load Turnstile site key at runtime
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s

This commit is contained in:
Cauê Faleiros
2026-07-28 13:03:51 -03:00
parent 20dde11ff0
commit ed3a3f0571
9 changed files with 77 additions and 25 deletions

View File

@@ -23,10 +23,9 @@ JWT_SECRET=super_secret_jwt_key_123
# --- CAPTCHA / Bot Protection (Optional) --- # --- CAPTCHA / Bot Protection (Optional) ---
# Create keys in Cloudflare Turnstile and set both values in production. # Create keys in Cloudflare Turnstile and set both values in production.
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled. # When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET= TURNSTILE_SECRET=
# --- 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,16 +112,13 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL ADMIN_EMAIL
ADMIN_PASSWORD ADMIN_PASSWORD
JWT_SECRET JWT_SECRET
TURNSTILE_SITE_KEY
TURNSTILE_SECRET TURNSTILE_SECRET
VITE_TURNSTILE_SITE_KEY
``` ```
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. `TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
Set `VITE_TURNSTILE_SITE_KEY` at frontend build time to show Cloudflare Turnstile `TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
on the login page. the verification widget at runtime.
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.
## Validation ## Validation

View File

@@ -8,5 +8,6 @@ module.exports = {
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_SITE_KEY: process.env.TURNSTILE_SITE_KEY || process.env.VITE_TURNSTILE_SITE_KEY || '',
TURNSTILE_SECRET: process.env.TURNSTILE_SECRET || process.env.TURNSTILE_SECRET_KEY || '' TURNSTILE_SECRET: process.env.TURNSTILE_SECRET || process.env.TURNSTILE_SECRET_KEY || ''
}; };

View File

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

View File

@@ -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_SITE_KEY=${TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY:-}}
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-${TURNSTILE_SECRET_KEY:-}} - TURNSTILE_SECRET=${TURNSTILE_SECRET:-${TURNSTILE_SECRET_KEY:-}}
depends_on: depends_on:
- db - db
@@ -35,8 +36,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,8 +55,19 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
end: formatDateParam(dateRange.end) end: formatDateParam(dateRange.end)
}); });
export type LoginConfig = {
captchaRequired: boolean;
turnstileSiteKey: string;
};
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error'; export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
export const getLoginConfig = async (): Promise<LoginConfig> => {
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<LoginConfig>;
};
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => { 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`, {

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, 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 { getLoginConfig, login } from '../dataService';
declare global { declare global {
interface Window { interface Window {
@@ -21,8 +21,8 @@ declare global {
} }
} }
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const turnstileScriptId = 'turnstile-api-script'; 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 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 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.'; const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.';
@@ -34,11 +34,47 @@ const Login = () => {
const [captchaToken, setCaptchaToken] = 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 [isConfigLoading, setIsConfigLoading] = useState(true);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [captchaRequired, setCaptchaRequired] = useState(false);
const [captchaReady, setCaptchaReady] = useState(true);
const captchaContainerRef = useRef<HTMLDivElement | null>(null); const captchaContainerRef = useRef<HTMLDivElement | null>(null);
const captchaWidgetIdRef = useRef<string | null>(null); const captchaWidgetIdRef = useRef<string | null>(null);
const navigate = useNavigate(); 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(() => { useEffect(() => {
if (!turnstileSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return; if (!turnstileSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
@@ -92,10 +128,15 @@ const Login = () => {
document.head.appendChild(script); document.head.appendChild(script);
return () => script.removeEventListener('load', renderCaptcha); return () => script.removeEventListener('load', renderCaptcha);
}, [captchaEnabled]); }, [turnstileSiteKey]);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (securityMisconfigured) {
setError(securityConfigurationMessage);
return;
}
if (captchaEnabled && !captchaToken) { if (captchaEnabled && !captchaToken) {
setError(securityRequiredMessage); setError(securityRequiredMessage);
return; return;
@@ -173,10 +214,10 @@ const Login = () => {
/> />
</div> </div>
{captchaEnabled && ( {(captchaEnabled || securityMisconfigured) && (
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3"> <div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
<div ref={captchaContainerRef} className="flex justify-center" /> {captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
{!captchaReady && ( {(securityMisconfigured || !captchaReady) && (
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p> <p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
)} )}
</div> </div>
@@ -186,10 +227,10 @@ const Login = () => {
<button <button
type="submit" type="submit"
disabled={isLoading || !captchaReady || (captchaEnabled && !captchaToken)} disabled={isLoading || isConfigLoading || securityMisconfigured || !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 || isConfigLoading ? 'Entrando...' : 'Entrar'}
</button> </button>
</form> </form>
</div> </div>

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

@@ -2,7 +2,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URL?: string; readonly VITE_API_URL?: string;
readonly VITE_TURNSTILE_SITE_KEY?: string;
} }
interface ImportMeta { interface ImportMeta {