Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,9 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/apperrors"
|
||||
@@ -74,11 +74,11 @@ func NewSubscriptionService(
|
||||
appleClient, err := NewAppleIAPClient(cfg.AppleIAP)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrIAPNotConfigured) {
|
||||
log.Printf("Warning: Failed to initialize Apple IAP client: %v", err)
|
||||
log.Warn().Err(err).Msg("Failed to initialize Apple IAP client")
|
||||
}
|
||||
} else {
|
||||
svc.appleClient = appleClient
|
||||
log.Println("Apple IAP validation client initialized")
|
||||
log.Info().Msg("Apple IAP validation client initialized")
|
||||
}
|
||||
|
||||
// Initialize Google IAP client
|
||||
@@ -86,11 +86,11 @@ func NewSubscriptionService(
|
||||
googleClient, err := NewGoogleIAPClient(ctx, cfg.GoogleIAP)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrIAPNotConfigured) {
|
||||
log.Printf("Warning: Failed to initialize Google IAP client: %v", err)
|
||||
log.Warn().Err(err).Msg("Failed to initialize Google IAP client")
|
||||
}
|
||||
} else {
|
||||
svc.googleClient = googleClient
|
||||
log.Println("Google IAP validation client initialized")
|
||||
log.Info().Msg("Google IAP validation client initialized")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,8 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getUserUsage calculates current usage for a user
|
||||
// getUserUsage calculates current usage for a user.
|
||||
// Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)).
|
||||
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
|
||||
residences, err := s.residenceRepo.FindOwnedByUser(userID)
|
||||
if err != nil {
|
||||
@@ -181,26 +182,26 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
|
||||
}
|
||||
propertiesCount := int64(len(residences))
|
||||
|
||||
// Count tasks, contractors, and documents across all user's residences
|
||||
var tasksCount, contractorsCount, documentsCount int64
|
||||
for _, r := range residences {
|
||||
tc, err := s.taskRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
tasksCount += tc
|
||||
// Collect residence IDs for batch queries
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
cc, err := s.contractorRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
contractorsCount += cc
|
||||
// Count tasks, contractors, and documents across all residences with single queries each
|
||||
tasksCount, err := s.taskRepo.CountByResidenceIDs(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
dc, err := s.documentRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
documentsCount += dc
|
||||
contractorsCount, err := s.contractorRepo.CountByResidenceIDs(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
documentsCount, err := s.documentRepo.CountByResidenceIDs(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return &UsageResponse{
|
||||
@@ -342,46 +343,40 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Validate with Apple if client is configured
|
||||
var expiresAt time.Time
|
||||
if s.appleClient != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result *AppleValidationResult
|
||||
var err error
|
||||
|
||||
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
|
||||
if transactionID != "" {
|
||||
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
|
||||
} else if receiptData != "" {
|
||||
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Log the validation error
|
||||
log.Printf("Apple validation warning for user %d: %v", userID, err)
|
||||
|
||||
// Check if it's a fatal error
|
||||
if errors.Is(err, ErrInvalidReceipt) || errors.Is(err, ErrSubscriptionCancelled) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For other errors (network, etc.), fall back with shorter expiry
|
||||
expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback
|
||||
} else if result != nil {
|
||||
// Use the expiration date from Apple
|
||||
expiresAt = result.ExpiresAt
|
||||
log.Printf("Apple purchase validated for user %d: product=%s, expires=%v, env=%s",
|
||||
userID, result.ProductID, result.ExpiresAt, result.Environment)
|
||||
}
|
||||
} else {
|
||||
// Apple validation not configured - trust client but log warning
|
||||
log.Printf("Warning: Apple IAP validation not configured, trusting client for user %d", userID)
|
||||
expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default
|
||||
// Apple IAP client must be configured to validate purchases.
|
||||
// Without server-side validation, we cannot trust client-provided receipts.
|
||||
if s.appleClient == nil {
|
||||
log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase")
|
||||
return nil, apperrors.BadRequest("error.iap_validation_not_configured")
|
||||
}
|
||||
|
||||
// Upgrade to Pro with the determined expiration
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result *AppleValidationResult
|
||||
var err error
|
||||
|
||||
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
|
||||
if transactionID != "" {
|
||||
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
|
||||
} else if receiptData != "" {
|
||||
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Validation failed -- do NOT fall through to grant Pro.
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, apperrors.BadRequest("error.no_receipt_or_transaction")
|
||||
}
|
||||
|
||||
expiresAt := result.ExpiresAt
|
||||
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated")
|
||||
|
||||
// Upgrade to Pro with the validated expiration
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -397,59 +392,48 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Validate the purchase with Google if client is configured
|
||||
var expiresAt time.Time
|
||||
if s.googleClient != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result *GoogleValidationResult
|
||||
var err error
|
||||
|
||||
// If productID is provided, use it directly; otherwise try known IDs
|
||||
if productID != "" {
|
||||
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
|
||||
} else {
|
||||
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Log the validation error
|
||||
log.Printf("Google purchase validation warning for user %d: %v", userID, err)
|
||||
|
||||
// Check if it's a fatal error
|
||||
if errors.Is(err, ErrInvalidPurchaseToken) || errors.Is(err, ErrSubscriptionCancelled) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrSubscriptionExpired) {
|
||||
// Subscription expired - still allow but set past expiry
|
||||
expiresAt = time.Now().UTC().Add(-1 * time.Hour)
|
||||
} else {
|
||||
// For other errors, fall back with shorter expiry
|
||||
expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback
|
||||
}
|
||||
} else if result != nil {
|
||||
// Use the expiration date from Google
|
||||
expiresAt = result.ExpiresAt
|
||||
log.Printf("Google purchase validated for user %d: product=%s, expires=%v, autoRenew=%v",
|
||||
userID, result.ProductID, result.ExpiresAt, result.AutoRenewing)
|
||||
|
||||
// Acknowledge the subscription if not already acknowledged
|
||||
if !result.AcknowledgedState {
|
||||
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil {
|
||||
log.Printf("Warning: Failed to acknowledge subscription for user %d: %v", userID, err)
|
||||
// Don't fail the purchase, just log the warning
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Google validation not configured - trust client but log warning
|
||||
log.Printf("Warning: Google IAP validation not configured, trusting client for user %d", userID)
|
||||
expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default
|
||||
// Google IAP client must be configured to validate purchases.
|
||||
// Without server-side validation, we cannot trust client-provided tokens.
|
||||
if s.googleClient == nil {
|
||||
log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase")
|
||||
return nil, apperrors.BadRequest("error.iap_validation_not_configured")
|
||||
}
|
||||
|
||||
// Upgrade to Pro with the determined expiration
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result *GoogleValidationResult
|
||||
var err error
|
||||
|
||||
// If productID is provided, use it directly; otherwise try known IDs
|
||||
if productID != "" {
|
||||
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
|
||||
} else {
|
||||
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Validation failed -- do NOT fall through to grant Pro.
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, apperrors.BadRequest("error.no_purchase_token")
|
||||
}
|
||||
|
||||
expiresAt := result.ExpiresAt
|
||||
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Bool("auto_renew", result.AutoRenewing).Msg("Google purchase validated")
|
||||
|
||||
// Acknowledge the subscription if not already acknowledged
|
||||
if !result.AcknowledgedState {
|
||||
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil {
|
||||
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to acknowledge Google subscription")
|
||||
// Don't fail the purchase, just log the warning
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade to Pro with the validated expiration
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -654,5 +638,5 @@ type ProcessPurchaseRequest struct {
|
||||
TransactionID string `json:"transaction_id"` // iOS StoreKit 2 transaction ID
|
||||
PurchaseToken string `json:"purchase_token"` // Android
|
||||
ProductID string `json:"product_id"` // Android (optional, helps identify subscription)
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
Platform string `json:"platform" validate:"required,oneof=ios android"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user