From 547619a1a769c6d3a7210c0635d263df05668f22 Mon Sep 17 00:00:00 2001 From: bigtux Date: Fri, 6 Feb 2026 14:37:04 -0300 Subject: [PATCH] feat: Add JWT authentication and API key security - Add users table for dashboard authentication (email, password_hash, role) - Add api_keys table for agent authentication (key_hash, prefix, name) - Implement JWT auth with 24h expiration - Implement API key auth with SHA256 hashing - Add auth endpoints: POST /api/v1/auth/login, POST /api/v1/auth/register - Add API key endpoints: GET/POST/DELETE /api/v1/api-keys - Protect all /api/v1/* routes (except /health and /auth/*) - Create default admin user (admin@ophion.local) - First registered user automatically becomes admin - Use bcrypt for password hashing (cost 12) - Use SHA256 for API key hashing - Add security headers middleware --- cmd/server/main.go | 185 +++++++++++++-- internal/ai/insights.go | 1 - internal/auth/handlers.go | 457 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 622 insertions(+), 21 deletions(-) create mode 100644 internal/auth/handlers.go diff --git a/cmd/server/main.go b/cmd/server/main.go index c01c960..48cc223 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,9 +8,12 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "time" + "github.com/bigtux/ophion/internal/auth" + "github.com/bigtux/ophion/internal/security" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" @@ -24,8 +27,9 @@ import ( // ═══════════════════════════════════════════════════════════ type Server struct { - app *fiber.App - db *sql.DB + app *fiber.App + db *sql.DB + authHandler *auth.AuthHandler } type Metric struct { @@ -102,9 +106,17 @@ func main() { } else { log.Println("✓ Connected to PostgreSQL") initSchema(db) + // Create default admin user + if err := auth.CreateDefaultAdmin(db); err != nil { + log.Printf("⚠ Failed to create default admin: %v", err) + } } - server := &Server{db: db} + // Initialize auth handler + jwtSecret := getEnv("JWT_SECRET", "ophion-super-secret-key-change-in-production") + authHandler := auth.NewAuthHandler(db, jwtSecret) + + server := &Server{db: db, authHandler: authHandler} // Create Fiber app app := fiber.New(fiber.Config{ @@ -215,12 +227,34 @@ func initSchema(db *sql.DB) { resolved_at TIMESTAMPTZ ); + -- Users table for JWT auth + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'user', + created_at TIMESTAMPTZ DEFAULT NOW() + ); + + -- API Keys table for agent auth + CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_hash VARCHAR(64) NOT NULL UNIQUE, + prefix VARCHAR(20) NOT NULL, + name VARCHAR(255) NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp DESC); CREATE INDEX IF NOT EXISTS idx_metrics_service_name ON metrics(service, name); CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC); CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service); CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON spans(trace_id); CREATE INDEX IF NOT EXISTS idx_spans_service ON spans(service); + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); ` if _, err := db.Exec(schema); err != nil { @@ -231,36 +265,147 @@ func initSchema(db *sql.DB) { } func (s *Server) setupRoutes() { - // Health check + // Health check (public) s.app.Get("/health", s.healthCheck) + // Security headers + s.app.Use(security.SecurityHeaders()) + // API v1 api := s.app.Group("/api/v1") - // Ingest endpoints (for agents) - api.Post("/metrics", s.ingestMetrics) - api.Post("/logs", s.ingestLogs) - api.Post("/traces", s.ingestTraces) + // ═══════════════════════════════════════════════════════════ + // 🔓 PUBLIC ROUTES (no auth required) + // ═══════════════════════════════════════════════════════════ + + // Auth endpoints + authGroup := api.Group("/auth") + authGroup.Post("/login", s.authHandler.Login) + authGroup.Post("/register", s.authHandler.Register) + + // ═══════════════════════════════════════════════════════════ + // 🔐 PROTECTED ROUTES (auth required) + // ═══════════════════════════════════════════════════════════ + + // Ingest endpoints (for agents - API key or JWT) + ingest := api.Group("/ingest", s.authMiddleware()) + ingest.Post("/metrics", s.ingestMetrics) + ingest.Post("/logs", s.ingestLogs) + ingest.Post("/traces", s.ingestTraces) + + // Legacy ingest routes (also protected, for backwards compat) + api.Post("/metrics", s.authMiddleware(), s.ingestMetrics) + api.Post("/logs", s.authMiddleware(), s.ingestLogs) + api.Post("/traces", s.authMiddleware(), s.ingestTraces) + + // Protected routes + protected := api.Group("", s.authMiddleware()) // Query endpoints (for dashboard) - api.Get("/metrics", s.queryMetrics) - api.Get("/metrics/names", s.getMetricNames) - api.Get("/logs", s.queryLogs) - api.Get("/traces", s.queryTraces) - api.Get("/traces/:traceId", s.getTrace) - api.Get("/services", s.getServices) + protected.Get("/metrics", s.queryMetrics) + protected.Get("/metrics/names", s.getMetricNames) + protected.Get("/logs", s.queryLogs) + protected.Get("/traces", s.queryTraces) + protected.Get("/traces/:traceId", s.getTrace) + protected.Get("/services", s.getServices) // Agents - api.Get("/agents", s.getAgents) - api.Post("/agents/register", s.registerAgent) + protected.Get("/agents", s.getAgents) + protected.Post("/agents/register", s.registerAgent) // Alerts - api.Get("/alerts", s.getAlerts) - api.Post("/alerts", s.createAlert) - api.Put("/alerts/:id/resolve", s.resolveAlert) + protected.Get("/alerts", s.getAlerts) + protected.Post("/alerts", s.createAlert) + protected.Put("/alerts/:id/resolve", s.resolveAlert) // Dashboard - api.Get("/dashboard/overview", s.getDashboardOverview) + protected.Get("/dashboard/overview", s.getDashboardOverview) + + // User info + protected.Get("/me", s.authHandler.Me) + + // API Keys management (JWT only) + protected.Post("/api-keys", s.authHandler.CreateAPIKey) + protected.Get("/api-keys", s.authHandler.ListAPIKeys) + protected.Delete("/api-keys/:id", s.authHandler.DeleteAPIKey) +} + +// authMiddleware creates authentication middleware that accepts both JWT and API keys +func (s *Server) authMiddleware() fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(401).JSON(fiber.Map{ + "error": "Unauthorized", + "message": "Missing Authorization header", + }) + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + + // Check if it's an API key (starts with ophion_) + if strings.HasPrefix(token, security.APIKeyPrefix) { + return s.authenticateAPIKey(c, token) + } + + // Otherwise treat as JWT + return s.authenticateJWT(c, token) + } +} + +// authenticateAPIKey validates API key authentication +func (s *Server) authenticateAPIKey(c *fiber.Ctx, apiKey string) error { + if !security.ValidateAPIKeyFormat(apiKey) { + return c.Status(401).JSON(fiber.Map{ + "error": "Unauthorized", + "message": "Invalid API key format", + }) + } + + keyHash := security.HashAPIKey(apiKey) + + var keyID, name, userID string + err := s.db.QueryRow(` + SELECT id, name, user_id FROM api_keys WHERE key_hash = $1 + `, keyHash).Scan(&keyID, &name, &userID) + + if err != nil { + return c.Status(401).JSON(fiber.Map{ + "error": "Unauthorized", + "message": "Invalid API key", + }) + } + + // Update last_used (async) + go s.db.Exec(`UPDATE api_keys SET last_used = NOW() WHERE id = $1`, keyID) + + // Set context + c.Locals("auth_type", "api_key") + c.Locals("api_key_id", keyID) + c.Locals("user_id", userID) + + return c.Next() +} + +// authenticateJWT validates JWT token authentication +func (s *Server) authenticateJWT(c *fiber.Ctx, token string) error { + authService := s.authHandler.GetAuthService() + claims, err := authService.ValidateAccessToken(token) + if err != nil { + return c.Status(401).JSON(fiber.Map{ + "error": "Unauthorized", + "message": err.Error(), + }) + } + + // Set context + c.Locals("auth_type", "jwt") + c.Locals("user_id", claims.UserID) + c.Locals("email", claims.Email) + c.Locals("role", claims.Role) + c.Locals("token_id", claims.TokenID) + + return c.Next() } func (s *Server) healthCheck(c *fiber.Ctx) error { diff --git a/internal/ai/insights.go b/internal/ai/insights.go index 67d42dd..62ee4d4 100644 --- a/internal/ai/insights.go +++ b/internal/ai/insights.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" ) // InsightsEngine motor de geração de insights diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go new file mode 100644 index 0000000..955ecfe --- /dev/null +++ b/internal/auth/handlers.go @@ -0,0 +1,457 @@ +package auth + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "log" + "os" + "time" + + "github.com/bigtux/ophion/internal/security" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// ═══════════════════════════════════════════════════════════ +// 🔐 AUTH HANDLERS +// ═══════════════════════════════════════════════════════════ + +type AuthHandler struct { + db *sql.DB + authService *AuthService +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +type APIKey struct { + ID string `json:"id"` + KeyHash string `json:"-"` + Prefix string `json:"prefix"` + Name string `json:"name"` + UserID string `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + LastUsed *time.Time `json:"last_used,omitempty"` +} + +// NewAuthHandler cria novo handler de autenticação +func NewAuthHandler(db *sql.DB, jwtSecret string) *AuthHandler { + config := AuthConfig{ + JWTSecret: []byte(jwtSecret), + JWTExpiration: 24 * time.Hour, + RefreshExpiration: 7 * 24 * time.Hour, + Issuer: "ophion", + } + + // Implementação simples do APIKeyStore + store := &DBAPIKeyStore{db: db} + + authService := NewAuthService(config, nil, store) + + return &AuthHandler{ + db: db, + authService: authService, + } +} + +// GetAuthService retorna o serviço de autenticação +func (h *AuthHandler) GetAuthService() *AuthService { + return h.authService +} + +// ───────────────────────────────────────────────────────────── +// Login +// ───────────────────────────────────────────────────────────── + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + ExpiresIn int `json:"expires_in"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + } `json:"user"` +} + +func (h *AuthHandler) Login(c *fiber.Ctx) error { + var req LoginRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Email == "" || req.Password == "" { + return c.Status(400).JSON(fiber.Map{"error": "Email and password are required"}) + } + + // Sanitize email + email, err := security.SanitizeEmail(req.Email) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid email format"}) + } + + // Check brute force protection + allowed, remaining, _ := h.authService.CheckLoginAllowed(email) + if !allowed { + return c.Status(429).JSON(fiber.Map{ + "error": "Too many failed attempts", + "message": "Account temporarily locked. Try again in " + remaining.String(), + }) + } + + // Find user + var user User + err = h.db.QueryRow(` + SELECT id, email, password_hash, role, created_at + FROM users WHERE email = $1 + `, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt) + + if err == sql.ErrNoRows { + h.authService.RecordLoginAttempt(email, false) + return c.Status(401).JSON(fiber.Map{"error": "Invalid credentials"}) + } + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Database error"}) + } + + // Verify password + if !security.VerifyPassword(req.Password, user.PasswordHash) { + blocked, remaining := h.authService.RecordLoginAttempt(email, false) + if blocked { + return c.Status(429).JSON(fiber.Map{ + "error": "Too many failed attempts", + "message": "Account temporarily locked", + }) + } + return c.Status(401).JSON(fiber.Map{ + "error": "Invalid credentials", + "remaining_attempts": remaining, + }) + } + + // Successful login + h.authService.RecordLoginAttempt(email, true) + + // Generate token + token, _, err := h.authService.GenerateTokenPair(user.ID, "", user.Email, user.Role) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to generate token"}) + } + + resp := LoginResponse{ + Token: token, + ExpiresIn: 86400, // 24 hours + } + resp.User.ID = user.ID + resp.User.Email = user.Email + resp.User.Role = user.Role + + return c.JSON(resp) +} + +// ───────────────────────────────────────────────────────────── +// Register +// ───────────────────────────────────────────────────────────── + +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *AuthHandler) Register(c *fiber.Ctx) error { + var req RegisterRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Email == "" || req.Password == "" { + return c.Status(400).JSON(fiber.Map{"error": "Email and password are required"}) + } + + // Sanitize email + email, err := security.SanitizeEmail(req.Email) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid email format"}) + } + + // Check password (relaxed policy for simplicity) + if len(req.Password) < 6 { + return c.Status(400).JSON(fiber.Map{"error": "Password must be at least 6 characters"}) + } + + // Check if any users exist (first user becomes admin) + var userCount int + h.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) + + role := "user" + if userCount == 0 { + role = "admin" + } + + // Check if email already exists + var existingID string + err = h.db.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&existingID) + if err != sql.ErrNoRows { + return c.Status(409).JSON(fiber.Map{"error": "Email already registered"}) + } + + // Hash password + passwordHash, err := security.HashPassword(req.Password) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to hash password"}) + } + + // Create user + userID := uuid.New().String() + _, err = h.db.Exec(` + INSERT INTO users (id, email, password_hash, role, created_at) + VALUES ($1, $2, $3, $4, NOW()) + `, userID, email, passwordHash, role) + if err != nil { + log.Printf("Error creating user: %v", err) + return c.Status(500).JSON(fiber.Map{"error": "Failed to create user"}) + } + + // Generate token + token, _, err := h.authService.GenerateTokenPair(userID, "", email, role) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to generate token"}) + } + + return c.Status(201).JSON(fiber.Map{ + "message": "User registered successfully", + "token": token, + "user": fiber.Map{ + "id": userID, + "email": email, + "role": role, + }, + }) +} + +// ───────────────────────────────────────────────────────────── +// API Keys +// ───────────────────────────────────────────────────────────── + +type CreateAPIKeyRequest struct { + Name string `json:"name"` +} + +func (h *AuthHandler) CreateAPIKey(c *fiber.Ctx) error { + var req CreateAPIKeyRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Name == "" { + return c.Status(400).JSON(fiber.Map{"error": "Name is required"}) + } + + userID := GetUserID(c) + if userID == "" { + return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) + } + + // Generate API key + key, keyHash, prefix := security.GenerateAPIKey() + + keyID := uuid.New().String() + _, err := h.db.Exec(` + INSERT INTO api_keys (id, key_hash, prefix, name, user_id, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + `, keyID, keyHash, prefix, req.Name, userID) + if err != nil { + log.Printf("Error creating API key: %v", err) + return c.Status(500).JSON(fiber.Map{"error": "Failed to create API key"}) + } + + return c.Status(201).JSON(fiber.Map{ + "message": "API key created successfully", + "key": key, // Only shown once! + "id": keyID, + "name": req.Name, + "prefix": prefix, + }) +} + +func (h *AuthHandler) ListAPIKeys(c *fiber.Ctx) error { + userID := GetUserID(c) + role := GetRole(c) + + var rows *sql.Rows + var err error + + // Admin can see all keys + if role == "admin" { + rows, err = h.db.Query(` + SELECT id, prefix, name, user_id, created_at, last_used + FROM api_keys ORDER BY created_at DESC + `) + } else { + rows, err = h.db.Query(` + SELECT id, prefix, name, user_id, created_at, last_used + FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC + `, userID) + } + + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Database error"}) + } + defer rows.Close() + + var keys []fiber.Map + for rows.Next() { + var k APIKey + if err := rows.Scan(&k.ID, &k.Prefix, &k.Name, &k.UserID, &k.CreatedAt, &k.LastUsed); err == nil { + keys = append(keys, fiber.Map{ + "id": k.ID, + "prefix": k.Prefix, + "name": k.Name, + "user_id": k.UserID, + "created_at": k.CreatedAt, + "last_used": k.LastUsed, + }) + } + } + + if keys == nil { + keys = []fiber.Map{} + } + + return c.JSON(fiber.Map{"api_keys": keys}) +} + +func (h *AuthHandler) DeleteAPIKey(c *fiber.Ctx) error { + keyID := c.Params("id") + userID := GetUserID(c) + role := GetRole(c) + + var ownerID string + err := h.db.QueryRow(`SELECT user_id FROM api_keys WHERE id = $1`, keyID).Scan(&ownerID) + if err == sql.ErrNoRows { + return c.Status(404).JSON(fiber.Map{"error": "API key not found"}) + } + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Database error"}) + } + + // Check permission + if role != "admin" && ownerID != userID { + return c.Status(403).JSON(fiber.Map{"error": "Permission denied"}) + } + + _, err = h.db.Exec(`DELETE FROM api_keys WHERE id = $1`, keyID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to delete API key"}) + } + + return c.JSON(fiber.Map{"message": "API key deleted"}) +} + +// ───────────────────────────────────────────────────────────── +// Me (current user) +// ───────────────────────────────────────────────────────────── + +func (h *AuthHandler) Me(c *fiber.Ctx) error { + userID := GetUserID(c) + email := c.Locals("email") + role := GetRole(c) + + return c.JSON(fiber.Map{ + "id": userID, + "email": email, + "role": role, + }) +} + +// ═══════════════════════════════════════════════════════════ +// 🗄️ DB API KEY STORE +// ═══════════════════════════════════════════════════════════ + +type DBAPIKeyStore struct { + db *sql.DB +} + +func (s *DBAPIKeyStore) ValidateKey(ctx context.Context, keyHash string) (*APIKeyInfo, error) { + var info APIKeyInfo + err := s.db.QueryRowContext(ctx, ` + SELECT id, name FROM api_keys WHERE key_hash = $1 + `, keyHash).Scan(&info.ID, &info.Name) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + info.Scopes = []string{"*"} // Full access for now + return &info, nil +} + +func (s *DBAPIKeyStore) UpdateLastUsed(ctx context.Context, keyID string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE api_keys SET last_used = NOW() WHERE id = $1 + `, keyID) + return err +} + +// ═══════════════════════════════════════════════════════════ +// 🔧 ADMIN USER SETUP +// ═══════════════════════════════════════════════════════════ + +// CreateDefaultAdmin cria usuário admin padrão se não existir +func CreateDefaultAdmin(db *sql.DB) error { + // Check if any users exist + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + // Table might not exist yet + return nil + } + + if count > 0 { + return nil // Admin already exists + } + + adminEmail := "admin@ophion.local" + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "ophion123" + } + + passwordHash, err := security.HashPassword(adminPassword) + if err != nil { + return err + } + + userID := uuid.New().String() + _, err = db.Exec(` + INSERT INTO users (id, email, password_hash, role, created_at) + VALUES ($1, $2, $3, 'admin', NOW()) + ON CONFLICT (email) DO NOTHING + `, userID, adminEmail, passwordHash) + + if err != nil { + return err + } + + log.Printf("✓ Default admin user created: %s", adminEmail) + return nil +} + +// HashAPIKey creates SHA256 hash of API key +func HashAPIKey(key string) string { + hash := sha256.Sum256([]byte(key)) + return hex.EncodeToString(hash[:]) +}