Files
honeyDueAPI/internal/services/iap_validation.go
Trey t 42a5533a56 Fix 113 hardening issues across entire Go backend
Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:13 -05:00

566 lines
18 KiB
Go

package services
import (
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/oauth2/google"
"google.golang.org/api/androidpublisher/v3"
"google.golang.org/api/option"
"github.com/treytartt/honeydue-api/internal/config"
)
// IAP validation errors
var (
ErrIAPNotConfigured = errors.New("IAP validation not configured")
ErrInvalidReceipt = errors.New("invalid receipt data")
ErrInvalidPurchaseToken = errors.New("invalid purchase token")
ErrSubscriptionExpired = errors.New("subscription has expired")
ErrSubscriptionCancelled = errors.New("subscription was cancelled")
ErrInvalidProductID = errors.New("invalid product ID")
ErrReceiptValidationFailed = errors.New("receipt validation failed")
)
// AppleIAPClient handles Apple App Store Server API validation
type AppleIAPClient struct {
keyID string
issuerID string
bundleID string
privateKey *ecdsa.PrivateKey
sandbox bool
httpClient *http.Client // P-07: Reused across requests
}
// GoogleIAPClient handles Google Play Developer API validation
type GoogleIAPClient struct {
service *androidpublisher.Service
packageName string
}
// AppleTransactionInfo represents decoded transaction info from Apple
type AppleTransactionInfo struct {
TransactionID string `json:"transactionId"`
OriginalTransactionID string `json:"originalTransactionId"`
ProductID string `json:"productId"`
PurchaseDate int64 `json:"purchaseDate"`
ExpiresDate int64 `json:"expiresDate"`
Type string `json:"type"` // "Auto-Renewable Subscription"
InAppOwnershipType string `json:"inAppOwnershipType"`
SignedDate int64 `json:"signedDate"`
Environment string `json:"environment"` // "Sandbox" or "Production"
BundleID string `json:"bundleId"`
RevocationDate *int64 `json:"revocationDate,omitempty"`
RevocationReason *int `json:"revocationReason,omitempty"`
}
// AppleValidationResult contains the result of Apple receipt validation
type AppleValidationResult struct {
Valid bool
TransactionID string
ProductID string
ExpiresAt time.Time
IsTrialPeriod bool
AutoRenewEnabled bool
Environment string
}
// GoogleValidationResult contains the result of Google token validation
type GoogleValidationResult struct {
Valid bool
OrderID string
ProductID string
ExpiresAt time.Time
AutoRenewing bool
PaymentState int64
CancelReason int64
AcknowledgedState bool
}
// NewAppleIAPClient creates a new Apple IAP validation client
func NewAppleIAPClient(cfg config.AppleIAPConfig) (*AppleIAPClient, error) {
if cfg.KeyPath == "" || cfg.KeyID == "" || cfg.IssuerID == "" || cfg.BundleID == "" {
return nil, ErrIAPNotConfigured
}
// Read the private key
keyData, err := os.ReadFile(cfg.KeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read Apple IAP key: %w", err)
}
// Parse the PEM-encoded private key
block, _ := pem.Decode(keyData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not ECDSA")
}
return &AppleIAPClient{
keyID: cfg.KeyID,
issuerID: cfg.IssuerID,
bundleID: cfg.BundleID,
privateKey: ecdsaKey,
sandbox: cfg.Sandbox,
httpClient: &http.Client{Timeout: 30 * time.Second}, // P-07: Single client reused across requests
}, nil
}
// generateJWT creates a signed JWT for App Store Server API authentication
func (c *AppleIAPClient) generateJWT() (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"iss": c.issuerID,
"iat": now.Unix(),
"exp": now.Add(60 * time.Minute).Unix(),
"aud": "appstoreconnect-v1",
"bid": c.bundleID,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = c.keyID
return token.SignedString(c.privateKey)
}
// getBaseURL returns the appropriate App Store Server API URL
func (c *AppleIAPClient) getBaseURL() string {
if c.sandbox {
return "https://api.storekit-sandbox.itunes.apple.com"
}
return "https://api.storekit.itunes.apple.com"
}
// ValidateTransaction validates a transaction ID with Apple's servers
func (c *AppleIAPClient) ValidateTransaction(ctx context.Context, transactionID string) (*AppleValidationResult, error) {
token, err := c.generateJWT()
if err != nil {
return nil, fmt.Errorf("failed to generate JWT: %w", err)
}
// Get transaction info
url := fmt.Sprintf("%s/inApps/v1/transactions/%s", c.getBaseURL(), transactionID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
// P-07: Reuse the single http.Client instead of creating one per request
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call Apple API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Apple API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse the response
var response struct {
SignedTransactionInfo string `json:"signedTransactionInfo"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Decode the signed transaction info (it's a JWS)
transactionInfo, err := c.decodeSignedTransaction(response.SignedTransactionInfo)
if err != nil {
return nil, err
}
// Validate bundle ID
if transactionInfo.BundleID != c.bundleID {
return nil, ErrInvalidReceipt
}
// Check if revoked
if transactionInfo.RevocationDate != nil {
return &AppleValidationResult{
Valid: false,
}, ErrSubscriptionCancelled
}
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
return &AppleValidationResult{
Valid: true,
TransactionID: transactionInfo.TransactionID,
ProductID: transactionInfo.ProductID,
ExpiresAt: expiresAt,
Environment: transactionInfo.Environment,
}, nil
}
// ValidateReceipt validates an App Store receipt (base64-encoded)
// This is the modern approach using the /verifyReceipt endpoint compatibility
// For new integrations, prefer ValidateTransaction with transaction IDs
func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) {
token, err := c.generateJWT()
if err != nil {
return nil, fmt.Errorf("failed to generate JWT: %w", err)
}
// Decode the receipt to get transaction history
// The receiptData from StoreKit 2 is typically a signed transaction
// For StoreKit 1, we need to use the legacy verifyReceipt endpoint
// Try to decode as a signed transaction first (StoreKit 2)
if strings.Contains(receiptData, ".") {
// This looks like a JWS - try to decode it directly
transactionInfo, err := c.decodeSignedTransaction(receiptData)
if err == nil {
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
return &AppleValidationResult{
Valid: true,
TransactionID: transactionInfo.TransactionID,
ProductID: transactionInfo.ProductID,
ExpiresAt: expiresAt,
Environment: transactionInfo.Environment,
}, nil
}
}
// For legacy receipts, use the subscription status endpoint
// First, we need to find the original transaction ID from the receipt
// This requires using the App Store Server API's history endpoint
url := fmt.Sprintf("%s/inApps/v1/history/%s", c.getBaseURL(), c.bundleID)
// Create request body
requestBody := struct {
ReceiptData string `json:"receipt-data"`
}{
ReceiptData: receiptData,
}
jsonBody, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
// P-07: Reuse the single http.Client
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call Apple API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
// If history endpoint fails, try legacy validation
return c.validateLegacyReceipt(ctx, receiptData)
}
var historyResponse struct {
SignedTransactions []string `json:"signedTransactions"`
HasMore bool `json:"hasMore"`
}
if err := json.Unmarshal(body, &historyResponse); err != nil {
return nil, fmt.Errorf("failed to parse history response: %w", err)
}
if len(historyResponse.SignedTransactions) == 0 {
return nil, ErrInvalidReceipt
}
// Get the most recent transaction
latestTransaction := historyResponse.SignedTransactions[len(historyResponse.SignedTransactions)-1]
transactionInfo, err := c.decodeSignedTransaction(latestTransaction)
if err != nil {
return nil, err
}
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
return &AppleValidationResult{
Valid: true,
TransactionID: transactionInfo.TransactionID,
ProductID: transactionInfo.ProductID,
ExpiresAt: expiresAt,
Environment: transactionInfo.Environment,
}, nil
}
// validateLegacyReceipt uses Apple's legacy verifyReceipt endpoint.
// It delegates to validateLegacyReceiptWithSandbox using the client's
// configured sandbox setting. This avoids mutating the struct field
// during the sandbox-retry flow, which caused a data race when
// multiple goroutines shared the same AppleIAPClient.
func (c *AppleIAPClient) validateLegacyReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) {
return c.validateLegacyReceiptWithSandbox(ctx, receiptData, c.sandbox)
}
// validateLegacyReceiptWithSandbox performs legacy receipt validation against
// the specified environment. The sandbox parameter is passed by value (not
// stored on the struct) so this function is safe for concurrent use.
func (c *AppleIAPClient) validateLegacyReceiptWithSandbox(ctx context.Context, receiptData string, useSandbox bool) (*AppleValidationResult, error) {
url := "https://buy.itunes.apple.com/verifyReceipt"
if useSandbox {
url = "https://sandbox.itunes.apple.com/verifyReceipt"
}
requestBody := map[string]string{
"receipt-data": receiptData,
}
jsonBody, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// P-07: Reuse the single http.Client
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call Apple verifyReceipt: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var legacyResponse struct {
Status int `json:"status"`
LatestReceiptInfo []struct {
TransactionID string `json:"transaction_id"`
OriginalTransactionID string `json:"original_transaction_id"`
ProductID string `json:"product_id"`
ExpiresDateMs string `json:"expires_date_ms"`
IsTrialPeriod string `json:"is_trial_period"`
} `json:"latest_receipt_info"`
PendingRenewalInfo []struct {
AutoRenewStatus string `json:"auto_renew_status"`
} `json:"pending_renewal_info"`
Environment string `json:"environment"`
}
if err := json.Unmarshal(body, &legacyResponse); err != nil {
return nil, fmt.Errorf("failed to parse legacy response: %w", err)
}
// Status codes: 0 = valid, 21007 = sandbox receipt on production, 21008 = production receipt on sandbox
if legacyResponse.Status == 21007 && !useSandbox {
// Retry with sandbox -- pass sandbox=true as a parameter instead of
// mutating c.sandbox, which avoids a data race.
return c.validateLegacyReceiptWithSandbox(ctx, receiptData, true)
}
if legacyResponse.Status != 0 {
return nil, fmt.Errorf("%w: status code %d", ErrReceiptValidationFailed, legacyResponse.Status)
}
if len(legacyResponse.LatestReceiptInfo) == 0 {
return nil, ErrInvalidReceipt
}
// Find the latest non-expired subscription
var latestReceipt = legacyResponse.LatestReceiptInfo[len(legacyResponse.LatestReceiptInfo)-1]
var expiresMs int64
fmt.Sscanf(latestReceipt.ExpiresDateMs, "%d", &expiresMs)
expiresAt := time.Unix(expiresMs/1000, 0)
autoRenew := false
if len(legacyResponse.PendingRenewalInfo) > 0 {
autoRenew = legacyResponse.PendingRenewalInfo[0].AutoRenewStatus == "1"
}
return &AppleValidationResult{
Valid: true,
TransactionID: latestReceipt.TransactionID,
ProductID: latestReceipt.ProductID,
ExpiresAt: expiresAt,
IsTrialPeriod: latestReceipt.IsTrialPeriod == "true",
AutoRenewEnabled: autoRenew,
Environment: legacyResponse.Environment,
}, nil
}
// decodeSignedTransaction decodes a JWS signed transaction from Apple
func (c *AppleIAPClient) decodeSignedTransaction(signedTransaction string) (*AppleTransactionInfo, error) {
// The signed transaction is a JWS (JSON Web Signature)
// Format: header.payload.signature
parts := strings.Split(signedTransaction, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
// Decode the payload (base64url encoded)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
}
var transactionInfo AppleTransactionInfo
if err := json.Unmarshal(payload, &transactionInfo); err != nil {
return nil, fmt.Errorf("failed to parse transaction info: %w", err)
}
return &transactionInfo, nil
}
// NewGoogleIAPClient creates a new Google IAP validation client
func NewGoogleIAPClient(ctx context.Context, cfg config.GoogleIAPConfig) (*GoogleIAPClient, error) {
if cfg.ServiceAccountPath == "" || cfg.PackageName == "" {
return nil, ErrIAPNotConfigured
}
// Read the service account JSON
serviceAccountJSON, err := os.ReadFile(cfg.ServiceAccountPath)
if err != nil {
return nil, fmt.Errorf("failed to read service account file: %w", err)
}
// Create credentials
creds, err := google.CredentialsFromJSON(ctx, serviceAccountJSON, androidpublisher.AndroidpublisherScope)
if err != nil {
return nil, fmt.Errorf("failed to create credentials: %w", err)
}
// Create the Android Publisher service
service, err := androidpublisher.NewService(ctx, option.WithCredentials(creds))
if err != nil {
return nil, fmt.Errorf("failed to create Android Publisher service: %w", err)
}
return &GoogleIAPClient{
service: service,
packageName: cfg.PackageName,
}, nil
}
// ValidateSubscription validates a Google Play subscription purchase token
func (c *GoogleIAPClient) ValidateSubscription(ctx context.Context, subscriptionID, purchaseToken string) (*GoogleValidationResult, error) {
// Call Google Play Developer API to verify the subscription
subscription, err := c.service.Purchases.Subscriptions.Get(
c.packageName,
subscriptionID,
purchaseToken,
).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("failed to validate subscription: %w", err)
}
// ExpiryTimeMillis is in milliseconds
expiresAt := time.Unix(subscription.ExpiryTimeMillis/1000, 0)
// Dereference pointer values safely
var paymentState int64
if subscription.PaymentState != nil {
paymentState = *subscription.PaymentState
}
result := &GoogleValidationResult{
Valid: true,
OrderID: subscription.OrderId,
ProductID: subscriptionID,
ExpiresAt: expiresAt,
AutoRenewing: subscription.AutoRenewing,
PaymentState: paymentState,
CancelReason: subscription.CancelReason,
AcknowledgedState: subscription.AcknowledgementState == 1,
}
// Check if subscription is valid
now := time.Now()
if expiresAt.Before(now) {
return result, ErrSubscriptionExpired
}
// Cancel reason: 0 = user cancelled, 1 = system cancelled
if subscription.CancelReason > 0 {
result.Valid = false
return result, ErrSubscriptionCancelled
}
return result, nil
}
// ValidatePurchaseToken validates a purchase token, attempting to determine the subscription ID
// This is a convenience method when the client only sends the token
func (c *GoogleIAPClient) ValidatePurchaseToken(ctx context.Context, purchaseToken string, knownSubscriptionIDs []string) (*GoogleValidationResult, error) {
// Try each known subscription ID until one works
for _, subID := range knownSubscriptionIDs {
result, err := c.ValidateSubscription(ctx, subID, purchaseToken)
if err == nil {
return result, nil
}
// If it's not a "not found" error, return the error
if !strings.Contains(err.Error(), "notFound") && !strings.Contains(err.Error(), "404") {
continue
}
}
return nil, ErrInvalidPurchaseToken
}
// AcknowledgeSubscription acknowledges a subscription purchase
// This must be called within 3 days of purchase or the subscription is refunded
func (c *GoogleIAPClient) AcknowledgeSubscription(ctx context.Context, subscriptionID, purchaseToken string) error {
err := c.service.Purchases.Subscriptions.Acknowledge(
c.packageName,
subscriptionID,
purchaseToken,
&androidpublisher.SubscriptionPurchasesAcknowledgeRequest{},
).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to acknowledge subscription: %w", err)
}
return nil
}