package auth import ( "encoding/json" "log/slog" "net/http" "os" "regexp" "time" ) // htmlTagRegex strips HTML/script tags from user input (M5 XSS prevention). var htmlTagRegex = regexp.MustCompile(`<[^>]*>`) // EmailSendFunc is a callback for sending verification emails. // Signature: func(toEmail, userName, code string) error type EmailSendFunc func(toEmail, userName, code string) error // HandleRegister processes new tenant + owner registration. // POST /api/auth/register { email, password, name, org_name, org_slug } // Returns verification_required — user must verify email before login. // If emailFn is nil, verification code is returned in response (dev mode). func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []byte, emailFn EmailSendFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // SEC-M4: Server-side registration gate if os.Getenv("SOC_REGISTRATION_OPEN") == "false" { http.Error(w, `{"error":"registration is closed — contact admin for an invitation"}`, http.StatusForbidden) return } var req struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` OrgName string `json:"org_name"` OrgSlug string `json:"org_slug"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) return } if req.Email == "" || req.Password == "" || req.OrgName == "" || req.OrgSlug == "" { http.Error(w, `{"error":"email, password, org_name, org_slug are required"}`, http.StatusBadRequest) return } if len(req.Password) < 8 { http.Error(w, `{"error":"password must be at least 8 characters"}`, http.StatusBadRequest) return } if req.Name == "" { req.Name = req.Email } // SEC-M5: Strip HTML tags from user input to prevent stored XSS req.Name = htmlTagRegex.ReplaceAllString(req.Name, "") req.OrgName = htmlTagRegex.ReplaceAllString(req.OrgName, "") // Create user first (admin of new tenant) user, err := userStore.CreateUser(req.Email, req.Name, req.Password, "admin") if err != nil { if err == ErrUserExists { http.Error(w, `{"error":"email already registered"}`, http.StatusConflict) return } http.Error(w, `{"error":"failed to create user"}`, http.StatusInternalServerError) return } // Create tenant tenant, err := tenantStore.CreateTenant(req.OrgName, req.OrgSlug, user.ID, "free") if err != nil { if err == ErrTenantExists { http.Error(w, `{"error":"organization slug already taken"}`, http.StatusConflict) return } http.Error(w, `{"error":"failed to create organization"}`, http.StatusInternalServerError) return } // Update user with tenant_id // CRITICAL: pgx/v5 requires $1/$2 placeholders, NOT ? (silently fails with ?) if userStore.db != nil { if _, err := userStore.db.Exec(`UPDATE users SET tenant_id = $1 WHERE id = $2`, tenant.ID, user.ID); err != nil { slog.Error("register: failed to set tenant_id on user", "user", user.ID, "tenant", tenant.ID, "error", err) } // Also update in-memory cache userStore.mu.Lock() if u, ok := userStore.users[user.Email]; ok { u.TenantID = tenant.ID } userStore.mu.Unlock() } // Generate verification code code, err := userStore.SetVerifyToken(req.Email) if err != nil { http.Error(w, `{"error":"failed to generate verification code"}`, http.StatusInternalServerError) return } // Send verification email if email service is configured resp := map[string]interface{}{ "status": "verification_required", "email": req.Email, "message": "Verification code sent to your email", "tenant": tenant, } if emailFn != nil { if err := emailFn(req.Email, req.Name, code); err != nil { slog.Error("failed to send verification email", "email", req.Email, "error", err) // Still return success — code is in DB, user can retry } } else { // SEC: Never expose verification code in API response. // Log server-side only for development debugging. slog.Warn("email service not configured — verification code logged (dev only)", "email", req.Email, "code", code) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } } // HandleVerifyEmail validates the verification code and issues JWT. // POST /api/auth/verify { email, code } func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) return } if req.Email == "" || req.Code == "" { http.Error(w, `{"error":"email and code required"}`, http.StatusBadRequest) return } if err := userStore.VerifyEmail(req.Email, req.Code); err != nil { if err == ErrInvalidVerifyCode { http.Error(w, `{"error":"invalid or expired verification code"}`, http.StatusBadRequest) return } http.Error(w, `{"error":"verification failed"}`, http.StatusInternalServerError) return } // Get user and tenant user, err := userStore.GetByEmail(req.Email) if err != nil { http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) return } // Find tenant for this user // CRITICAL: pgx/v5 requires $1 placeholder, NOT ? var tenantID string if userStore.db != nil { if err := userStore.db.QueryRow(`SELECT tenant_id FROM users WHERE id = $1`, user.ID).Scan(&tenantID); err != nil { slog.Warn("verify: could not read tenant_id from DB", "user", user.ID, "error", err) } } // Fallback: check in-memory user object if tenantID == "" && user.TenantID != "" { tenantID = user.TenantID } // Issue JWT with tenant context accessToken, err := Sign(Claims{ Sub: user.Email, Role: user.Role, TenantID: tenantID, TokenType: "access", Exp: time.Now().Add(15 * time.Minute).Unix(), }, jwtSecret) if err != nil { http.Error(w, `{"error":"failed to issue token"}`, http.StatusInternalServerError) return } refreshToken, _ := Sign(Claims{ Sub: user.Email, Role: user.Role, TenantID: tenantID, TokenType: "refresh", Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), }, jwtSecret) var tenant *Tenant if tenantID != "" { tenant, _ = tenantStore.GetTenant(tenantID) } // SEC: H1 - Use httpOnly Cookies instead of returning JSON tokens http.SetCookie(w, &http.Cookie{ Name: "syntrex_token", Value: accessToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 900, }) http.SetCookie(w, &http.Cookie{ Name: "syntrex_refresh", Value: refreshToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 7 * 24 * 3600, }) // SEC: M2 - Generate stateless CSRF token csrfToken := hmacSign([]byte(accessToken), jwtSecret)[:32] w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "csrf_token": csrfToken, "user": user, "tenant": tenant, }) } } // HandleGetTenant returns the current tenant info. // GET /api/auth/tenant func HandleGetTenant(tenantStore *TenantStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil || claims.TenantID == "" { http.Error(w, `{"error":"no tenant context"}`, http.StatusForbidden) return } tenant, err := tenantStore.GetTenant(claims.TenantID) if err != nil { http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound) return } plan := tenant.GetPlan() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "tenant": tenant, "plan": plan, "usage": map[string]interface{}{ "events_this_month": tenant.EventsThisMonth, "events_limit": plan.MaxEventsMonth, "usage_percent": usagePercent(tenant.EventsThisMonth, plan.MaxEventsMonth), }, }) } } // HandleUpdateTenantPlan upgrades/downgrades the tenant plan. // POST /api/auth/tenant/plan { plan_id } // SEC: Only allows downgrade to 'free' without payment. Paid upgrades require // Stripe webhook confirmation (HandleStripeWebhook). This prevents users from // clicking "Перейти" on paid plans and getting access without payment. func HandleUpdateTenantPlan(tenantStore *TenantStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil || claims.Role != "admin" { http.Error(w, `{"error":"admin role required"}`, http.StatusForbidden) return } if claims.TenantID == "" { http.Error(w, `{"error":"no tenant context"}`, http.StatusForbidden) return } var req struct { PlanID string `json:"plan_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } // SEC: Block direct upgrades to paid plans — only Stripe webhook can do that if req.PlanID != "free" { http.Error(w, `{"error":"paid plan upgrades require payment — visit syntrex.pro/pricing"}`, http.StatusPaymentRequired) return } if err := tenantStore.UpdatePlan(claims.TenantID, req.PlanID); err != nil { slog.Error("plan update failed", "tenant_id", claims.TenantID, "plan", req.PlanID, "error", err) http.Error(w, `{"error":"plan update failed"}`, http.StatusBadRequest) return } tenant, _ := tenantStore.GetTenant(claims.TenantID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "tenant": tenant, "plan": tenant.GetPlan(), }) } } // HandleListPlans returns all available pricing plans. // GET /api/auth/plans func HandleListPlans() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plans := make([]Plan, 0, len(DefaultPlans)) order := []string{"free", "starter", "professional", "enterprise"} for _, id := range order { if p, ok := DefaultPlans[id]; ok { plans = append(plans, p) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"plans": plans}) } } // HandleBillingStatus returns the billing status for the tenant. // GET /api/auth/billing func HandleBillingStatus(tenantStore *TenantStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil || claims.TenantID == "" { http.Error(w, `{"error":"no tenant context"}`, http.StatusForbidden) return } tenant, err := tenantStore.GetTenant(claims.TenantID) if err != nil { http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound) return } plan := tenant.GetPlan() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "plan": plan, "payment_customer_id": tenant.PaymentCustomerID, "payment_sub_id": tenant.PaymentSubID, "events_used": tenant.EventsThisMonth, "events_limit": plan.MaxEventsMonth, "usage_percent": usagePercent(tenant.EventsThisMonth, plan.MaxEventsMonth), "next_reset": tenant.MonthResetAt, }) } } // HandleStripeWebhook processes Stripe webhook events. // POST /api/billing/webhook func HandleStripeWebhook(tenantStore *TenantStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var evt struct { Type string `json:"type"` Data struct { Object struct { CustomerID string `json:"customer"` SubscriptionID string `json:"id"` Status string `json:"status"` Metadata struct { TenantID string `json:"tenant_id"` PlanID string `json:"plan_id"` } `json:"metadata"` } `json:"object"` } `json:"data"` } if err := json.NewDecoder(r.Body).Decode(&evt); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } tenantID := evt.Data.Object.Metadata.TenantID switch evt.Type { case "customer.subscription.created", "customer.subscription.updated": if tenantID != "" { tenantStore.SetStripeIDs(tenantID, evt.Data.Object.CustomerID, evt.Data.Object.SubscriptionID) if planID := evt.Data.Object.Metadata.PlanID; planID != "" { tenantStore.UpdatePlan(tenantID, planID) } } case "customer.subscription.deleted": if tenantID != "" { tenantStore.UpdatePlan(tenantID, "starter") tenantStore.SetStripeIDs(tenantID, evt.Data.Object.CustomerID, "") } } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"received":true}`)) } } func usagePercent(used, limit int) float64 { if limit <= 0 { return 0 } pct := float64(used) / float64(limit) * 100 if pct > 100 { return 100 } return pct } // HandleListTenants returns a list of all tenants for superadmin dashboard. // GET /api/auth/tenants func HandleListTenants(tenantStore *TenantStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil || claims.Role != "superadmin" { http.Error(w, `{"error":"forbidden: superadmin only"}`, http.StatusForbidden) return } tenants := tenantStore.ListTenants() type tenantResp struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` PlanID string `json:"plan_id"` Active bool `json:"active"` } res := make([]tenantResp, len(tenants)) for i, t := range tenants { res[i] = tenantResp{ ID: t.ID, Name: t.Name, Slug: t.Slug, PlanID: t.PlanID, Active: t.Active, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } } // HandleImpersonateTenant allows a superadmin to switch their working tenant context. // POST /api/auth/impersonate func HandleImpersonateTenant(tenantStore *TenantStore, jwtSecret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil || claims.Role != "superadmin" { http.Error(w, `{"error":"forbidden: superadmin only"}`, http.StatusForbidden) return } var req struct { TenantID string `json:"tenant_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) return } // Verify target tenant is valid if _, err := tenantStore.GetTenant(req.TenantID); err != nil { http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound) return } // Issue new token with updated TenantID accessClaims := Claims{ Sub: claims.Sub, Role: claims.Role, // Preserves superadmin explicitly TenantID: req.TenantID, // The impersonated tenant ID TokenType: "access", Exp: time.Now().Add(15 * time.Minute).Unix(), } refreshClaims := Claims{ Sub: claims.Sub, Role: claims.Role, TenantID: req.TenantID, TokenType: "refresh", Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), } accessToken, err := Sign(accessClaims, jwtSecret) if err != nil { http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError) return } refreshToken, err := Sign(refreshClaims, jwtSecret) if err != nil { http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "syntrex_token", Value: accessToken, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: 900, }) http.SetCookie(w, &http.Cookie{ Name: "syntrex_refresh", Value: refreshToken, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: 7 * 24 * 3600, }) // Also provide CSRF token for strict SPA requirements csrfToken := hmacSign([]byte(accessToken), jwtSecret)[:32] w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ "status": "success", "tenant_id": req.TenantID, "csrf_token": csrfToken, }) } }