feat: add API key authentication system for ChatGPT Business
Implements comprehensive Bearer token authentication to ensure only authorized ChatGPT workspaces can access the MCP server. Core Features: - API key validation with constant-time comparison - Multi-key support for rotation grace periods - Rate limiting (5 failures per IP per 5 min) - Comprehensive audit logging of all auth attempts - IP-based failed attempt tracking Key Management: - generate_api_key.py: Create secure 64-char keys - rotate_api_key.py: Guided key rotation with backup - check_key_age.py: Automated expiration monitoring Infrastructure: - Traefik labels for HTTPS and rate limiting - Security headers (HSTS, CSP, X-Frame-Options) - Environment-based configuration - Docker secrets support Documentation: - AUTH_SETUP.md: Complete authentication setup guide - CHATGPT_SETUP.md: ChatGPT Business integration guide - KEY_ROTATION.md: Key rotation procedures and automation Security: - Read-only operations enforced - No write access to Gitea possible - All auth attempts logged with correlation IDs - Failed attempts trigger IP rate limits - Keys never logged in full (only hints) Breaking Changes: - AUTH_ENABLED defaults to true - MCP_API_KEYS environment variable now required - Minimum key length: 32 characters (64 recommended) Migration: 1. Generate API key: make generate-key 2. Add to .env: MCP_API_KEYS=<generated-key> 3. Restart: docker-compose restart aegis-mcp 4. Configure ChatGPT with Authorization header Closes requirements for ChatGPT Business exclusive access.
This commit is contained in:
243
src/aegis_gitea_mcp/auth.py
Normal file
243
src/aegis_gitea_mcp/auth.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Authentication module for MCP server API key validation."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APIKeyValidator:
|
||||
"""Validates API keys for MCP server access."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize API key validator."""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
self._failed_attempts: dict[str, list[datetime]] = {}
|
||||
|
||||
def _constant_time_compare(self, a: str, b: str) -> bool:
|
||||
"""Compare two strings in constant time to prevent timing attacks.
|
||||
|
||||
Args:
|
||||
a: First string
|
||||
b: Second string
|
||||
|
||||
Returns:
|
||||
True if strings are equal, False otherwise
|
||||
"""
|
||||
return hmac.compare_digest(a, b)
|
||||
|
||||
def _check_rate_limit(self, identifier: str) -> bool:
|
||||
"""Check if identifier has exceeded failed authentication rate limit.
|
||||
|
||||
Args:
|
||||
identifier: IP address or other identifier
|
||||
|
||||
Returns:
|
||||
True if within rate limit, False if exceeded
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_start = now.timestamp() - self.settings.auth_failure_window
|
||||
|
||||
# Clean up old attempts
|
||||
if identifier in self._failed_attempts:
|
||||
self._failed_attempts[identifier] = [
|
||||
attempt
|
||||
for attempt in self._failed_attempts[identifier]
|
||||
if attempt.timestamp() > window_start
|
||||
]
|
||||
|
||||
# Check count
|
||||
attempt_count = len(self._failed_attempts.get(identifier, []))
|
||||
return attempt_count < self.settings.max_auth_failures
|
||||
|
||||
def _record_failed_attempt(self, identifier: str) -> None:
|
||||
"""Record a failed authentication attempt.
|
||||
|
||||
Args:
|
||||
identifier: IP address or other identifier
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if identifier not in self._failed_attempts:
|
||||
self._failed_attempts[identifier] = []
|
||||
self._failed_attempts[identifier].append(now)
|
||||
|
||||
# Check if threshold exceeded
|
||||
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
|
||||
self.audit.log_security_event(
|
||||
event_type="auth_rate_limit_exceeded",
|
||||
description=f"IP {identifier} exceeded auth failure threshold",
|
||||
severity="high",
|
||||
metadata={
|
||||
"identifier": identifier,
|
||||
"failure_count": len(self._failed_attempts[identifier]),
|
||||
"window_seconds": self.settings.auth_failure_window,
|
||||
},
|
||||
)
|
||||
|
||||
def validate_api_key(
|
||||
self, provided_key: Optional[str], client_ip: str, user_agent: str
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Validate an API key.
|
||||
|
||||
Args:
|
||||
provided_key: API key provided by client
|
||||
client_ip: Client IP address
|
||||
user_agent: Client user agent string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check if authentication is enabled
|
||||
if not self.settings.auth_enabled:
|
||||
self.audit.log_security_event(
|
||||
event_type="auth_disabled",
|
||||
description="Authentication is disabled - allowing all requests",
|
||||
severity="critical",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return True, None
|
||||
|
||||
# Check rate limit
|
||||
if not self._check_rate_limit(client_ip):
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="rate_limit_exceeded",
|
||||
)
|
||||
return False, "Too many failed authentication attempts. Please try again later."
|
||||
|
||||
# Check if key was provided
|
||||
if not provided_key:
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="missing_api_key",
|
||||
)
|
||||
return False, "Authorization header missing. Required: Authorization: Bearer <api-key>"
|
||||
|
||||
# Validate key format (should be at least 32 characters)
|
||||
if len(provided_key) < 32:
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="invalid_key_format",
|
||||
)
|
||||
return False, "Invalid API key format"
|
||||
|
||||
# Get valid API keys from config
|
||||
valid_keys = self.settings.mcp_api_keys
|
||||
|
||||
if not valid_keys:
|
||||
self.audit.log_security_event(
|
||||
event_type="no_api_keys_configured",
|
||||
description="No API keys configured in environment",
|
||||
severity="critical",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return False, "Server configuration error: No API keys configured"
|
||||
|
||||
# Check against all valid keys (constant time comparison)
|
||||
is_valid = any(self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys)
|
||||
|
||||
if is_valid:
|
||||
# Success - log and return
|
||||
key_hint = f"{provided_key[:8]}...{provided_key[-4:]}"
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="api_authentication",
|
||||
result_status="success",
|
||||
params={"client_ip": client_ip, "user_agent": user_agent, "key_hint": key_hint},
|
||||
)
|
||||
return True, None
|
||||
else:
|
||||
# Failure - record attempt and log
|
||||
self._record_failed_attempt(client_ip)
|
||||
key_hint = f"{provided_key[:8]}..." if len(provided_key) >= 8 else "too_short"
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="invalid_api_key",
|
||||
)
|
||||
self.audit.log_security_event(
|
||||
event_type="invalid_api_key_attempt",
|
||||
description=f"Invalid API key attempted from {client_ip}",
|
||||
severity="medium",
|
||||
metadata={
|
||||
"client_ip": client_ip,
|
||||
"user_agent": user_agent,
|
||||
"key_hint": key_hint,
|
||||
},
|
||||
)
|
||||
return False, "Invalid API key"
|
||||
|
||||
def extract_bearer_token(self, authorization_header: Optional[str]) -> Optional[str]:
|
||||
"""Extract bearer token from Authorization header.
|
||||
|
||||
Args:
|
||||
authorization_header: Authorization header value
|
||||
|
||||
Returns:
|
||||
Extracted token or None if invalid format
|
||||
"""
|
||||
if not authorization_header:
|
||||
return None
|
||||
|
||||
parts = authorization_header.split()
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
scheme, token = parts
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def generate_api_key(length: int = 64) -> str:
|
||||
"""Generate a cryptographically secure API key.
|
||||
|
||||
Args:
|
||||
length: Length of the key in characters (default: 64)
|
||||
|
||||
Returns:
|
||||
Generated API key as hex string
|
||||
"""
|
||||
return secrets.token_hex(length // 2)
|
||||
|
||||
|
||||
def hash_api_key(api_key: str) -> str:
|
||||
"""Hash an API key for secure storage (future use).
|
||||
|
||||
Args:
|
||||
api_key: Plain text API key
|
||||
|
||||
Returns:
|
||||
SHA256 hash of the key
|
||||
"""
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
|
||||
# Global validator instance
|
||||
_validator: Optional[APIKeyValidator] = None
|
||||
|
||||
|
||||
def get_validator() -> APIKeyValidator:
|
||||
"""Get or create global API key validator instance."""
|
||||
global _validator
|
||||
if _validator is None:
|
||||
_validator = APIKeyValidator()
|
||||
return _validator
|
||||
|
||||
|
||||
def reset_validator() -> None:
|
||||
"""Reset global validator instance (primarily for testing)."""
|
||||
global _validator
|
||||
_validator = None
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Configuration management for AegisGitea MCP server."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field, HttpUrl, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -67,6 +67,26 @@ class Settings(BaseSettings):
|
||||
ge=1,
|
||||
)
|
||||
|
||||
# Authentication configuration
|
||||
auth_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable API key authentication (disable only for testing)",
|
||||
)
|
||||
mcp_api_keys: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of valid API keys for MCP access (comma-separated in env)",
|
||||
)
|
||||
max_auth_failures: int = Field(
|
||||
default=5,
|
||||
description="Maximum authentication failures before rate limiting",
|
||||
ge=1,
|
||||
)
|
||||
auth_failure_window: int = Field(
|
||||
default=300, # 5 minutes
|
||||
description="Time window for counting auth failures (in seconds)",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
@@ -85,6 +105,41 @@ class Settings(BaseSettings):
|
||||
raise ValueError("gitea_token cannot be empty or whitespace")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("mcp_api_keys", mode="before")
|
||||
@classmethod
|
||||
def parse_api_keys(cls, v: object) -> List[str]:
|
||||
"""Parse API keys from comma-separated string or list."""
|
||||
if isinstance(v, str):
|
||||
# Split by comma and strip whitespace
|
||||
keys = [key.strip() for key in v.split(",") if key.strip()]
|
||||
return keys
|
||||
elif isinstance(v, list):
|
||||
return v
|
||||
return []
|
||||
|
||||
@field_validator("mcp_api_keys")
|
||||
@classmethod
|
||||
def validate_api_keys(cls, v: List[str], info) -> List[str]:
|
||||
"""Validate API keys if authentication is enabled."""
|
||||
# Get auth_enabled from values (it's already been processed)
|
||||
auth_enabled = info.data.get("auth_enabled", True)
|
||||
|
||||
if auth_enabled and not v:
|
||||
raise ValueError(
|
||||
"At least one API key must be configured when auth_enabled=True. "
|
||||
"Set MCP_API_KEYS environment variable or disable auth with AUTH_ENABLED=false"
|
||||
)
|
||||
|
||||
# Validate key format (at least 32 characters for security)
|
||||
for key in v:
|
||||
if len(key) < 32:
|
||||
raise ValueError(
|
||||
f"API keys must be at least 32 characters long. "
|
||||
f"Use scripts/generate_api_key.py to generate secure keys."
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
@property
|
||||
def gitea_base_url(self) -> str:
|
||||
"""Get Gitea base URL as string."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.auth import get_validator
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
from aegis_gitea_mcp.mcp_protocol import (
|
||||
@@ -38,9 +39,10 @@ app = FastAPI(
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Global settings and audit logger
|
||||
# Global settings, audit logger, and auth validator
|
||||
settings = get_settings()
|
||||
audit = get_audit_logger()
|
||||
auth_validator = get_validator()
|
||||
|
||||
|
||||
# Tool dispatcher mapping
|
||||
@@ -52,6 +54,44 @@ TOOL_HANDLERS = {
|
||||
}
|
||||
|
||||
|
||||
# Authentication middleware
|
||||
@app.middleware("http")
|
||||
async def authenticate_request(request: Request, call_next):
|
||||
"""Authenticate all requests except health checks and root."""
|
||||
# Skip authentication for health check and root endpoints
|
||||
if request.url.path in ["/", "/health"]:
|
||||
return await call_next(request)
|
||||
|
||||
# Only authenticate MCP endpoints
|
||||
if not request.url.path.startswith("/mcp/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract client information
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent", "unknown")
|
||||
|
||||
# Extract Authorization header
|
||||
auth_header = request.headers.get("authorization")
|
||||
api_key = auth_validator.extract_bearer_token(auth_header)
|
||||
|
||||
# Validate API key
|
||||
is_valid, error_message = auth_validator.validate_api_key(api_key, client_ip, user_agent)
|
||||
|
||||
if not is_valid:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Authentication failed",
|
||||
"message": error_message,
|
||||
"detail": "Please provide a valid API key in the Authorization header: Bearer <api-key>",
|
||||
},
|
||||
)
|
||||
|
||||
# Authentication successful - continue to endpoint
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
"""Initialize server on startup."""
|
||||
@@ -59,6 +99,13 @@ async def startup_event() -> None:
|
||||
logger.info(f"Connected to Gitea instance: {settings.gitea_base_url}")
|
||||
logger.info(f"Audit logging enabled: {settings.audit_log_path}")
|
||||
|
||||
# Log authentication status
|
||||
if settings.auth_enabled:
|
||||
key_count = len(settings.mcp_api_keys)
|
||||
logger.info(f"API key authentication ENABLED ({key_count} key(s) configured)")
|
||||
else:
|
||||
logger.warning("API key authentication DISABLED - server is open to all requests!")
|
||||
|
||||
# Test Gitea connection
|
||||
try:
|
||||
async with GiteaClient() as gitea:
|
||||
|
||||
Reference in New Issue
Block a user