veza/veza-backend-api/internal/handlers/account_deletion_handler.go
senke 9cd0da0046 fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files
CRITICAL fixes:
- Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002)
- IDOR on analytics endpoint — ownership check enforced (CRITICAL-003)
- CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004)
- Mass assignment on user self-update — strip privileged fields (CRITICAL-005)

HIGH fixes:
- Path traversal in marketplace upload — UUID filenames (HIGH-001)
- IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002)
- Popularity metrics (followers, likes) set to json:"-" (HIGH-003)
- bcrypt cost hardened to 12 everywhere (HIGH-004)
- Refresh token lock made mandatory (HIGH-005)
- Stream token replay prevention with access_count (HIGH-006)
- Subscription trial race condition fixed (HIGH-007)
- License download expiration check (HIGH-008)
- Webhook amount validation (HIGH-009)
- pprof endpoint removed from production (HIGH-010)

MEDIUM fixes:
- WebSocket message size limit 64KB (MEDIUM-010)
- HSTS header in nginx production (MEDIUM-001)
- CORS origin restricted in nginx-rtmp (MEDIUM-002)
- Docker alpine pinned to 3.21 (MEDIUM-003/004)
- Redis authentication enforced (MEDIUM-005)
- GDPR account deletion expanded (MEDIUM-006)
- .gitignore hardened (MEDIUM-007)

LOW/INFO fixes:
- GitHub Actions SHA pinning on all workflows (LOW-001)
- .env.example security documentation (INFO-001)
- Production CORS set to HTTPS (LOW-002)

All tests pass. Go and Rust compile clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:44:46 +01:00

164 lines
6.3 KiB
Go

package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/services"
"veza-backend-api/internal/utils"
)
// DeleteAccountRequest is the request body for account deletion (v0.10.8 F065)
type DeleteAccountRequest struct {
Password string `json:"password" binding:"required"`
Reason string `json:"reason"`
ConfirmText string `json:"confirm_text" binding:"required"`
KeepPublicTracks bool `json:"keep_public_tracks"` // If true, public tracks remain (attributed to deleted account)
}
// DeleteAccountHandler returns a handler for DELETE /users/me (v0.803 SEC2-05)
//
// @Summary Delete account
// @Description Permanently delete user account with anonymization, session revocation, audit log
// @Tags Users
// @Security BearerAuth
// @Param body body DeleteAccountRequest true "Password, reason, confirm_text (must be DELETE)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /users/me [delete]
func DeleteAccountHandler(
db *gorm.DB,
sessionService *services.SessionService,
auditService *services.AuditService,
s3Service *services.S3StorageService,
logger *zap.Logger,
) gin.HandlerFunc {
refreshTokenService := services.NewRefreshTokenService(db)
return func(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req DeleteAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: password and confirm_text required"})
return
}
if req.ConfirmText != "DELETE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type DELETE to confirm"})
return
}
// 1. Verify password
var passwordHash string
if err := db.WithContext(c.Request.Context()).Raw("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&passwordHash).Error; err != nil || passwordHash == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
if err := utils.CheckPasswordHash(req.Password, passwordHash); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
ctx := c.Request.Context()
recoveryDeadline := time.Now().Add(30 * 24 * time.Hour)
anonUsername := "deleted-" + userID.String()
anonEmail := "deleted-" + userID.String() + "@veza.app"
// 2. Soft delete + full PII anonymization (v0.10.8 droit à l'oubli)
result := db.WithContext(ctx).Exec(`
UPDATE users SET
is_active = false,
deleted_at = NOW(),
deletion_reason = ?,
recovery_deadline = ?,
username = ?,
email = ?,
first_name = NULL,
last_name = NULL,
bio = '',
location = '',
avatar = '',
banner_url = '',
updated_at = NOW()
WHERE id = ?
`, req.Reason, recoveryDeadline, anonUsername, anonEmail, userID)
if result.Error != nil {
logger.Error("Failed to delete account", zap.Error(result.Error), zap.String("user_id", userID.String()))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete account"})
return
}
// 2b. Anonymize user_profiles if exists (v0.10.8)
_ = db.WithContext(ctx).Exec("UPDATE user_profiles SET bio = NULL, tagline = NULL, location = NULL, website_url = NULL, avatar_url = NULL, banner_url = NULL, updated_at = NOW() WHERE user_id = ?", userID)
// 2c. Tracks: if !KeepPublicTracks, soft-delete all user tracks (v0.10.8)
if !req.KeepPublicTracks {
_ = db.WithContext(ctx).Exec("UPDATE tracks SET deleted_at = NOW() WHERE creator_id = ?", userID)
}
// 2d. SECURITY(REM-013): Anonymize financial data for GDPR compliance.
// Orders are anonymized (not deleted) for legal/accounting obligations.
_ = db.WithContext(ctx).Exec("UPDATE orders SET buyer_id = ? WHERE buyer_id = ?", uuid.Nil, userID)
// Revoke active licenses
_ = db.WithContext(ctx).Exec("UPDATE licenses SET revoked_at = NOW() WHERE buyer_id = ? AND revoked_at IS NULL", userID)
// Zero out seller balances (prevent orphaned payouts)
_ = db.WithContext(ctx).Exec("UPDATE seller_balances SET available_cents = 0, pending_cents = 0 WHERE seller_id = ?", userID)
// Cancel pending payouts
_ = db.WithContext(ctx).Exec("UPDATE seller_payouts SET status = 'cancelled' WHERE seller_id = ? AND status IN ('pending', 'processing')", userID)
// Cancel active subscriptions
_ = db.WithContext(ctx).Exec("UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = NOW() WHERE user_id = ? AND status IN ('active', 'trialing')", userID)
// 3. Revoke all sessions
if sessionService != nil {
if _, err := sessionService.RevokeAllUserSessions(ctx, userID); err != nil {
logger.Warn("Failed to revoke sessions during account deletion", zap.Error(err), zap.String("user_id", userID.String()))
}
}
// 4. Revoke all refresh tokens
if err := refreshTokenService.RevokeAll(userID); err != nil {
logger.Warn("Failed to revoke refresh tokens during account deletion", zap.Error(err), zap.String("user_id", userID.String()))
}
// 5. S3 cleanup (cloud files for user)
if s3Service != nil {
deleteUserS3Files(ctx, db, s3Service, userID, logger)
}
// 6. Audit log
if auditService != nil {
_ = auditService.LogDeletion(ctx, userID, "user", userID, c.ClientIP(), c.Request.UserAgent())
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Account deleted"})
}
}
// deleteUserS3Files deletes cloud files for a user from S3
func deleteUserS3Files(ctx context.Context, db *gorm.DB, s3Service *services.S3StorageService, userID uuid.UUID, logger *zap.Logger) {
var files []struct {
S3Key string
}
if err := db.WithContext(ctx).Raw("SELECT s3_key FROM user_files WHERE user_id = ?", userID).Scan(&files).Error; err != nil {
logger.Warn("Failed to list user files for S3 cleanup", zap.Error(err), zap.String("user_id", userID.String()))
return
}
for _, f := range files {
if err := s3Service.DeleteFile(ctx, f.S3Key); err != nil {
logger.Warn("Failed to delete S3 file during account deletion", zap.Error(err), zap.String("key", f.S3Key))
}
}
}