- 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
374 lines
11 KiB
Go
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 ""
|
|
}
|