Files
ophion/internal/security/security.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

559 lines
16 KiB
Go

package security
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"net"
"regexp"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
"golang.org/x/crypto/bcrypt"
)
// ═══════════════════════════════════════════════════════════
// 🔐 CONSTANTES DE SEGURANÇA
// ═══════════════════════════════════════════════════════════
const (
// Bcrypt cost (12-14 recomendado para produção)
BcryptCost = 12
// Tamanho mínimo de senha
MinPasswordLength = 12
// Tamanho da API Key
APIKeyLength = 32
// Prefixo da API Key
APIKeyPrefix = "ophion_"
// Rate limit padrão (requests por minuto)
DefaultRateLimit = 100
// Rate limit para auth (tentativas por minuto)
AuthRateLimit = 5
// Tempo de bloqueio após falhas (minutos)
LockoutDuration = 15
// Máximo de tentativas antes do bloqueio
MaxFailedAttempts = 5
)
// ═══════════════════════════════════════════════════════════
// 🔑 API KEY MANAGEMENT
// ═══════════════════════════════════════════════════════════
// GenerateAPIKey cria uma nova API key segura
func GenerateAPIKey() (key string, hash string, prefix string) {
bytes := make([]byte, APIKeyLength)
if _, err := rand.Read(bytes); err != nil {
panic("failed to generate random bytes")
}
key = APIKeyPrefix + hex.EncodeToString(bytes)
hash = HashAPIKey(key)
prefix = key[:len(APIKeyPrefix)+8] // ophion_xxxxxxxx
return key, hash, prefix
}
// HashAPIKey cria hash SHA256 da API key
func HashAPIKey(key string) string {
hash := sha256.Sum256([]byte(key))
return hex.EncodeToString(hash[:])
}
// ValidateAPIKeyFormat verifica formato da API key
func ValidateAPIKeyFormat(key string) bool {
if !strings.HasPrefix(key, APIKeyPrefix) {
return false
}
// ophion_ + 64 hex chars
return len(key) == len(APIKeyPrefix)+64
}
// SecureCompare compara strings em tempo constante (previne timing attacks)
func SecureCompare(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// ═══════════════════════════════════════════════════════════
// 🔒 PASSWORD MANAGEMENT
// ═══════════════════════════════════════════════════════════
// PasswordPolicy define regras de senha
type PasswordPolicy struct {
MinLength int
RequireUppercase bool
RequireLowercase bool
RequireNumbers bool
RequireSpecial bool
}
// DefaultPasswordPolicy retorna política padrão
func DefaultPasswordPolicy() PasswordPolicy {
return PasswordPolicy{
MinLength: 12,
RequireUppercase: true,
RequireLowercase: true,
RequireNumbers: true,
RequireSpecial: true,
}
}
// ValidatePassword verifica se senha atende à política
func ValidatePassword(password string, policy PasswordPolicy) (bool, []string) {
var errors []string
if len(password) < policy.MinLength {
errors = append(errors, fmt.Sprintf("Senha deve ter no mínimo %d caracteres", policy.MinLength))
}
if policy.RequireUppercase && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
errors = append(errors, "Senha deve conter letra maiúscula")
}
if policy.RequireLowercase && !regexp.MustCompile(`[a-z]`).MatchString(password) {
errors = append(errors, "Senha deve conter letra minúscula")
}
if policy.RequireNumbers && !regexp.MustCompile(`[0-9]`).MatchString(password) {
errors = append(errors, "Senha deve conter número")
}
if policy.RequireSpecial && !regexp.MustCompile(`[!@#$%^&*(),.?":{}|<>]`).MatchString(password) {
errors = append(errors, "Senha deve conter caractere especial")
}
// Verificar senhas comuns
if isCommonPassword(password) {
errors = append(errors, "Senha muito comum, escolha outra")
}
return len(errors) == 0, errors
}
// HashPassword cria hash bcrypt da senha
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// VerifyPassword verifica senha contra hash
func VerifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// isCommonPassword verifica senhas comuns
func isCommonPassword(password string) bool {
common := []string{
"password", "123456", "12345678", "qwerty", "abc123",
"monkey", "1234567", "letmein", "trustno1", "dragon",
"baseball", "iloveyou", "master", "sunshine", "ashley",
"passw0rd", "shadow", "123123", "654321", "superman",
"senha", "mudar123", "admin123", "root123",
}
lower := strings.ToLower(password)
for _, p := range common {
if strings.Contains(lower, p) {
return true
}
}
return false
}
// ═══════════════════════════════════════════════════════════
// 🚦 RATE LIMITING
// ═══════════════════════════════════════════════════════════
// RateLimiter controla taxa de requests
type RateLimiter struct {
sync.RWMutex
requests map[string]*rateLimitEntry
limit int
window time.Duration
}
type rateLimitEntry struct {
count int
firstSeen time.Time
blocked bool
blockedAt time.Time
}
// NewRateLimiter cria novo rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
requests: make(map[string]*rateLimitEntry),
limit: limit,
window: window,
}
// Cleanup goroutine
go rl.cleanup()
return rl
}
// Allow verifica se request é permitido
func (rl *RateLimiter) Allow(identifier string) bool {
rl.Lock()
defer rl.Unlock()
now := time.Now()
entry, exists := rl.requests[identifier]
if !exists {
rl.requests[identifier] = &rateLimitEntry{
count: 1,
firstSeen: now,
}
return true
}
// Verificar se está bloqueado
if entry.blocked {
if now.Sub(entry.blockedAt) < time.Duration(LockoutDuration)*time.Minute {
return false
}
// Desbloquear
entry.blocked = false
entry.count = 0
entry.firstSeen = now
}
// Verificar janela de tempo
if now.Sub(entry.firstSeen) > rl.window {
entry.count = 1
entry.firstSeen = now
return true
}
entry.count++
if entry.count > rl.limit {
return false
}
return true
}
// Block bloqueia um identificador
func (rl *RateLimiter) Block(identifier string) {
rl.Lock()
defer rl.Unlock()
entry, exists := rl.requests[identifier]
if !exists {
entry = &rateLimitEntry{}
rl.requests[identifier] = entry
}
entry.blocked = true
entry.blockedAt = time.Now()
}
// cleanup remove entradas antigas
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.Lock()
now := time.Now()
for id, entry := range rl.requests {
if now.Sub(entry.firstSeen) > rl.window*2 && !entry.blocked {
delete(rl.requests, id)
}
}
rl.Unlock()
}
}
// ═══════════════════════════════════════════════════════════
// 🛡️ BRUTE FORCE PROTECTION
// ═══════════════════════════════════════════════════════════
// LoginAttemptTracker rastreia tentativas de login
type LoginAttemptTracker struct {
sync.RWMutex
attempts map[string]*loginAttempt
}
type loginAttempt struct {
failures int
lastAttempt time.Time
lockedUntil time.Time
}
// NewLoginAttemptTracker cria tracker
func NewLoginAttemptTracker() *LoginAttemptTracker {
return &LoginAttemptTracker{
attempts: make(map[string]*loginAttempt),
}
}
// RecordFailure registra falha de login
func (t *LoginAttemptTracker) RecordFailure(identifier string) (blocked bool, remainingAttempts int) {
t.Lock()
defer t.Unlock()
now := time.Now()
attempt, exists := t.attempts[identifier]
if !exists {
t.attempts[identifier] = &loginAttempt{
failures: 1,
lastAttempt: now,
}
return false, MaxFailedAttempts - 1
}
// Reset se última tentativa foi há muito tempo
if now.Sub(attempt.lastAttempt) > time.Duration(LockoutDuration)*time.Minute {
attempt.failures = 1
attempt.lastAttempt = now
attempt.lockedUntil = time.Time{}
return false, MaxFailedAttempts - 1
}
attempt.failures++
attempt.lastAttempt = now
if attempt.failures >= MaxFailedAttempts {
attempt.lockedUntil = now.Add(time.Duration(LockoutDuration) * time.Minute)
return true, 0
}
return false, MaxFailedAttempts - attempt.failures
}
// RecordSuccess registra sucesso de login
func (t *LoginAttemptTracker) RecordSuccess(identifier string) {
t.Lock()
defer t.Unlock()
delete(t.attempts, identifier)
}
// IsLocked verifica se está bloqueado
func (t *LoginAttemptTracker) IsLocked(identifier string) (bool, time.Duration) {
t.RLock()
defer t.RUnlock()
attempt, exists := t.attempts[identifier]
if !exists {
return false, 0
}
if attempt.lockedUntil.IsZero() {
return false, 0
}
remaining := time.Until(attempt.lockedUntil)
if remaining <= 0 {
return false, 0
}
return true, remaining
}
// ═══════════════════════════════════════════════════════════
// 🔍 INPUT VALIDATION & SANITIZATION
// ═══════════════════════════════════════════════════════════
// SanitizeEmail limpa e valida email
func SanitizeEmail(email string) (string, error) {
email = strings.TrimSpace(strings.ToLower(email))
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(email) {
return "", fmt.Errorf("email inválido")
}
return email, nil
}
// SanitizeHostname limpa hostname
func SanitizeHostname(hostname string) (string, error) {
hostname = strings.TrimSpace(hostname)
// Permitir apenas alfanuméricos, hífens e pontos
hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]{0,253}[a-zA-Z0-9]$`)
if !hostnameRegex.MatchString(hostname) && len(hostname) > 1 {
return "", fmt.Errorf("hostname inválido")
}
return hostname, nil
}
// SanitizeSQL previne SQL injection (para queries dinâmicas)
func SanitizeSQL(input string) string {
// Remover caracteres perigosos
dangerous := []string{"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_"}
result := input
for _, d := range dangerous {
result = strings.ReplaceAll(result, d, "")
}
return result
}
// ValidateIPAddress valida endereço IP
func ValidateIPAddress(ip string) bool {
return net.ParseIP(ip) != nil
}
// ═══════════════════════════════════════════════════════════
// 🛡️ SECURITY HEADERS MIDDLEWARE
// ═══════════════════════════════════════════════════════════
// SecurityHeaders adiciona headers de segurança
func SecurityHeaders() fiber.Handler {
return func(c *fiber.Ctx) error {
// Prevenir clickjacking
c.Set("X-Frame-Options", "DENY")
// Prevenir MIME type sniffing
c.Set("X-Content-Type-Options", "nosniff")
// XSS Protection
c.Set("X-XSS-Protection", "1; mode=block")
// Content Security Policy
c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:")
// HSTS (apenas se HTTPS)
if c.Protocol() == "https" {
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
// Referrer Policy
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions Policy
c.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
// Remove Server header
c.Set("Server", "")
return c.Next()
}
}
// ═══════════════════════════════════════════════════════════
// 📝 AUDIT LOGGING
// ═══════════════════════════════════════════════════════════
// AuditEvent representa um evento de auditoria
type AuditEvent struct {
Timestamp time.Time `json:"timestamp"`
EventType string `json:"event_type"`
UserID string `json:"user_id,omitempty"`
OrgID string `json:"org_id,omitempty"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Resource string `json:"resource"`
Action string `json:"action"`
Status string `json:"status"`
Details map[string]string `json:"details,omitempty"`
}
// AuditEventType tipos de eventos
const (
AuditLogin = "auth.login"
AuditLogout = "auth.logout"
AuditLoginFailed = "auth.login_failed"
AuditAPIKeyCreated = "apikey.created"
AuditAPIKeyRevoked = "apikey.revoked"
AuditUserCreated = "user.created"
AuditUserDeleted = "user.deleted"
AuditConfigChanged = "config.changed"
AuditAlertCreated = "alert.created"
AuditDataExport = "data.export"
)
// AuditLogger interface para logging de auditoria
type AuditLogger interface {
Log(event AuditEvent) error
}
// ═══════════════════════════════════════════════════════════
// 🔐 SECRETS MANAGEMENT
// ═══════════════════════════════════════════════════════════
// GenerateSecureToken gera token seguro
func GenerateSecureToken(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
panic("failed to generate secure token")
}
return base64.URLEncoding.EncodeToString(bytes)[:length]
}
// MaskSecret mascara segredos para logging
func MaskSecret(secret string) string {
if len(secret) <= 8 {
return "****"
}
return secret[:4] + "****" + secret[len(secret)-4:]
}
// ═══════════════════════════════════════════════════════════
// 🌐 IP FILTERING
// ═══════════════════════════════════════════════════════════
// IPWhitelist gerencia whitelist de IPs
type IPWhitelist struct {
sync.RWMutex
allowed map[string]bool
cidrs []*net.IPNet
}
// NewIPWhitelist cria whitelist
func NewIPWhitelist(ips []string, cidrs []string) *IPWhitelist {
wl := &IPWhitelist{
allowed: make(map[string]bool),
}
for _, ip := range ips {
wl.allowed[ip] = true
}
for _, cidr := range cidrs {
_, network, err := net.ParseCIDR(cidr)
if err == nil {
wl.cidrs = append(wl.cidrs, network)
}
}
return wl
}
// IsAllowed verifica se IP está permitido
func (wl *IPWhitelist) IsAllowed(ip string) bool {
wl.RLock()
defer wl.RUnlock()
// Verificar lista direta
if wl.allowed[ip] {
return true
}
// Verificar CIDRs
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false
}
for _, network := range wl.cidrs {
if network.Contains(parsedIP) {
return true
}
}
return false
}