Add invisible Turnstile verification to admin login
This commit is contained in:
parent
e3d626c040
commit
14d8ca9ac0
|
|
@ -1,26 +1,75 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth';
|
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() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
|
const turnstileRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const router = useRouter();
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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('/');
|
router.push('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Login failed. Check your credentials.');
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +77,12 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-warm-100">
|
<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="w-full max-w-md">
|
||||||
<div className="card p-8">
|
<div className="card p-8">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
|
|
@ -69,6 +124,8 @@ export default function LoginPage() {
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Invisible Turnstile widget container */}
|
||||||
|
<div ref={turnstileRef} />
|
||||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,12 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// 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', {
|
const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
this.setToken(data.access_token);
|
this.setToken(data.access_token);
|
||||||
return data;
|
return data;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface AuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
user: any | null;
|
user: any | null;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string, turnstileToken?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,8 +42,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string, turnstileToken?: string) => {
|
||||||
const data = await api.login(email, password);
|
const data = await api.login(email, password, turnstileToken);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ func main() {
|
||||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||||
analysisHandler := handlers.NewAnalysisHandler()
|
analysisHandler := handlers.NewAnalysisHandler()
|
||||||
appealHandler := handlers.NewAppealHandler(appealService)
|
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
|
var s3Client *s3.Client
|
||||||
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
||||||
|
|
|
||||||
|
|
@ -22,19 +22,21 @@ type AdminHandler struct {
|
||||||
moderationService *services.ModerationService
|
moderationService *services.ModerationService
|
||||||
appealService *services.AppealService
|
appealService *services.AppealService
|
||||||
jwtSecret string
|
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{
|
return &AdminHandler{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
moderationService: moderationService,
|
moderationService: moderationService,
|
||||||
appealService: appealService,
|
appealService: appealService,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
|
turnstileSecret: turnstileSecret,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Admin Login (no Turnstile required)
|
// Admin Login (invisible Turnstile verification)
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *AdminHandler) AdminLogin(c *gin.Context) {
|
func (h *AdminHandler) AdminLogin(c *gin.Context) {
|
||||||
|
|
@ -43,6 +45,7 @@ func (h *AdminHandler) AdminLogin(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
|
TurnstileToken string `json:"turnstile_token"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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))
|
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
|
// Look up user
|
||||||
var userID uuid.UUID
|
var userID uuid.UUID
|
||||||
var passwordHash, status string
|
var passwordHash, status string
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue