578 lines
16 KiB
Go
578 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
_ "github.com/lib/pq"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
var db *sql.DB
|
|
|
|
// jwtSecret carrega o secret do ambiente ou usa fallback (NUNCA use fallback em produção)
|
|
var jwtSecret = []byte(getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"))
|
|
|
|
// Rate limiting simples (IP -> timestamp das últimas tentativas)
|
|
var loginAttempts = make(map[string][]time.Time)
|
|
var registerAttempts = make(map[string][]time.Time)
|
|
|
|
const maxAttemptsPerMinute = 5
|
|
|
|
// corsMiddleware adiciona headers CORS
|
|
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// CORS - apenas domínios permitidos
|
|
allowedOrigins := map[string]bool{
|
|
"http://localhost": true, // Dev local
|
|
"http://dash.localhost": true, // Dashboard dev
|
|
"http://aggios.local": true, // Institucional dev
|
|
"http://dash.aggios.local": true, // Dashboard dev alternativo
|
|
"https://aggios.app": true, // Institucional prod
|
|
"https://dash.aggios.app": true, // Dashboard prod
|
|
"https://www.aggios.app": true, // Institucional prod www
|
|
}
|
|
|
|
origin := r.Header.Get("Origin")
|
|
if allowedOrigins[origin] {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
}
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
|
|
// Headers de segurança
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
|
|
// Handle preflight
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Log da requisição (sem dados sensíveis)
|
|
log.Printf("📥 %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
|
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// RegisterRequest representa os dados completos de registro
|
|
type RegisterRequest struct {
|
|
// Step 1 - Dados Pessoais
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
FullName string `json:"fullName"`
|
|
Newsletter bool `json:"newsletter"`
|
|
|
|
// Step 2 - Empresa
|
|
CompanyName string `json:"companyName"`
|
|
CNPJ string `json:"cnpj"`
|
|
RazaoSocial string `json:"razaoSocial"`
|
|
Description string `json:"description"`
|
|
Website string `json:"website"`
|
|
Industry string `json:"industry"`
|
|
TeamSize string `json:"teamSize"`
|
|
|
|
// Step 3 - Localização
|
|
CEP string `json:"cep"`
|
|
State string `json:"state"`
|
|
City string `json:"city"`
|
|
Neighborhood string `json:"neighborhood"`
|
|
Street string `json:"street"`
|
|
Number string `json:"number"`
|
|
Complement string `json:"complement"`
|
|
Contacts []struct {
|
|
ID int `json:"id"`
|
|
WhatsApp string `json:"whatsapp"`
|
|
} `json:"contacts"`
|
|
|
|
// Step 4 - Domínio
|
|
Subdomain string `json:"subdomain"`
|
|
|
|
// Step 5 - Personalização
|
|
PrimaryColor string `json:"primaryColor"`
|
|
SecondaryColor string `json:"secondaryColor"`
|
|
LogoURL string `json:"logoUrl"`
|
|
}
|
|
|
|
// RegisterResponse representa a resposta do registro
|
|
type RegisterResponse struct {
|
|
Token string `json:"token"`
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
TenantID string `json:"tenantId"`
|
|
Company string `json:"company"`
|
|
Subdomain string `json:"subdomain"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ErrorResponse representa uma resposta de erro
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// LoginRequest representa os dados de login
|
|
type LoginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// LoginResponse representa a resposta do login
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
User UserPayload `json:"user"`
|
|
}
|
|
|
|
// UserPayload representa os dados do usuário no token
|
|
type UserPayload struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
TenantID string `json:"tenantId"`
|
|
Company string `json:"company"`
|
|
Subdomain string `json:"subdomain"`
|
|
}
|
|
|
|
// Claims customizado para JWT
|
|
type Claims struct {
|
|
UserID string `json:"userId"`
|
|
Email string `json:"email"`
|
|
TenantID string `json:"tenantId"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// getEnvOrDefault retorna variável de ambiente ou valor padrão
|
|
func getEnvOrDefault(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// checkRateLimit verifica se IP excedeu limite de tentativas
|
|
func checkRateLimit(ip string, attempts map[string][]time.Time) bool {
|
|
now := time.Now()
|
|
cutoff := now.Add(-1 * time.Minute)
|
|
|
|
// Limpar tentativas antigas
|
|
if timestamps, exists := attempts[ip]; exists {
|
|
var recent []time.Time
|
|
for _, t := range timestamps {
|
|
if t.After(cutoff) {
|
|
recent = append(recent, t)
|
|
}
|
|
}
|
|
attempts[ip] = recent
|
|
|
|
// Verificar se excedeu limite
|
|
if len(recent) >= maxAttemptsPerMinute {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Adicionar nova tentativa
|
|
attempts[ip] = append(attempts[ip], now)
|
|
return true
|
|
}
|
|
|
|
// validateEmail valida formato de email
|
|
func validateEmail(email string) bool {
|
|
if len(email) < 3 || len(email) > 254 {
|
|
return false
|
|
}
|
|
// Regex simples para validação
|
|
return strings.Contains(email, "@") && strings.Contains(email, ".")
|
|
}
|
|
|
|
func initDB() error {
|
|
connStr := fmt.Sprintf(
|
|
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
|
os.Getenv("DB_HOST"),
|
|
os.Getenv("DB_PORT"),
|
|
os.Getenv("DB_USER"),
|
|
os.Getenv("DB_PASSWORD"),
|
|
os.Getenv("DB_NAME"),
|
|
)
|
|
|
|
var err error
|
|
db, err = sql.Open("postgres", connStr)
|
|
if err != nil {
|
|
return fmt.Errorf("erro ao abrir conexão: %v", err)
|
|
}
|
|
|
|
if err = db.Ping(); err != nil {
|
|
return fmt.Errorf("erro ao conectar ao banco: %v", err)
|
|
}
|
|
|
|
log.Println("✅ Conectado ao PostgreSQL")
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
// Inicializar banco de dados
|
|
if err := initDB(); err != nil {
|
|
log.Fatalf("❌ Erro ao inicializar banco: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Health check handlers
|
|
http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"status":"healthy","version":"1.0.0","database":"pending","redis":"pending","minio":"pending"}`)
|
|
})
|
|
|
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"status":"ok"}`)
|
|
})
|
|
|
|
// Auth routes (com CORS)
|
|
http.HandleFunc("/api/auth/register", corsMiddleware(handleRegister))
|
|
http.HandleFunc("/api/auth/login", corsMiddleware(handleLogin))
|
|
http.HandleFunc("/api/me", corsMiddleware(authMiddleware(handleMe)))
|
|
|
|
port := os.Getenv("SERVER_PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
addr := fmt.Sprintf(":%s", port)
|
|
log.Printf("🚀 Server starting on %s", addr)
|
|
log.Printf("📍 Health check: http://localhost:%s/health", port)
|
|
log.Printf("🔗 API: http://localhost:%s/api/health", port)
|
|
log.Printf("👤 Register: http://localhost:%s/api/auth/register", port)
|
|
log.Printf("🔐 Login: http://localhost:%s/api/auth/login", port)
|
|
log.Printf("👤 Me: http://localhost:%s/api/me", port)
|
|
|
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
|
log.Fatalf("❌ Server error: %v", err)
|
|
}
|
|
}
|
|
|
|
// handleRegister handler para criar novo usuário
|
|
func handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
// Apenas POST
|
|
if r.Method != http.MethodPost {
|
|
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Rate limiting
|
|
ip := strings.Split(r.RemoteAddr, ":")[0]
|
|
if !checkRateLimit(ip, registerAttempts) {
|
|
sendError(w, "Too many registration attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Parse JSON
|
|
var req RegisterRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendError(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validações básicas
|
|
if !validateEmail(req.Email) {
|
|
sendError(w, "Invalid email format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Password == "" {
|
|
sendError(w, "Password is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.Password) < 8 {
|
|
sendError(w, "Password must be at least 8 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.FullName == "" {
|
|
sendError(w, "Full name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.CompanyName == "" {
|
|
sendError(w, "Company name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Subdomain == "" {
|
|
sendError(w, "Subdomain is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verificar se email já existe
|
|
var exists bool
|
|
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists)
|
|
if err != nil {
|
|
sendError(w, "Database error", http.StatusInternalServerError)
|
|
log.Printf("Erro ao verificar email: %v", err)
|
|
return
|
|
}
|
|
if exists {
|
|
sendError(w, "Email already registered", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Hash da senha com bcrypt
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
sendError(w, "Error processing password", http.StatusInternalServerError)
|
|
log.Printf("Erro ao hash senha: %v", err)
|
|
return
|
|
}
|
|
|
|
// Criar Tenant (empresa)
|
|
tenantID := uuid.New().String()
|
|
domain := fmt.Sprintf("%s.aggios.app", req.Subdomain)
|
|
createdAt := time.Now()
|
|
|
|
_, err = db.Exec(
|
|
"INSERT INTO tenants (id, name, domain, subdomain, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
|
tenantID, req.CompanyName, domain, req.Subdomain, true, createdAt, createdAt,
|
|
)
|
|
if err != nil {
|
|
sendError(w, "Error creating company", http.StatusInternalServerError)
|
|
log.Printf("Erro ao criar tenant: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("✅ Tenant criado: %s (%s)", req.CompanyName, tenantID)
|
|
|
|
// Criar Usuário (administrador do tenant)
|
|
userID := uuid.New().String()
|
|
firstName := req.FullName
|
|
lastName := ""
|
|
|
|
_, err = db.Exec(
|
|
"INSERT INTO users (id, tenant_id, email, password_hash, first_name, last_name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
|
userID, tenantID, req.Email, string(hashedPassword), firstName, lastName, true, createdAt, createdAt,
|
|
)
|
|
if err != nil {
|
|
sendError(w, "Error creating user", http.StatusInternalServerError)
|
|
log.Printf("Erro ao inserir usuário: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("✅ Usuário criado: %s (%s)", req.Email, userID)
|
|
|
|
// Gerar token JWT para login automático
|
|
token, err := generateToken(userID, req.Email, tenantID)
|
|
if err != nil {
|
|
sendError(w, "Error generating token", http.StatusInternalServerError)
|
|
log.Printf("Erro ao gerar token: %v", err)
|
|
return
|
|
}
|
|
|
|
response := RegisterResponse{
|
|
Token: token,
|
|
ID: userID,
|
|
Email: req.Email,
|
|
Name: req.FullName,
|
|
TenantID: tenantID,
|
|
Company: req.CompanyName,
|
|
Subdomain: req.Subdomain,
|
|
CreatedAt: createdAt.Format(time.RFC3339),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// sendError envia uma resposta de erro padronizada
|
|
func sendError(w http.ResponseWriter, message string, statusCode int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
json.NewEncoder(w).Encode(ErrorResponse{
|
|
Error: http.StatusText(statusCode),
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
// generateToken gera um JWT token para o usuário
|
|
func generateToken(userID, email, tenantID string) (string, error) {
|
|
claims := Claims{
|
|
UserID: userID,
|
|
Email: email,
|
|
TenantID: tenantID,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
Issuer: "aggios-api",
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(jwtSecret)
|
|
}
|
|
|
|
// authMiddleware verifica o token JWT
|
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
sendError(w, "Authorization header required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if tokenString == authHeader {
|
|
sendError(w, "Invalid authorization format", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
claims := &Claims{}
|
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
|
return jwtSecret, nil
|
|
})
|
|
|
|
if err != nil || !token.Valid {
|
|
sendError(w, "Invalid or expired token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Adicionar claims ao contexto (simplificado: usar headers)
|
|
r.Header.Set("X-User-ID", claims.UserID)
|
|
r.Header.Set("X-User-Email", claims.Email)
|
|
r.Header.Set("X-Tenant-ID", claims.TenantID)
|
|
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// handleLogin handler para fazer login
|
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Rate limiting
|
|
ip := strings.Split(r.RemoteAddr, ":")[0]
|
|
if !checkRateLimit(ip, loginAttempts) {
|
|
sendError(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
var req LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendError(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !validateEmail(req.Email) || req.Password == "" {
|
|
sendError(w, "Invalid credentials", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Buscar usuário no banco
|
|
var userID, email, passwordHash, firstName, tenantID string
|
|
var tenantName, subdomain string
|
|
|
|
err := db.QueryRow(`
|
|
SELECT u.id, u.email, u.password_hash, u.first_name, u.tenant_id, t.name, t.subdomain
|
|
FROM users u
|
|
INNER JOIN tenants t ON u.tenant_id = t.id
|
|
WHERE u.email = $1 AND u.is_active = true
|
|
`, req.Email).Scan(&userID, &email, &passwordHash, &firstName, &tenantID, &tenantName, &subdomain)
|
|
|
|
if err == sql.ErrNoRows {
|
|
sendError(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if err != nil {
|
|
sendError(w, "Database error", http.StatusInternalServerError)
|
|
log.Printf("Erro ao buscar usuário: %v", err)
|
|
return
|
|
}
|
|
|
|
// Verificar senha
|
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
|
sendError(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Gerar token JWT
|
|
token, err := generateToken(userID, email, tenantID)
|
|
if err != nil {
|
|
sendError(w, "Error generating token", http.StatusInternalServerError)
|
|
log.Printf("Erro ao gerar token: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("✅ Login bem-sucedido: %s", email)
|
|
|
|
response := LoginResponse{
|
|
Token: token,
|
|
User: UserPayload{
|
|
ID: userID,
|
|
Email: email,
|
|
Name: firstName,
|
|
TenantID: tenantID,
|
|
Company: tenantName,
|
|
Subdomain: subdomain,
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// handleMe retorna dados do usuário autenticado
|
|
func handleMe(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
userID := r.Header.Get("X-User-ID")
|
|
tenantID := r.Header.Get("X-Tenant-ID")
|
|
|
|
var email, firstName, lastName string
|
|
var tenantName, subdomain string
|
|
|
|
err := db.QueryRow(`
|
|
SELECT u.email, u.first_name, u.last_name, t.name, t.subdomain
|
|
FROM users u
|
|
INNER JOIN tenants t ON u.tenant_id = t.id
|
|
WHERE u.id = $1 AND u.tenant_id = $2
|
|
`, userID, tenantID).Scan(&email, &firstName, &lastName, &tenantName, &subdomain)
|
|
|
|
if err != nil {
|
|
sendError(w, "User not found", http.StatusNotFound)
|
|
log.Printf("Erro ao buscar usuário: %v", err)
|
|
return
|
|
}
|
|
|
|
fullName := firstName
|
|
if lastName != "" {
|
|
fullName += " " + lastName
|
|
}
|
|
|
|
response := UserPayload{
|
|
ID: userID,
|
|
Email: email,
|
|
Name: fullName,
|
|
TenantID: tenantID,
|
|
Company: tenantName,
|
|
Subdomain: subdomain,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|