v1.4: Segurança multi-tenant, file serving via API e UX humanizada
- Validação cross-tenant no login e rotas protegidas
- File serving via /api/files/{bucket}/{path} (eliminação DNS)
- Mensagens de erro humanizadas inline (sem pop-ups)
- Middleware tenant detection via headers customizados
- Upload de logos retorna URLs via API
- README atualizado com changelog v1.4 completo
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
@@ -172,20 +173,28 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate public URL
|
||||
logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename)
|
||||
// Generate public URL through API (not direct MinIO access)
|
||||
// This is more secure and doesn't require DNS configuration
|
||||
logoURL := fmt.Sprintf("http://api.localhost/api/files/%s/%s", bucketName, filename)
|
||||
|
||||
log.Printf("Logo uploaded successfully: %s", logoURL)
|
||||
|
||||
// Delete old logo file from MinIO if exists
|
||||
if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" {
|
||||
// Extract object key from URL
|
||||
// Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
|
||||
// Example: http://api.localhost/api/files/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
|
||||
oldFilename := ""
|
||||
if len(currentLogoURL) > 0 {
|
||||
// Split by bucket name
|
||||
if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) {
|
||||
oldFilename = currentLogoURL[idx:]
|
||||
// Split by /api/files/{bucket}/ to get the file path
|
||||
apiPrefix := fmt.Sprintf("http://api.localhost/api/files/%s/", bucketName)
|
||||
if strings.HasPrefix(currentLogoURL, apiPrefix) {
|
||||
oldFilename = strings.TrimPrefix(currentLogoURL, apiPrefix)
|
||||
} else {
|
||||
// Fallback for old MinIO URLs
|
||||
baseURL := fmt.Sprintf("%s/%s/", h.config.Minio.PublicURL, bucketName)
|
||||
if len(currentLogoURL) > len(baseURL) {
|
||||
oldFilename = currentLogoURL[len(baseURL):]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +211,8 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Update tenant record in database
|
||||
var err2 error
|
||||
log.Printf("Updating database: tenant_id=%s, logo_type=%s, logo_url=%s", tenantID, logoType, logoURL)
|
||||
|
||||
if logoType == "horizontal" {
|
||||
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
|
||||
} else {
|
||||
@@ -209,10 +220,12 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err2 != nil {
|
||||
log.Printf("Failed to update logo: %v", err2)
|
||||
http.Error(w, "Failed to update database", http.StatusInternalServerError)
|
||||
log.Printf("ERROR: Failed to update logo in database: %v", err2)
|
||||
http.Error(w, fmt.Sprintf("Failed to update database: %v", err2), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("SUCCESS: Logo saved to database successfully!")
|
||||
|
||||
// Return success response
|
||||
response := map[string]string{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -13,11 +14,13 @@ import (
|
||||
|
||||
type AgencyHandler struct {
|
||||
tenantRepo *repository.TenantRepository
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler {
|
||||
func NewAgencyHandler(tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyHandler {
|
||||
return &AgencyHandler{
|
||||
tenantRepo: tenantRepo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/service"
|
||||
)
|
||||
@@ -96,6 +97,23 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant do usuário corresponde ao subdomain acessado
|
||||
tenantIDFromContext := ""
|
||||
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
|
||||
tenantIDFromContext, _ = ctxTenantID.(string)
|
||||
}
|
||||
|
||||
// Se foi detectado um tenant no contexto (não é superadmin ou site institucional)
|
||||
if tenantIDFromContext != "" && response.User.TenantID != nil {
|
||||
userTenantID := response.User.TenantID.String()
|
||||
if userTenantID != tenantIDFromContext {
|
||||
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", userTenantID, tenantIDFromContext)
|
||||
http.Error(w, "Forbidden: Invalid credentials for this tenant", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", userTenantID)
|
||||
}
|
||||
|
||||
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
87
backend/internal/api/handlers/files.go
Normal file
87
backend/internal/api/handlers/files.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aggios-app/backend/internal/config"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type FilesHandler struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewFilesHandler(cfg *config.Config) *FilesHandler {
|
||||
return &FilesHandler{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeFile serves files from MinIO through the API
|
||||
func (h *FilesHandler) ServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
bucket := vars["bucket"]
|
||||
|
||||
// Get the file path (everything after /api/files/{bucket}/)
|
||||
prefix := fmt.Sprintf("/api/files/%s/", bucket)
|
||||
filePath := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
|
||||
if filePath == "" {
|
||||
http.Error(w, "File path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📁 Serving file: bucket=%s, path=%s", bucket, filePath)
|
||||
|
||||
// Initialize MinIO client
|
||||
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
|
||||
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
|
||||
Secure: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create MinIO client: %v", err)
|
||||
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get object from MinIO
|
||||
ctx := context.Background()
|
||||
object, err := minioClient.GetObject(ctx, bucket, filePath, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get object: %v", err)
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer object.Close()
|
||||
|
||||
// Get object info for content type and size
|
||||
objInfo, err := object.Stat()
|
||||
if err != nil {
|
||||
log.Printf("Failed to stat object: %v", err)
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
w.Header().Set("Content-Type", objInfo.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", objInfo.Size))
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Copy file content to response
|
||||
_, err = io.Copy(w, object)
|
||||
if err != nil {
|
||||
log.Printf("Failed to copy object content: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ File served successfully: %s", filePath)
|
||||
}
|
||||
@@ -67,3 +67,39 @@ func (h *TenantHandler) CheckExists(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetPublicConfig returns public branding info for a tenant by subdomain
|
||||
func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
subdomain := r.URL.Query().Get("subdomain")
|
||||
if subdomain == "" {
|
||||
http.Error(w, "subdomain is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := h.tenantService.GetBySubdomain(subdomain)
|
||||
if err != nil {
|
||||
if err == service.ErrTenantNotFound {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return only public info
|
||||
response := map[string]string{
|
||||
"name": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user