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:
parent
cc312c7e9f
commit
a3fcfe67ab
5
.windsurf/rules/code-build.md
Normal file
5
.windsurf/rules/code-build.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
trigger: manual
|
||||||
|
---
|
||||||
|
|
||||||
|
ALWAYS EDIT FILES LOCALLY, SYNC TO THE GIT, THEN PULL ON THE SERVER - SSH MPLS - AND BUILD.
|
||||||
|
|
@ -18,8 +18,8 @@ export default function LoginPage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Use development bypass if in development mode
|
// Use development bypass if in development mode
|
||||||
const turnstileToken = process.env.NODE_ENV === 'development' ? 'BYPASS_DEV_MODE' : '';
|
const altchaToken = process.env.NODE_ENV === 'development' ? 'BYPASS_DEV_MODE' : '';
|
||||||
await login(emailRef.current, passwordRef.current, turnstileToken);
|
await login(emailRef.current, passwordRef.current, altchaToken);
|
||||||
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.');
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
async login(email: string, password: string, turnstileToken?: string) {
|
async login(email: string, password: string, altchaToken?: string) {
|
||||||
const body: Record<string, string> = { email, password };
|
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', {
|
const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// Admin login (no auth middleware - this IS the auth step)
|
||||||
r.POST("/api/v1/admin/login", adminHandler.AdminLogin)
|
r.POST("/api/v1/admin/login", adminHandler.AdminLogin)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (h *AdminHandler) AdminLogin(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
var req struct {
|
var req AdminLoginRequest
|
||||||
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 {
|
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()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
// Verify Turnstile token
|
// Verify ALTCHA token
|
||||||
if h.turnstileSecret != "" {
|
altchaService := services.NewAltchaService(h.jwtSecret)
|
||||||
// Allow bypass for development
|
remoteIP := c.ClientIP()
|
||||||
if req.TurnstileToken != "BYPASS_DEV_MODE" {
|
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
|
||||||
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 {
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !turnstileResp.Success {
|
|
||||||
log.Warn().
|
if !altchaResp.Verified {
|
||||||
Strs("errors", turnstileResp.ErrorCodes).
|
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
|
||||||
Str("hostname", turnstileResp.Hostname).
|
log.Warn().Str("email", req.Email).Str("error", errorMsg).Msg("Admin login: ALTCHA validation failed")
|
||||||
Str("action", turnstileResp.Action).
|
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
||||||
Msg("Admin login: Turnstile validation failed")
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.Info().Str("email", req.Email).Msg("Admin login: using development bypass")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up user
|
// Look up user
|
||||||
var userID uuid.UUID
|
var userID uuid.UUID
|
||||||
var passwordHash, status string
|
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`,
|
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
|
||||||
req.Email).Scan(&userID, &passwordHash, &status)
|
req.Email).Scan(&userID, &passwordHash, &status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
32
go-backend/internal/handlers/altcha_handler.go
Normal file
32
go-backend/internal/handlers/altcha_handler.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ type RegisterRequest struct {
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
Handle string `json:"handle" binding:"required,min=3"`
|
Handle string `json:"handle" binding:"required,min=3"`
|
||||||
DisplayName string `json:"display_name" binding:"required"`
|
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"`
|
AcceptTerms bool `json:"accept_terms" binding:"required,eq=true"`
|
||||||
AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"`
|
AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"`
|
||||||
EmailNewsletter bool `json:"email_newsletter"`
|
EmailNewsletter bool `json:"email_newsletter"`
|
||||||
|
|
@ -50,7 +50,7 @@ type RegisterRequest struct {
|
||||||
type LoginRequest struct {
|
type LoginRequest 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"`
|
AltchaToken string `json:"altcha_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Register(c *gin.Context) {
|
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))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
// Validate Turnstile token
|
// Validate ALTCHA token
|
||||||
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
|
altchaService := services.NewAltchaService(h.config.JWTSecret)
|
||||||
remoteIP := c.ClientIP()
|
remoteIP := c.ClientIP()
|
||||||
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
|
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
|
||||||
if err != nil {
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !turnstileResp.Success {
|
if !altchaResp.Verified {
|
||||||
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
|
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
|
||||||
log.Printf("[Auth] Turnstile validation failed: %s", errorMsg)
|
log.Printf("[Auth] ALTCHA validation failed: %s", errorMsg)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -198,19 +198,19 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
}
|
}
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
// Validate Turnstile token
|
// Validate ALTCHA token
|
||||||
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
|
altchaService := services.NewAltchaService(h.config.JWTSecret)
|
||||||
remoteIP := c.ClientIP()
|
remoteIP := c.ClientIP()
|
||||||
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
|
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
|
||||||
if err != nil {
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !turnstileResp.Success {
|
if !altchaResp.Verified {
|
||||||
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
|
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
|
||||||
log.Printf("[Auth] Login Turnstile validation failed: %s", errorMsg)
|
log.Printf("[Auth] Login ALTCHA validation failed: %s", errorMsg)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
go-backend/internal/services/altcha_service.go
Normal file
110
go-backend/internal/services/altcha_service.go
Normal 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"
|
||||||
|
}
|
||||||
5
go-backend/scripts/check_admin_accounts.sql
Normal file
5
go-backend/scripts/check_admin_accounts.sql
Normal 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;
|
||||||
1
go-backend/scripts/check_mp_users.sql
Normal file
1
go-backend/scripts/check_mp_users.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
SELECT email, status, created_at FROM users WHERE email LIKE '%mp.ls' ORDER BY created_at DESC;
|
||||||
1
go-backend/scripts/check_patrick_password.sql
Normal file
1
go-backend/scripts/check_patrick_password.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
SELECT email, encrypted_password FROM users WHERE email = 'patrick@mp.ls';
|
||||||
1
go-backend/scripts/check_patrick_role.sql
Normal file
1
go-backend/scripts/check_patrick_role.sql
Normal 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';
|
||||||
5
go-backend/scripts/create_admin.sql
Normal file
5
go-backend/scripts/create_admin.sql
Normal 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';
|
||||||
3
go-backend/scripts/create_admin_mp_profile.sql
Normal file
3
go-backend/scripts/create_admin_mp_profile.sql
Normal 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'));
|
||||||
2
go-backend/scripts/fix_admin_mp.sql
Normal file
2
go-backend/scripts/fix_admin_mp.sql
Normal 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');
|
||||||
2
go-backend/scripts/reset_patrick_password.sql
Normal file
2
go-backend/scripts/reset_patrick_password.sql
Normal 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';
|
||||||
1
go-backend/scripts/set_admin_role.sql
Normal file
1
go-backend/scripts/set_admin_role.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
UPDATE profiles SET role = 'admin' WHERE id IN (SELECT id FROM users WHERE email = 'admin@sojorn.net');
|
||||||
12
go-backend/scripts/setup_mp_admin.sql
Normal file
12
go-backend/scripts/setup_mp_admin.sql
Normal 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'));
|
||||||
4
go-backend/scripts/setup_server_admin.sql
Normal file
4
go-backend/scripts/setup_server_admin.sql
Normal 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';
|
||||||
|
|
@ -6,7 +6,7 @@ import 'package:local_auth/local_auth.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../theme/app_theme.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_button.dart';
|
||||||
import '../../widgets/sojorn_input.dart';
|
import '../../widgets/sojorn_input.dart';
|
||||||
import 'sign_up_screen.dart';
|
import 'sign_up_screen.dart';
|
||||||
|
|
@ -32,7 +32,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
bool _saveCredentials = true;
|
bool _saveCredentials = true;
|
||||||
String? _storedEmail;
|
String? _storedEmail;
|
||||||
String? _storedPassword;
|
String? _storedPassword;
|
||||||
String? _turnstileToken;
|
String? _altchaToken;
|
||||||
|
|
||||||
// Turnstile site key from environment or default production key
|
// Turnstile site key from environment or default production key
|
||||||
static const String _turnstileSiteKey = String.fromEnvironment(
|
static const String _turnstileSiteKey = String.fromEnvironment(
|
||||||
|
|
@ -101,7 +101,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
_supportsBiometric &&
|
_supportsBiometric &&
|
||||||
_hasStoredCredentials &&
|
_hasStoredCredentials &&
|
||||||
!_isBiometricAuthenticating &&
|
!_isBiometricAuthenticating &&
|
||||||
(_turnstileToken != null || kDebugMode); // Allow bypass for development
|
(_altchaToken != null || kDebugMode); // Allow bypass for development
|
||||||
|
|
||||||
Future<void> _signIn() async {
|
Future<void> _signIn() async {
|
||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
|
|
@ -121,11 +121,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Turnstile token
|
// Validate ALTCHA token
|
||||||
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
|
if (_altchaToken == null || _altchaToken!.isEmpty) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
// Allow bypass for development
|
// Allow bypass for development
|
||||||
_turnstileToken = "BYPASS_DEV_MODE";
|
_altchaToken = "BYPASS_DEV_MODE";
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = 'Please complete the security verification';
|
_errorMessage = 'Please complete the security verification';
|
||||||
|
|
@ -144,21 +144,23 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
await authService.signInWithGoBackend(
|
await authService.signInWithGoBackend(
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
turnstileToken: _turnstileToken!,
|
altchaToken: _altchaToken!,
|
||||||
);
|
);
|
||||||
await _persistCredentials(email, password);
|
await _persistCredentials(email, password);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
// Reset Turnstile token on error so user must re-verify
|
// Reset ALTCHA token on error so user must re-verify
|
||||||
_turnstileToken = null;
|
_altchaToken = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
// Reset ALTCHA token after successful login
|
||||||
|
_altchaToken = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -433,11 +435,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
||||||
// Turnstile CAPTCHA
|
// ALTCHA verification
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _turnstileToken != null
|
color: _altchaToken != null
|
||||||
? AppTheme.success
|
? AppTheme.success
|
||||||
: AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
: AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
|
|
@ -447,12 +449,16 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (_turnstileToken == null) ...[
|
if (_altchaToken == null) ...[
|
||||||
TurnstileWidget(
|
AltchaWidget(
|
||||||
siteKey: _turnstileSiteKey,
|
onVerified: (token) {
|
||||||
onToken: (token) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_turnstileToken = token;
|
_altchaToken = token;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = error;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ class AuthService {
|
||||||
Future<Map<String, dynamic>> signInWithGoBackend({
|
Future<Map<String, dynamic>> signInWithGoBackend({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
required String turnstileToken,
|
required String altchaToken,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
|
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
|
||||||
|
|
@ -214,7 +214,7 @@ class AuthService {
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'email': email,
|
'email': email,
|
||||||
'password': password,
|
'password': password,
|
||||||
'turnstile_token': turnstileToken,
|
'altcha_token': altchaToken,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -271,7 +271,7 @@ class AuthService {
|
||||||
required String password,
|
required String password,
|
||||||
required String handle,
|
required String handle,
|
||||||
required String displayName,
|
required String displayName,
|
||||||
required String turnstileToken,
|
required String altchaToken,
|
||||||
required bool acceptTerms,
|
required bool acceptTerms,
|
||||||
required bool acceptPrivacy,
|
required bool acceptPrivacy,
|
||||||
bool emailNewsletter = false,
|
bool emailNewsletter = false,
|
||||||
|
|
@ -289,7 +289,7 @@ class AuthService {
|
||||||
'password': password,
|
'password': password,
|
||||||
'handle': handle,
|
'handle': handle,
|
||||||
'display_name': displayName,
|
'display_name': displayName,
|
||||||
'turnstile_token': turnstileToken,
|
'altcha_token': altchaToken,
|
||||||
'accept_terms': acceptTerms,
|
'accept_terms': acceptTerms,
|
||||||
'accept_privacy': acceptPrivacy,
|
'accept_privacy': acceptPrivacy,
|
||||||
'email_newsletter': emailNewsletter,
|
'email_newsletter': emailNewsletter,
|
||||||
|
|
|
||||||
183
sojorn_app/lib/widgets/altcha_widget.dart
Normal file
183
sojorn_app/lib/widgets/altcha_widget.dart
Normal 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
23
test_admin_login.sh
Normal 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 .
|
||||||
Loading…
Reference in a new issue