🔐 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:
@@ -1,93 +1,373 @@
|
||||
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"
|
||||
)
|
||||
|
||||
var jwtSecret []byte
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🔐 AUTH CONFIG
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
func Init(secret string) {
|
||||
jwtSecret = []byte(secret)
|
||||
type AuthConfig struct {
|
||||
JWTSecret []byte
|
||||
JWTExpiration time.Duration
|
||||
RefreshExpiration time.Duration
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// GenerateAPIKey creates a new API key for agents
|
||||
func GenerateAPIKey() string {
|
||||
bytes := make([]byte, 32)
|
||||
rand.Read(bytes)
|
||||
return "ophion_" + hex.EncodeToString(bytes)
|
||||
type AuthService struct {
|
||||
config AuthConfig
|
||||
redis *redis.Client
|
||||
rateLimiter *security.RateLimiter
|
||||
loginTracker *security.LoginAttemptTracker
|
||||
apiKeyStore APIKeyStore
|
||||
}
|
||||
|
||||
// GenerateJWT creates a JWT token for users
|
||||
func GenerateJWT(userID string, email string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"email": email,
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
// 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,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token
|
||||
func ValidateJWT(tokenString string) (*jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎫 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 || !token.Valid {
|
||||
return nil, err
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, jwt.ErrInvalidKey
|
||||
|
||||
claims, ok := token.Claims.(*TokenClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// AuthMiddleware protects routes
|
||||
func AuthMiddleware() fiber.Handler {
|
||||
// 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 {
|
||||
authHeader := c.Get("Authorization")
|
||||
// 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": "Missing authorization header",
|
||||
"error": "Unauthorized",
|
||||
"message": "Missing authorization header",
|
||||
})
|
||||
}
|
||||
|
||||
// Support both "Bearer <token>" and API keys
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Check if it's an API key
|
||||
if strings.HasPrefix(token, "ophion_") {
|
||||
// TODO: Validate API key against database
|
||||
c.Locals("auth_type", "api_key")
|
||||
c.Locals("api_key", token)
|
||||
return c.Next()
|
||||
// API Key authentication
|
||||
if strings.HasPrefix(token, security.APIKeyPrefix) {
|
||||
return s.authenticateAPIKey(c, token)
|
||||
}
|
||||
|
||||
// JWT authentication
|
||||
return s.authenticateJWT(c, token)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JWT
|
||||
claims, err := ValidateJWT(token)
|
||||
if err != nil {
|
||||
return c.Status(401).JSON(fiber.Map{
|
||||
"error": "Invalid 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",
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("auth_type", "jwt")
|
||||
c.Locals("user_id", (*claims)["sub"])
|
||||
c.Locals("email", (*claims)["email"])
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user