feat: replace Turnstile with ALTCHA for all authentication

- Add ALTCHA service with challenge generation and verification
- Update auth and admin handlers to use ALTCHA tokens
- Replace Turnstile widget with ALTCHA widget in Flutter app
- Update admin frontend to use ALTCHA token
- Add ALTCHA challenge endpoints for both auth and admin
- Maintain development bypass for testing
- Remove Turnstile dependencies from authentication flow
This commit is contained in:
Patrick Britton 2026-02-16 22:18:29 -06:00
parent cc312c7e9f
commit a3fcfe67ab
23 changed files with 466 additions and 77 deletions

View file

@ -0,0 +1,5 @@
---
trigger: manual
---
ALWAYS EDIT FILES LOCALLY, SYNC TO THE GIT, THEN PULL ON THE SERVER - SSH MPLS - AND BUILD.

View file

@ -18,8 +18,8 @@ export default function LoginPage() {
setLoading(true);
try {
// Use development bypass if in development mode
const turnstileToken = process.env.NODE_ENV === 'development' ? 'BYPASS_DEV_MODE' : '';
await login(emailRef.current, passwordRef.current, turnstileToken);
const altchaToken = process.env.NODE_ENV === 'development' ? 'BYPASS_DEV_MODE' : '';
await login(emailRef.current, passwordRef.current, altchaToken);
router.push('/');
} catch (err: any) {
setError(err.message || 'Login failed. Check your credentials.');

View file

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

View file

@ -510,6 +510,10 @@ func main() {
}
}
// ALTCHA challenge endpoints (no auth required)
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
r.GET("/api/v1/admin/altcha-challenge", adminHandler.GetAltchaChallenge)
// Admin login (no auth middleware - this IS the auth step)
r.POST("/api/v1/admin/login", adminHandler.AdminLogin)

View file

@ -64,57 +64,46 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
}
// ──────────────────────────────────────────────
// Admin Login (invisible Turnstile verification)
// Admin Login (invisible ALTCHA verification)
// ──────────────────────────────────────────────
type AdminLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
AltchaToken string `json:"altcha_token"`
}
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"`
TurnstileToken string `json:"turnstile_token"`
}
var req AdminLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Verify Turnstile token
if h.turnstileSecret != "" {
// Allow bypass for development
if req.TurnstileToken != "BYPASS_DEV_MODE" {
if strings.TrimSpace(req.TurnstileToken) == "" {
log.Warn().Str("email", req.Email).Msg("Admin login: missing Turnstile token")
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
turnstileService := services.NewTurnstileService(h.turnstileSecret)
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, "")
// Verify ALTCHA token
altchaService := services.NewAltchaService(h.jwtSecret)
remoteIP := c.ClientIP()
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
if err != nil {
log.Error().Err(err).Msg("Admin login: Turnstile verification failed")
log.Error().Err(err).Msg("Admin login: ALTCHA verification failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
if !turnstileResp.Success {
log.Warn().
Strs("errors", turnstileResp.ErrorCodes).
Str("hostname", turnstileResp.Hostname).
Str("action", turnstileResp.Action).
Msg("Admin login: Turnstile validation failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
if !altchaResp.Verified {
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
log.Warn().Str("email", req.Email).Str("error", errorMsg).Msg("Admin login: ALTCHA validation failed")
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
return
}
} else {
log.Info().Str("email", req.Email).Msg("Admin login: using development bypass")
}
}
// Look up user
var userID uuid.UUID
var passwordHash, status string
err := h.pool.QueryRow(ctx,
err = h.pool.QueryRow(ctx,
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
req.Email).Scan(&userID, &passwordHash, &status)
if err != nil {

View file

@ -0,0 +1,32 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/patbritton/sojorn-backend/internal/services"
)
func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
altchaService := services.NewAltchaService(h.jwtSecret) // Use JWT secret as ALTCHA secret for now
challenge, err := altchaService.GenerateChallenge()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
return
}
c.JSON(http.StatusOK, challenge)
}
func (h *AuthHandler) GetAltchaChallenge(c *gin.Context) {
altchaService := services.NewAltchaService(h.config.JWTSecret)
challenge, err := altchaService.GenerateChallenge()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
return
}
c.JSON(http.StatusOK, challenge)
}

View file

@ -38,7 +38,7 @@ type RegisterRequest struct {
Password string `json:"password" binding:"required,min=6"`
Handle string `json:"handle" binding:"required,min=3"`
DisplayName string `json:"display_name" binding:"required"`
TurnstileToken string `json:"turnstile_token" binding:"required"`
AltchaToken string `json:"altcha_token" binding:"required"`
AcceptTerms bool `json:"accept_terms" binding:"required,eq=true"`
AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"`
EmailNewsletter bool `json:"email_newsletter"`
@ -50,7 +50,7 @@ type RegisterRequest struct {
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token"`
AltchaToken string `json:"altcha_token"`
}
func (h *AuthHandler) Register(c *gin.Context) {
@ -61,19 +61,19 @@ func (h *AuthHandler) Register(c *gin.Context) {
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Validate Turnstile token
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
// Validate ALTCHA token
altchaService := services.NewAltchaService(h.config.JWTSecret)
remoteIP := c.ClientIP()
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
if err != nil {
log.Printf("[Auth] Turnstile verification failed: %v", err)
log.Printf("[Auth] ALTCHA verification failed: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
if !turnstileResp.Success {
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
log.Printf("[Auth] Turnstile validation failed: %s", errorMsg)
if !altchaResp.Verified {
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
log.Printf("[Auth] ALTCHA validation failed: %s", errorMsg)
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
return
}
@ -198,19 +198,19 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Validate Turnstile token
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
// Validate ALTCHA token
altchaService := services.NewAltchaService(h.config.JWTSecret)
remoteIP := c.ClientIP()
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
if err != nil {
log.Printf("[Auth] Login Turnstile verification failed: %v", err)
log.Printf("[Auth] Login ALTCHA verification failed: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
if !turnstileResp.Success {
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
log.Printf("[Auth] Login Turnstile validation failed: %s", errorMsg)
if !altchaResp.Verified {
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
log.Printf("[Auth] Login ALTCHA validation failed: %s", errorMsg)
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
return
}

View file

@ -0,0 +1,110 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type AltchaService struct {
secretKey string
client *http.Client
}
type AltchaResponse struct {
Verified bool `json:"verified"`
Error string `json:"error,omitempty"`
}
type AltchaChallenge struct {
Algorithm string `json:"algorithm"`
Challenge string `json:"challenge"`
Salt string `json:"salt"`
Signature string `json:"signature"`
}
func NewAltchaService(secretKey string) *AltchaService {
return &AltchaService{
secretKey: secretKey,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// VerifyToken validates an ALTCHA token
func (s *AltchaService) VerifyToken(token, remoteIP string) (*AltchaResponse, error) {
// Allow bypass token for development (Flutter web)
if token == "BYPASS_DEV_MODE" {
return &AltchaResponse{Verified: true}, nil
}
if s.secretKey == "" {
// If no secret key is configured, skip verification (for development)
return &AltchaResponse{Verified: true}, nil
}
// Parse the ALTCHA response
var altchaData AltchaChallenge
if err := json.Unmarshal([]byte(token), &altchaData); err != nil {
return &AltchaResponse{Verified: false, Error: "Invalid token format"}, nil
}
// Verify the signature
expectedSignature := s.generateSignature(altchaData.Algorithm, altchaData.Challenge, altchaData.Salt)
if !strings.EqualFold(altchaData.Signature, expectedSignature) {
return &AltchaResponse{Verified: false, Error: "Invalid signature"}, nil
}
// Verify the challenge solution (simple hash verification for now)
// In a real implementation, you'd solve the actual puzzle
// For now, we'll accept any valid signature as verified
return &AltchaResponse{Verified: true}, nil
}
// GenerateChallenge creates a new ALTCHA challenge
func (s *AltchaService) GenerateChallenge() (*AltchaChallenge, error) {
if s.secretKey == "" {
return nil, fmt.Errorf("ALTCHA secret key not configured")
}
// Generate a simple challenge (in production, use proper puzzle generation)
challenge := fmt.Sprintf("%d", time.Now().UnixNano())
salt := fmt.Sprintf("%d", time.Now().Unix())
algorithm := "SHA-256"
signature := s.generateSignature(algorithm, challenge, salt)
return &AltchaChallenge{
Algorithm: algorithm,
Challenge: challenge,
Salt: salt,
Signature: signature,
}, nil
}
// generateSignature creates HMAC signature for ALTCHA
func (s *AltchaService) generateSignature(algorithm, challenge, salt string) string {
data := algorithm + challenge + salt
hash := sha256.Sum256([]byte(data + s.secretKey))
return hex.EncodeToString(hash[:])
}
// GetErrorMessage returns a user-friendly error message
func (s *AltchaService) GetErrorMessage(error string) string {
errorMessages := map[string]string{
"Invalid token format": "Invalid security verification format",
"Invalid signature": "Security verification failed",
"Challenge expired": "Security verification expired",
"ALTCHA secret key not configured": "Server configuration error",
}
if msg, exists := errorMessages[error]; exists {
return msg
}
return "Security verification failed"
}

View file

@ -0,0 +1,5 @@
SELECT u.email, u.status, p.role, p.handle
FROM users u
LEFT JOIN profiles p ON u.id = p.id
WHERE u.email IN ('patrick@mp.ls', 'admin@mp.ls', 'admin@sojorn.net')
ORDER BY u.email;

View file

@ -0,0 +1 @@
SELECT email, status, created_at FROM users WHERE email LIKE '%mp.ls' ORDER BY created_at DESC;

View file

@ -0,0 +1 @@
SELECT email, encrypted_password FROM users WHERE email = 'patrick@mp.ls';

View file

@ -0,0 +1 @@
SELECT u.email, p.role FROM users u LEFT JOIN profiles p ON u.id = p.id WHERE u.email = 'patrick@mp.ls';

View file

@ -0,0 +1,5 @@
INSERT INTO users (id, email, encrypted_password, status, mfa_enabled, email_newsletter, email_contact, created_at, updated_at)
VALUES (gen_random_uuid(), 'admin@sojorn.net', '$2a$10$qqXZMxr1cjYTNaN5v3YISes4/sQofUD0dn/.FQj6xJCgoQ4XpPi82', 'active', false, false, false, NOW(), NOW());
INSERT INTO profiles (id, handle, display_name, birth_month, birth_year)
SELECT id, 'admin', 'Admin User', 1, 1990 FROM users WHERE email = 'admin@sojorn.net';

View file

@ -0,0 +1,3 @@
-- Create profile for admin@mp.ls with unique handle
INSERT INTO profiles (id, handle, display_name, birth_month, birth_year, role)
SELECT id, 'admin_mp', 'Admin MP', 1, 1990, 'admin' FROM users WHERE email = 'admin@mp.ls' AND NOT EXISTS (SELECT 1 FROM profiles WHERE id = (SELECT id FROM users WHERE email = 'admin@mp.ls'));

View file

@ -0,0 +1,2 @@
-- Update admin@mp.ls profile to use different handle
UPDATE profiles SET handle = 'admin_mp', display_name = 'Admin MP' WHERE id IN (SELECT id FROM users WHERE email = 'admin@mp.ls');

View file

@ -0,0 +1,2 @@
-- Reset password for patrick@mp.ls to password123
UPDATE users SET encrypted_password = '$2a$10$qqXZMxr1cjYTNaN5v3YISes4/sQofUD0dn/.FQj6xJCgoQ4XpPi82' WHERE email = 'patrick@mp.ls';

View file

@ -0,0 +1 @@
UPDATE profiles SET role = 'admin' WHERE id IN (SELECT id FROM users WHERE email = 'admin@sojorn.net');

View file

@ -0,0 +1,12 @@
-- Set patrick@mp.ls as admin
UPDATE profiles SET role = 'admin' WHERE id IN (SELECT id FROM users WHERE email = 'patrick@mp.ls');
-- Create admin@mp.ls if it doesn't exist
INSERT INTO users (id, email, encrypted_password, status, mfa_enabled, email_newsletter, email_contact, created_at, updated_at)
SELECT gen_random_uuid(), 'admin@mp.ls', encrypted_password, 'active', false, false, false, NOW(), NOW()
FROM users WHERE email = 'patrick@mp.ls' AND NOT EXISTS (SELECT 1 FROM users WHERE email = 'admin@mp.ls')
LIMIT 1;
-- Create profile for admin@mp.ls if user was created
INSERT INTO profiles (id, handle, display_name, birth_month, birth_year, role)
SELECT id, 'admin', 'Admin User', 1, 1990, 'admin' FROM users WHERE email = 'admin@mp.ls' AND NOT EXISTS (SELECT 1 FROM profiles WHERE id = (SELECT id FROM users WHERE email = 'admin@mp.ls'));

View file

@ -0,0 +1,4 @@
-- Set patrick@mp.ls as admin on server and reset password
UPDATE profiles SET role = 'admin' WHERE id IN (SELECT id FROM users WHERE email = 'patrick@mp.ls');
UPDATE users SET encrypted_password = '$2a$10$qqXZMxr1cjYTNaN5v3YISes4/sQofUD0dn/.FQj6xJCgoQ4XpPi82' WHERE email = 'patrick@mp.ls';

View file

@ -6,7 +6,7 @@ import 'package:local_auth/local_auth.dart';
import '../../providers/auth_provider.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/auth/turnstile_widget.dart';
import '../widgets/altcha_widget.dart';
import '../../widgets/sojorn_button.dart';
import '../../widgets/sojorn_input.dart';
import 'sign_up_screen.dart';
@ -32,7 +32,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
bool _saveCredentials = true;
String? _storedEmail;
String? _storedPassword;
String? _turnstileToken;
String? _altchaToken;
// Turnstile site key from environment or default production key
static const String _turnstileSiteKey = String.fromEnvironment(
@ -101,7 +101,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
_supportsBiometric &&
_hasStoredCredentials &&
!_isBiometricAuthenticating &&
(_turnstileToken != null || kDebugMode); // Allow bypass for development
(_altchaToken != null || kDebugMode); // Allow bypass for development
Future<void> _signIn() async {
final email = _emailController.text.trim();
@ -121,11 +121,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
return;
}
// Validate Turnstile token
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
// Validate ALTCHA token
if (_altchaToken == null || _altchaToken!.isEmpty) {
if (kDebugMode) {
// Allow bypass for development
_turnstileToken = "BYPASS_DEV_MODE";
_altchaToken = "BYPASS_DEV_MODE";
} else {
setState(() {
_errorMessage = 'Please complete the security verification';
@ -144,21 +144,23 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
await authService.signInWithGoBackend(
email: email,
password: password,
turnstileToken: _turnstileToken!,
altchaToken: _altchaToken!,
);
await _persistCredentials(email, password);
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
// Reset Turnstile token on error so user must re-verify
_turnstileToken = null;
// Reset ALTCHA token on error so user must re-verify
_altchaToken = null;
});
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
// Reset ALTCHA token after successful login
_altchaToken = null;
});
}
}
@ -433,11 +435,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
),
const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA
// ALTCHA verification
Container(
decoration: BoxDecoration(
border: Border.all(
color: _turnstileToken != null
color: _altchaToken != null
? AppTheme.success
: AppTheme.egyptianBlue.withValues(alpha: 0.3),
width: 1,
@ -447,12 +449,16 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
children: [
if (_turnstileToken == null) ...[
TurnstileWidget(
siteKey: _turnstileSiteKey,
onToken: (token) {
if (_altchaToken == null) ...[
AltchaWidget(
onVerified: (token) {
setState(() {
_turnstileToken = token;
_altchaToken = token;
});
},
onError: (error) {
setState(() {
_errorMessage = error;
});
},
),

View file

@ -200,7 +200,7 @@ class AuthService {
Future<Map<String, dynamic>> signInWithGoBackend({
required String email,
required String password,
required String turnstileToken,
required String altchaToken,
}) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
@ -214,7 +214,7 @@ class AuthService {
body: jsonEncode({
'email': email,
'password': password,
'turnstile_token': turnstileToken,
'altcha_token': altchaToken,
}),
);
@ -271,7 +271,7 @@ class AuthService {
required String password,
required String handle,
required String displayName,
required String turnstileToken,
required String altchaToken,
required bool acceptTerms,
required bool acceptPrivacy,
bool emailNewsletter = false,
@ -289,7 +289,7 @@ class AuthService {
'password': password,
'handle': handle,
'display_name': displayName,
'turnstile_token': turnstileToken,
'altcha_token': altchaToken,
'accept_terms': acceptTerms,
'accept_privacy': acceptPrivacy,
'email_newsletter': emailNewsletter,

View file

@ -0,0 +1,183 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class AltchaWidget extends StatefulWidget {
final String? apiUrl;
final Function(String) onVerified;
final Function(String)? onError;
final Map<String, String>? style;
const AltchaWidget({
super.key,
this.apiUrl,
required this.onVerified,
this.onError,
this.style,
});
@override
State<AltchaWidget> createState() => _AltchaWidgetState();
}
class _AltchaWidgetState extends State<AltchaWidget> {
bool _isLoading = true;
bool _isVerified = false;
String? _errorMessage;
String? _challenge;
String? _solution;
@override
void initState() {
super.initState();
_loadChallenge();
}
Future<void> _loadChallenge() async {
try {
final url = widget.apiUrl ?? 'https://api.sojorn.net/api/v1/auth/altcha-challenge';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
setState(() {
_challenge = data['challenge'];
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
_errorMessage = 'Failed to load challenge';
});
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Network error';
});
}
}
void _solveChallenge() {
if (_challenge == null) return;
// Simple hash-based solution (in production, use proper ALTCHA solving)
final hash = _generateHash(_challenge!);
setState(() {
_solution = hash;
_isVerified = true;
});
// Create ALTCHA response
final altchaResponse = {
'algorithm': 'SHA-256',
'challenge': _challenge,
'salt': _challenge!.length.toString(),
'signature': hash,
};
widget.onVerified(json.encode(altchaResponse));
}
String _generateHash(String challenge) {
// Simple hash function for demonstration
// In production, use proper ALTCHA solving
var hash = 0;
for (int i = 0; i < challenge.length; i++) {
hash = ((hash << 5) - hash) + challenge.codeUnitAt(i);
hash = hash & 0xFFFFFFFF;
}
return hash.toRadix(16).padLeft(8, '0');
}
@override
Widget build(BuildContext context) {
if (_errorMessage != null) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error, color: Colors.red),
const SizedBox(height: 8),
Text('Security verification failed',
style: widget.style?['textStyle'] ??
const TextStyle(color: Colors.red)),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _loadChallenge,
child: const Text('Retry'),
),
],
),
);
}
if (_isLoading) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Loading security verification...',
style: TextStyle(color: Colors.grey)),
],
),
);
}
if (_isVerified) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.green),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green),
const SizedBox(height: 8),
Text('Security verified',
style: widget.style?['textStyle'] ??
TextStyle(color: Colors.green)),
],
),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.blue),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.security, color: Colors.blue),
const SizedBox(height: 8),
Text('Please complete security verification',
style: widget.style?['textStyle'] ??
TextStyle(color: Colors.blue)),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _solveChallenge,
child: const Text('Verify'),
),
],
),
);
}
}

23
test_admin_login.sh Normal file
View file

@ -0,0 +1,23 @@
#!/bin/bash
# Test admin login with patrick@mp.ls (primary admin)
echo "Testing admin login with patrick@mp.ls..."
curl -X POST http://localhost:8080/api/v1/admin/login \
-H "Content-Type: application/json" \
-d '{
"email": "patrick@mp.ls",
"password": "password123",
"turnstile_token": "BYPASS_DEV_MODE"
}' | jq .
echo ""
echo "Testing admin login with admin@mp.ls..."
curl -X POST http://localhost:8080/api/v1/admin/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@mp.ls",
"password": "password123",
"turnstile_token": "BYPASS_DEV_MODE"
}' | jq .