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/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 {