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>
164 lines
6.3 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|