Add invisible Turnstile verification to admin login

This commit is contained in:
Patrick Britton 2026-02-06 09:40:43 -06:00
parent e3d626c040
commit 14d8ca9ac0
5 changed files with 91 additions and 12 deletions

View file

@ -1,26 +1,75 @@
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import Script from 'next/script';
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [turnstileToken, setTurnstileToken] = useState('');
const turnstileRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const { login } = useAuth();
const router = useRouter();
const renderTurnstile = useCallback(() => {
if (!TURNSTILE_SITE_KEY || !turnstileRef.current || !(window as any).turnstile) return;
// Clean up previous widget if exists
if (widgetIdRef.current) {
try { (window as any).turnstile.remove(widgetIdRef.current); } catch {}
}
widgetIdRef.current = (window as any).turnstile.render(turnstileRef.current, {
sitekey: TURNSTILE_SITE_KEY,
size: 'invisible',
callback: (token: string) => setTurnstileToken(token),
'error-callback': () => setTurnstileToken(''),
'expired-callback': () => setTurnstileToken(''),
});
}, []);
useEffect(() => {
// If script already loaded (e.g. SPA navigation), render immediately
if ((window as any).turnstile && TURNSTILE_SITE_KEY) {
renderTurnstile();
}
}, [renderTurnstile]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
// If Turnstile is configured but token is empty, execute the challenge
if (TURNSTILE_SITE_KEY && !turnstileToken && widgetIdRef.current) {
(window as any).turnstile.execute(widgetIdRef.current);
// Wait briefly for the invisible challenge to resolve
await new Promise<void>((resolve, reject) => {
let attempts = 0;
const check = setInterval(() => {
attempts++;
if (turnstileToken || attempts > 50) {
clearInterval(check);
resolve();
}
}, 100);
});
}
await login(email, password, turnstileToken);
router.push('/');
} catch (err: any) {
setError(err.message || 'Login failed. Check your credentials.');
// Reset turnstile for retry
if (widgetIdRef.current && (window as any).turnstile) {
(window as any).turnstile.reset(widgetIdRef.current);
setTurnstileToken('');
}
} finally {
setLoading(false);
}
@ -28,6 +77,12 @@ export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-warm-100">
{TURNSTILE_SITE_KEY && (
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
onReady={renderTurnstile}
/>
)}
<div className="w-full max-w-md">
<div className="card p-8">
<div className="text-center mb-8">
@ -69,6 +124,8 @@ export default function LoginPage() {
required
/>
</div>
{/* Invisible Turnstile widget container */}
<div ref={turnstileRef} />
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>

View file

@ -52,10 +52,12 @@ class ApiClient {
}
// Auth
async login(email: string, password: string) {
async login(email: string, password: string, turnstileToken?: string) {
const body: Record<string, string> = { email, password };
if (turnstileToken) body.turnstile_token = turnstileToken;
const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify(body),
});
this.setToken(data.access_token);
return data;

View file

@ -7,7 +7,7 @@ interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: any | null;
login: (email: string, password: string) => Promise<void>;
login: (email: string, password: string, turnstileToken?: string) => Promise<void>;
logout: () => void;
}
@ -42,8 +42,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const login = async (email: string, password: string) => {
const data = await api.login(email, password);
const login = async (email: string, password: string, turnstileToken?: string) => {
const data = await api.login(email, password, turnstileToken);
setIsAuthenticated(true);
setUser(data.user);
};

View file

@ -133,7 +133,7 @@ func main() {
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
analysisHandler := handlers.NewAnalysisHandler()
appealHandler := handlers.NewAppealHandler(appealService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret, cfg.TurnstileSecretKey)
var s3Client *s3.Client
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {

View file

@ -22,27 +22,30 @@ type AdminHandler struct {
moderationService *services.ModerationService
appealService *services.AppealService
jwtSecret string
turnstileSecret string
}
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string) *AdminHandler {
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string, turnstileSecret string) *AdminHandler {
return &AdminHandler{
pool: pool,
moderationService: moderationService,
appealService: appealService,
jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret,
}
}
// ──────────────────────────────────────────────
// Admin Login (no Turnstile required)
// Admin Login (invisible Turnstile verification)
// ──────────────────────────────────────────────
func (h *AdminHandler) AdminLogin(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@ -50,6 +53,23 @@ func (h *AdminHandler) AdminLogin(c *gin.Context) {
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Verify Turnstile token (invisible mode)
if h.turnstileSecret != "" {
turnstileService := services.NewTurnstileService(h.turnstileSecret)
remoteIP := c.ClientIP()
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
if err != nil {
log.Error().Err(err).Msg("Admin login: Turnstile verification failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
if !turnstileResp.Success {
log.Warn().Strs("errors", turnstileResp.ErrorCodes).Msg("Admin login: Turnstile validation failed")
c.JSON(http.StatusForbidden, gin.H{"error": "Security verification failed. Please try again."})
return
}
}
// Look up user
var userID uuid.UUID
var passwordHash, status string