🔐 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
This commit is contained in:
558
internal/security/security.go
Normal file
558
internal/security/security.go
Normal file
@@ -0,0 +1,558 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user