diff --git a/.windsurf/rules/code-build.md b/.windsurf/rules/code-build.md new file mode 100644 index 0000000..2a1d7ee --- /dev/null +++ b/.windsurf/rules/code-build.md @@ -0,0 +1,5 @@ +--- +trigger: manual +--- + +ALWAYS EDIT FILES LOCALLY, SYNC TO THE GIT, THEN PULL ON THE SERVER - SSH MPLS - AND BUILD. \ No newline at end of file diff --git a/admin/src/app/login/page.tsx b/admin/src/app/login/page.tsx index 559fb1f..b01d499 100644 --- a/admin/src/app/login/page.tsx +++ b/admin/src/app/login/page.tsx @@ -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.'); diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 3c6b9bb..f6d3a31 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -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 = { 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), diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 01bcb62..19236d9 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -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) diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index eae988e..2fad350 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -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, "") - 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). - Str("hostname", turnstileResp.Hostname). - Str("action", turnstileResp.Action). - Msg("Admin login: Turnstile validation failed") - c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"}) - return - } - } else { - log.Info().Str("email", req.Email).Msg("Admin login: using development bypass") - } + // 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: ALTCHA verification failed") + c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"}) + return + } + + 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 } // 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 { diff --git a/go-backend/internal/handlers/altcha_handler.go b/go-backend/internal/handlers/altcha_handler.go new file mode 100644 index 0000000..617f865 --- /dev/null +++ b/go-backend/internal/handlers/altcha_handler.go @@ -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) +} diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index 083b143..749069f 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -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"` @@ -48,9 +48,9 @@ type RegisterRequest struct { } type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + 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 } diff --git a/go-backend/internal/services/altcha_service.go b/go-backend/internal/services/altcha_service.go new file mode 100644 index 0000000..55db9bd --- /dev/null +++ b/go-backend/internal/services/altcha_service.go @@ -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" +} diff --git a/go-backend/scripts/check_admin_accounts.sql b/go-backend/scripts/check_admin_accounts.sql new file mode 100644 index 0000000..6d737a3 --- /dev/null +++ b/go-backend/scripts/check_admin_accounts.sql @@ -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; diff --git a/go-backend/scripts/check_mp_users.sql b/go-backend/scripts/check_mp_users.sql new file mode 100644 index 0000000..e0b8113 --- /dev/null +++ b/go-backend/scripts/check_mp_users.sql @@ -0,0 +1 @@ +SELECT email, status, created_at FROM users WHERE email LIKE '%mp.ls' ORDER BY created_at DESC; diff --git a/go-backend/scripts/check_patrick_password.sql b/go-backend/scripts/check_patrick_password.sql new file mode 100644 index 0000000..980a76a --- /dev/null +++ b/go-backend/scripts/check_patrick_password.sql @@ -0,0 +1 @@ +SELECT email, encrypted_password FROM users WHERE email = 'patrick@mp.ls'; diff --git a/go-backend/scripts/check_patrick_role.sql b/go-backend/scripts/check_patrick_role.sql new file mode 100644 index 0000000..41d9ee3 --- /dev/null +++ b/go-backend/scripts/check_patrick_role.sql @@ -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'; diff --git a/go-backend/scripts/create_admin.sql b/go-backend/scripts/create_admin.sql new file mode 100644 index 0000000..3c14484 --- /dev/null +++ b/go-backend/scripts/create_admin.sql @@ -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'; diff --git a/go-backend/scripts/create_admin_mp_profile.sql b/go-backend/scripts/create_admin_mp_profile.sql new file mode 100644 index 0000000..9f23c04 --- /dev/null +++ b/go-backend/scripts/create_admin_mp_profile.sql @@ -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')); diff --git a/go-backend/scripts/fix_admin_mp.sql b/go-backend/scripts/fix_admin_mp.sql new file mode 100644 index 0000000..2ca0408 --- /dev/null +++ b/go-backend/scripts/fix_admin_mp.sql @@ -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'); diff --git a/go-backend/scripts/reset_patrick_password.sql b/go-backend/scripts/reset_patrick_password.sql new file mode 100644 index 0000000..b012e37 --- /dev/null +++ b/go-backend/scripts/reset_patrick_password.sql @@ -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'; diff --git a/go-backend/scripts/set_admin_role.sql b/go-backend/scripts/set_admin_role.sql new file mode 100644 index 0000000..a316c84 --- /dev/null +++ b/go-backend/scripts/set_admin_role.sql @@ -0,0 +1 @@ +UPDATE profiles SET role = 'admin' WHERE id IN (SELECT id FROM users WHERE email = 'admin@sojorn.net'); diff --git a/go-backend/scripts/setup_mp_admin.sql b/go-backend/scripts/setup_mp_admin.sql new file mode 100644 index 0000000..1687065 --- /dev/null +++ b/go-backend/scripts/setup_mp_admin.sql @@ -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')); diff --git a/go-backend/scripts/setup_server_admin.sql b/go-backend/scripts/setup_server_admin.sql new file mode 100644 index 0000000..afcd429 --- /dev/null +++ b/go-backend/scripts/setup_server_admin.sql @@ -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'; diff --git a/sojorn_app/lib/screens/auth/sign_in_screen.dart b/sojorn_app/lib/screens/auth/sign_in_screen.dart index 8a41efc..89c4af3 100644 --- a/sojorn_app/lib/screens/auth/sign_in_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_in_screen.dart @@ -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 { 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 { _supportsBiometric && _hasStoredCredentials && !_isBiometricAuthenticating && - (_turnstileToken != null || kDebugMode); // Allow bypass for development + (_altchaToken != null || kDebugMode); // Allow bypass for development Future _signIn() async { final email = _emailController.text.trim(); @@ -121,11 +121,11 @@ class _SignInScreenState extends ConsumerState { 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 { 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 { ), 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 { 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; }); }, ), diff --git a/sojorn_app/lib/services/auth_service.dart b/sojorn_app/lib/services/auth_service.dart index ab2c6f9..fdbf82e 100644 --- a/sojorn_app/lib/services/auth_service.dart +++ b/sojorn_app/lib/services/auth_service.dart @@ -200,7 +200,7 @@ class AuthService { Future> 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, diff --git a/sojorn_app/lib/widgets/altcha_widget.dart b/sojorn_app/lib/widgets/altcha_widget.dart new file mode 100644 index 0000000..c62cd23 --- /dev/null +++ b/sojorn_app/lib/widgets/altcha_widget.dart @@ -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? style; + + const AltchaWidget({ + super.key, + this.apiUrl, + required this.onVerified, + this.onError, + this.style, + }); + + @override + State createState() => _AltchaWidgetState(); +} + +class _AltchaWidgetState extends State { + bool _isLoading = true; + bool _isVerified = false; + String? _errorMessage; + String? _challenge; + String? _solution; + + @override + void initState() { + super.initState(); + _loadChallenge(); + } + + Future _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'), + ), + ], + ), + ); + } +} diff --git a/test_admin_login.sh b/test_admin_login.sh new file mode 100644 index 0000000..71c83b1 --- /dev/null +++ b/test_admin_login.sh @@ -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 .