auth.py 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. """
  2. api.middleware.auth — API Key authentication middleware.
  3. Security: Only SHA-256 hash stored. Key shown once at creation.
  4. Format: ap_xxxxxxxxxxxxxxxx (32 char random, prefix ap_)
  5. """
  6. from __future__ import annotations
  7. import hashlib
  8. import time
  9. from typing import Optional
  10. from agentpaas.db.session import get_db
  11. from agentpaas.db.models import now_utc
  12. class AuthError(Exception):
  13. def __init__(self, message: str, code: int = 401):
  14. self.message = message
  15. self.code = code
  16. super().__init__(message)
  17. class TenantContext:
  18. """Injected into request state after authentication."""
  19. def __init__(self, tenant_id: str, tenant_name: str, plan: str,
  20. user_id: str = "", role: str = "developer",
  21. scopes: list = None, rate_limit: int = 60):
  22. self.tenant_id = tenant_id
  23. self.tenant_name = tenant_name
  24. self.plan = plan
  25. self.user_id = user_id
  26. self.role = role
  27. self.scopes = scopes or []
  28. self.rate_limit = rate_limit
  29. def _legacy_hash_key(key: str) -> str:
  30. """Legacy SHA-256 hash (for migration)."""
  31. return hashlib.sha256(key.encode()).hexdigest()
  32. def hash_key(key: str) -> str:
  33. """SEC-20: PBKDF2 hash for API keys (migration-compatible).
  34. New keys use PBKDF2. Legacy SHA-256 hashes are still accepted
  35. for backward compatibility but should be migrated.
  36. """
  37. # Use PBKDF2 with fixed salt derived from key prefix
  38. # Fixed salt ensures same key always produces same hash (needed for lookup)
  39. salt = hashlib.sha256(key[:8].encode()).digest()[:16]
  40. dk = hashlib.pbkdf2_hmac('sha256', key.encode(), salt, 100000)
  41. return dk.hex()
  42. async def authenticate(api_key: str) -> TenantContext:
  43. """
  44. Authenticate via API Key.
  45. Returns TenantContext with tenant info and permissions.
  46. """
  47. if not api_key:
  48. raise AuthError("Missing API key", 401)
  49. # Strip "Bearer " prefix if present
  50. if api_key.startswith("Bearer "):
  51. api_key = api_key[7:]
  52. db = get_db()
  53. # Try PBKDF2 hash first, fall back to legacy SHA-256
  54. key_hash = hash_key(api_key)
  55. record = db.fetchone(
  56. "SELECT k.*, t.name as tenant_name, t.plan, t.status as tenant_status "
  57. "FROM api_keys k JOIN tenants t ON k.tenant_id = t.id "
  58. "WHERE k.key_hash = ? AND k.status = 'active'",
  59. (key_hash,)
  60. )
  61. if not record:
  62. # Try legacy hash for migration
  63. legacy_hash = _legacy_hash_key(api_key)
  64. record = db.fetchone(
  65. "SELECT k.*, t.name as tenant_name, t.plan, t.status as tenant_status "
  66. "FROM api_keys k JOIN tenants t ON k.tenant_id = t.id "
  67. "WHERE k.key_hash = ? AND k.status = 'active'",
  68. (legacy_hash,)
  69. )
  70. if record:
  71. # Auto-migrate to PBKDF2
  72. db.execute("UPDATE api_keys SET key_hash = ? WHERE id = ?",
  73. (key_hash, record["id"]))
  74. db.commit()
  75. if not record:
  76. raise AuthError("Invalid API key", 401)
  77. if record["tenant_status"] != "active":
  78. raise AuthError("Tenant suspended", 403)
  79. if record.get("expires_at") and record["expires_at"] < now_utc():
  80. raise AuthError("API key expired", 401)
  81. # Update last_used_at
  82. db.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?",
  83. (now_utc(), record["id"]))
  84. db.commit()
  85. import json
  86. scopes = json.loads(record.get("scopes", "[]"))
  87. return TenantContext(
  88. tenant_id=record["tenant_id"],
  89. tenant_name=record["tenant_name"],
  90. plan=record["plan"],
  91. user_id=record.get("user_id", ""),
  92. scopes=scopes,
  93. rate_limit=record.get("rate_limit", 60),
  94. )