Files
ophion/internal/auth/middleware.go
bigtux a94809c812 🔐 Security hardening: auth, rate limiting, brute force protection
- Add comprehensive security package with:
  - API Key generation and validation (SHA256 hash)
  - Password policy enforcement (min 12 chars, complexity)
  - Rate limiting with presets (auth, api, ingest, export)
  - Brute force protection (5 attempts, 15min lockout)
  - Security headers middleware
  - IP whitelisting
  - Audit logging structure
  - Secure token generation

- Enhanced auth middleware:
  - JWT + API Key dual authentication
  - Token revocation via Redis
  - Scope-based authorization
  - Role-based access control

- Updated installer with:
  - Interactive setup for client customization
  - Auto-generated secure credentials
  - Docker all-in-one image
  - Agent installer script

- Added documentation:
  - SECURITY.md - Complete security guide
  - INSTALL.md - Installation guide
  - .env.example - Configuration reference
2026-02-05 23:02:06 -03:00

374 lines
11 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/bigtux/ophion/internal/security"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
// ═══════════════════════════════════════════════════════════
// 🔐 AUTH CONFIG
// ═══════════════════════════════════════════════════════════
type AuthConfig struct {
JWTSecret []byte
JWTExpiration time.Duration
RefreshExpiration time.Duration
Issuer string
}
type AuthService struct {
config AuthConfig
redis *redis.Client
rateLimiter *security.RateLimiter
loginTracker *security.LoginAttemptTracker
apiKeyStore APIKeyStore
}
// APIKeyStore interface para storage de API keys
type APIKeyStore interface {
ValidateKey(ctx context.Context, keyHash string) (*APIKeyInfo, error)
UpdateLastUsed(ctx context.Context, keyID string) error
}
type APIKeyInfo struct {
ID string
OrgID string
Scopes []string
Name string
}
// NewAuthService cria serviço de autenticação
func NewAuthService(config AuthConfig, redis *redis.Client, apiKeyStore APIKeyStore) *AuthService {
return &AuthService{
config: config,
redis: redis,
rateLimiter: security.NewRateLimiter(security.AuthRateLimit, time.Minute),
loginTracker: security.NewLoginAttemptTracker(),
apiKeyStore: apiKeyStore,
}
}
// ═══════════════════════════════════════════════════════════
// 🎫 JWT TOKENS
// ═══════════════════════════════════════════════════════════
type TokenClaims struct {
jwt.RegisteredClaims
UserID string `json:"uid"`
OrgID string `json:"oid"`
Email string `json:"email"`
Role string `json:"role"`
Scopes []string `json:"scopes,omitempty"`
TokenID string `json:"jti"`
}
// GenerateTokenPair gera access + refresh tokens
func (s *AuthService) GenerateTokenPair(userID, orgID, email, role string) (accessToken, refreshToken string, err error) {
now := time.Now()
tokenID := generateTokenID()
// Access Token (curta duração)
accessClaims := TokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.config.Issuer,
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.JWTExpiration)),
ID: tokenID,
},
UserID: userID,
OrgID: orgID,
Email: email,
Role: role,
TokenID: tokenID,
}
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(s.config.JWTSecret)
if err != nil {
return "", "", fmt.Errorf("failed to sign access token: %w", err)
}
// Refresh Token (longa duração)
refreshID := generateTokenID()
refreshClaims := jwt.RegisteredClaims{
Issuer: s.config.Issuer,
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.RefreshExpiration)),
ID: refreshID,
}
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(s.config.JWTSecret)
if err != nil {
return "", "", fmt.Errorf("failed to sign refresh token: %w", err)
}
// Armazenar refresh token no Redis (permite revogação)
if s.redis != nil {
ctx := context.Background()
key := fmt.Sprintf("refresh_token:%s", refreshID)
s.redis.Set(ctx, key, userID, s.config.RefreshExpiration)
}
return accessToken, refreshToken, nil
}
// ValidateAccessToken valida access token
func (s *AuthService) ValidateAccessToken(tokenString string) (*TokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.config.JWTSecret, nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := token.Claims.(*TokenClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
// Verificar se token foi revogado
if s.redis != nil {
ctx := context.Background()
revoked, _ := s.redis.Get(ctx, fmt.Sprintf("revoked_token:%s", claims.TokenID)).Result()
if revoked != "" {
return nil, fmt.Errorf("token revoked")
}
}
return claims, nil
}
// RevokeToken revoga um token
func (s *AuthService) RevokeToken(tokenID string, expiration time.Duration) error {
if s.redis == nil {
return nil
}
ctx := context.Background()
return s.redis.Set(ctx, fmt.Sprintf("revoked_token:%s", tokenID), "1", expiration).Err()
}
// ═══════════════════════════════════════════════════════════
// 🛡️ MIDDLEWARE
// ═══════════════════════════════════════════════════════════
// AuthMiddleware middleware de autenticação
func (s *AuthService) AuthMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Obter IP real (considerando proxies)
ip := c.IP()
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
ip = strings.Split(forwarded, ",")[0]
}
// Rate limiting por IP
if !s.rateLimiter.Allow(ip) {
return c.Status(429).JSON(fiber.Map{
"error": "Too many requests",
"message": "Rate limit exceeded. Try again later.",
})
}
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": "Missing authorization header",
})
}
token := strings.TrimPrefix(authHeader, "Bearer ")
// API Key authentication
if strings.HasPrefix(token, security.APIKeyPrefix) {
return s.authenticateAPIKey(c, token)
}
// JWT authentication
return s.authenticateJWT(c, token)
}
}
// authenticateAPIKey valida API key
func (s *AuthService) authenticateAPIKey(c *fiber.Ctx, apiKey string) error {
// Validar formato
if !security.ValidateAPIKeyFormat(apiKey) {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": "Invalid API key format",
})
}
// Hash da key para busca
keyHash := security.HashAPIKey(apiKey)
// Buscar no storage
keyInfo, err := s.apiKeyStore.ValidateKey(c.Context(), keyHash)
if err != nil || keyInfo == nil {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": "Invalid API key",
})
}
// Atualizar last_used (async)
go s.apiKeyStore.UpdateLastUsed(context.Background(), keyInfo.ID)
// Setar contexto
c.Locals("auth_type", "api_key")
c.Locals("org_id", keyInfo.OrgID)
c.Locals("api_key_id", keyInfo.ID)
c.Locals("scopes", keyInfo.Scopes)
return c.Next()
}
// authenticateJWT valida JWT token
func (s *AuthService) authenticateJWT(c *fiber.Ctx, token string) error {
claims, err := s.ValidateAccessToken(token)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": err.Error(),
})
}
// Setar contexto
c.Locals("auth_type", "jwt")
c.Locals("user_id", claims.UserID)
c.Locals("org_id", claims.OrgID)
c.Locals("email", claims.Email)
c.Locals("role", claims.Role)
c.Locals("token_id", claims.TokenID)
return c.Next()
}
// RequireScopes middleware que exige scopes específicos
func RequireScopes(required ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
scopes, ok := c.Locals("scopes").([]string)
if !ok {
// JWT tokens têm acesso total por padrão
if c.Locals("auth_type") == "jwt" {
return c.Next()
}
return c.Status(403).JSON(fiber.Map{
"error": "Forbidden",
"message": "Missing required scopes",
})
}
scopeMap := make(map[string]bool)
for _, s := range scopes {
scopeMap[s] = true
}
for _, req := range required {
if !scopeMap[req] && !scopeMap["*"] {
return c.Status(403).JSON(fiber.Map{
"error": "Forbidden",
"message": fmt.Sprintf("Missing required scope: %s", req),
})
}
}
return c.Next()
}
}
// RequireRole middleware que exige role específico
func RequireRole(roles ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
userRole, ok := c.Locals("role").(string)
if !ok {
return c.Status(403).JSON(fiber.Map{
"error": "Forbidden",
"message": "Role not found",
})
}
for _, r := range roles {
if userRole == r {
return c.Next()
}
}
// Admin tem acesso a tudo
if userRole == "admin" {
return c.Next()
}
return c.Status(403).JSON(fiber.Map{
"error": "Forbidden",
"message": "Insufficient permissions",
})
}
}
// ═══════════════════════════════════════════════════════════
// 🔐 LOGIN PROTECTION
// ═══════════════════════════════════════════════════════════
// CheckLoginAllowed verifica se login é permitido (brute force protection)
func (s *AuthService) CheckLoginAllowed(identifier string) (bool, time.Duration, error) {
locked, remaining := s.loginTracker.IsLocked(identifier)
return !locked, remaining, nil
}
// RecordLoginAttempt registra tentativa de login
func (s *AuthService) RecordLoginAttempt(identifier string, success bool) (blocked bool, remaining int) {
if success {
s.loginTracker.RecordSuccess(identifier)
return false, 0
}
return s.loginTracker.RecordFailure(identifier)
}
// ═══════════════════════════════════════════════════════════
// 🔧 HELPERS
// ═══════════════════════════════════════════════════════════
func generateTokenID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// GetUserID obtém user ID do contexto
func GetUserID(c *fiber.Ctx) string {
if id, ok := c.Locals("user_id").(string); ok {
return id
}
return ""
}
// GetOrgID obtém org ID do contexto
func GetOrgID(c *fiber.Ctx) string {
if id, ok := c.Locals("org_id").(string); ok {
return id
}
return ""
}
// GetRole obtém role do contexto
func GetRole(c *fiber.Ctx) string {
if role, ok := c.Locals("role").(string); ok {
return role
}
return ""
}