Files
Cognitive_Agent/src/cognitive_agent/auth.py
T
2026-07-30 08:45:38 +08:00

227 lines
7.4 KiB
Python
Executable File

"""Authentication and abuse controls for the public demo server."""
from __future__ import annotations
import argparse
import base64
import getpass
import hashlib
import hmac
import json
import os
import secrets
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from threading import RLock
PASSWORD_SCHEME = "pbkdf2_sha256"
PASSWORD_ITERATIONS = 600_000
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _b64decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def hash_password(
password: str,
*,
salt: bytes | None = None,
iterations: int = PASSWORD_ITERATIONS,
) -> str:
if len(password) < 12:
raise ValueError("demo password must contain at least 12 characters")
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return f"{PASSWORD_SCHEME}${iterations}${_b64encode(salt)}${_b64encode(digest)}"
def verify_password(password: str, encoded: str) -> bool:
try:
scheme, iteration_text, salt_text, digest_text = encoded.split("$", 3)
if scheme != PASSWORD_SCHEME:
return False
iterations = int(iteration_text)
if not 100_000 <= iterations <= 2_000_000:
return False
expected = _b64decode(digest_text)
actual = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
_b64decode(salt_text),
iterations,
)
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class AuthConfig:
enabled: bool
username: str
password_hash: str
session_secret: bytes
session_ttl_seconds: int
login_attempts: int
login_window_seconds: int
requests_per_minute: int
trust_proxy_headers: bool
cookie_name: str = "cognitive_agent_session"
@classmethod
def from_env(cls) -> AuthConfig:
enabled = _env_bool("DEMO_AUTH_ENABLED")
username = os.getenv("DEMO_AUTH_USERNAME", "").strip()
password_hash = os.getenv("DEMO_AUTH_PASSWORD_HASH", "").strip()
secret_text = os.getenv("DEMO_AUTH_SESSION_SECRET", "").strip()
if enabled:
if not username or len(username) > 64:
raise RuntimeError("DEMO_AUTH_USERNAME must contain 1-64 characters")
if not password_hash.startswith(f"{PASSWORD_SCHEME}$"):
raise RuntimeError("DEMO_AUTH_PASSWORD_HASH is missing or unsupported")
if len(secret_text) < 43:
raise RuntimeError("DEMO_AUTH_SESSION_SECRET must contain at least 43 characters")
return cls(
enabled=enabled,
username=username,
password_hash=password_hash,
session_secret=secret_text.encode("utf-8"),
session_ttl_seconds=max(
300,
min(int(os.getenv("DEMO_AUTH_SESSION_TTL_SECONDS", "28800")), 604800),
),
login_attempts=max(
1,
min(int(os.getenv("DEMO_AUTH_LOGIN_ATTEMPTS", "5")), 20),
),
login_window_seconds=max(
60,
min(int(os.getenv("DEMO_AUTH_LOGIN_WINDOW_SECONDS", "300")), 3600),
),
requests_per_minute=max(
1,
min(int(os.getenv("DEMO_REQUESTS_PER_MINUTE", "10")), 120),
),
trust_proxy_headers=_env_bool("DEMO_TRUST_PROXY_HEADERS"),
)
class SessionCodec:
def __init__(self, config: AuthConfig) -> None:
self.config = config
def issue(self, username: str, now: int | None = None) -> str:
now = int(time.time()) if now is None else now
payload = {
"sub": username,
"iat": now,
"exp": now + self.config.session_ttl_seconds,
"nonce": secrets.token_urlsafe(12),
}
encoded = _b64encode(
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
)
signature = _b64encode(
hmac.new(self.config.session_secret, encoded.encode("ascii"), hashlib.sha256).digest()
)
return f"{encoded}.{signature}"
def verify(self, token: str, now: int | None = None) -> str | None:
if not token or len(token) > 2048:
return None
try:
encoded, signature = token.split(".", 1)
expected = _b64encode(
hmac.new(
self.config.session_secret,
encoded.encode("ascii"),
hashlib.sha256,
).digest()
)
if not hmac.compare_digest(signature, expected):
return None
payload = json.loads(_b64decode(encoded))
current_time = int(time.time()) if now is None else now
if payload.get("sub") != self.config.username:
return None
if not int(payload.get("iat", 0)) <= current_time < int(payload.get("exp", 0)):
return None
return str(payload["sub"])
except (ValueError, TypeError, json.JSONDecodeError):
return None
class SlidingWindowLimiter:
def __init__(self, limit: int, window_seconds: int) -> None:
self.limit = limit
self.window_seconds = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
self._lock = RLock()
def retry_after(self, key: str, now: float | None = None) -> int:
current = time.monotonic() if now is None else now
with self._lock:
events = self._events[key]
cutoff = current - self.window_seconds
while events and events[0] <= cutoff:
events.popleft()
if len(events) < self.limit:
return 0
return max(1, int(self.window_seconds - (current - events[0]) + 0.999))
def record(self, key: str, now: float | None = None) -> None:
current = time.monotonic() if now is None else now
with self._lock:
self._events[key].append(current)
def clear(self, key: str) -> None:
with self._lock:
self._events.pop(key, None)
class AuthRuntime:
def __init__(self, config: AuthConfig) -> None:
self.config = config
self.sessions = SessionCodec(config)
self.login_failures = SlidingWindowLimiter(
config.login_attempts,
config.login_window_seconds,
)
self.api_requests = SlidingWindowLimiter(config.requests_per_minute, 60)
def main() -> None:
parser = argparse.ArgumentParser(description="Cognitive Agent authentication utilities")
parser.add_argument("command", choices=["hash-password", "generate-secret"])
args = parser.parse_args()
if args.command == "generate-secret":
print(secrets.token_urlsafe(48))
return
password = getpass.getpass("Demo password: ")
confirmation = getpass.getpass("Confirm password: ")
if password != confirmation:
raise SystemExit("Passwords do not match")
print(hash_password(password))
if __name__ == "__main__":
main()