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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user