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
This commit is contained in:
2026-02-06 14:37:04 -03:00
parent d6b08cb586
commit 547619a1a7
3 changed files with 622 additions and 21 deletions

View File

@@ -8,9 +8,12 @@ import (
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/bigtux/ophion/internal/auth"
"github.com/bigtux/ophion/internal/security"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/logger"
@@ -26,6 +29,7 @@ import (
type Server struct { type Server struct {
app *fiber.App app *fiber.App
db *sql.DB db *sql.DB
authHandler *auth.AuthHandler
} }
type Metric struct { type Metric struct {
@@ -102,9 +106,17 @@ func main() {
} else { } else {
log.Println("✓ Connected to PostgreSQL") log.Println("✓ Connected to PostgreSQL")
initSchema(db) 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 // Create Fiber app
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
@@ -215,12 +227,34 @@ func initSchema(db *sql.DB) {
resolved_at TIMESTAMPTZ 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_timestamp ON metrics(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_metrics_service_name ON metrics(service, name); 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_timestamp ON logs(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service); 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_trace_id ON spans(trace_id);
CREATE INDEX IF NOT EXISTS idx_spans_service ON spans(service); 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 { if _, err := db.Exec(schema); err != nil {
@@ -231,36 +265,147 @@ func initSchema(db *sql.DB) {
} }
func (s *Server) setupRoutes() { func (s *Server) setupRoutes() {
// Health check // Health check (public)
s.app.Get("/health", s.healthCheck) s.app.Get("/health", s.healthCheck)
// Security headers
s.app.Use(security.SecurityHeaders())
// API v1 // API v1
api := s.app.Group("/api/v1") api := s.app.Group("/api/v1")
// Ingest endpoints (for agents) // ═══════════════════════════════════════════════════════════
api.Post("/metrics", s.ingestMetrics) // 🔓 PUBLIC ROUTES (no auth required)
api.Post("/logs", s.ingestLogs) // ═══════════════════════════════════════════════════════════
api.Post("/traces", s.ingestTraces)
// 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) // Query endpoints (for dashboard)
api.Get("/metrics", s.queryMetrics) protected.Get("/metrics", s.queryMetrics)
api.Get("/metrics/names", s.getMetricNames) protected.Get("/metrics/names", s.getMetricNames)
api.Get("/logs", s.queryLogs) protected.Get("/logs", s.queryLogs)
api.Get("/traces", s.queryTraces) protected.Get("/traces", s.queryTraces)
api.Get("/traces/:traceId", s.getTrace) protected.Get("/traces/:traceId", s.getTrace)
api.Get("/services", s.getServices) protected.Get("/services", s.getServices)
// Agents // Agents
api.Get("/agents", s.getAgents) protected.Get("/agents", s.getAgents)
api.Post("/agents/register", s.registerAgent) protected.Post("/agents/register", s.registerAgent)
// Alerts // Alerts
api.Get("/alerts", s.getAlerts) protected.Get("/alerts", s.getAlerts)
api.Post("/alerts", s.createAlert) protected.Post("/alerts", s.createAlert)
api.Put("/alerts/:id/resolve", s.resolveAlert) protected.Put("/alerts/:id/resolve", s.resolveAlert)
// Dashboard // 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 { func (s *Server) healthCheck(c *fiber.Ctx) error {

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
) )
// InsightsEngine motor de geração de insights // InsightsEngine motor de geração de insights

457
internal/auth/handlers.go Normal file
View File

@@ -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[:])
}