Files
Cognitive_Agent/src/cognitive_agent/auth.py
T

278 lines
9.3 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
from urllib.parse import urlsplit
PASSWORD_SCHEME = "pbkdf2_sha256"
PASSWORD_ITERATIONS = 600_000
def is_json_content_type(value: str) -> bool:
"""Return whether an HTTP Content-Type is JSON, allowing normal parameters."""
media_type = value.split(";", 1)[0].strip().casefold()
return media_type == "application/json"
def origin_matches_host(origin: str, host: str, scheme: str) -> bool:
"""Compare a browser Origin with the request's externally visible origin.
Host names are case-insensitive and default ports are normalized, but aliases
are intentionally not: ``localhost`` and ``127.0.0.1`` are different origins.
"""
normalized_scheme = scheme.strip().casefold()
if normalized_scheme not in {"http", "https"} or not origin or not host:
return False
try:
candidate = urlsplit(origin.strip())
expected = urlsplit(f"{normalized_scheme}://{host.strip()}")
if (
candidate.scheme.casefold() != normalized_scheme
or not candidate.netloc
or candidate.path
or candidate.query
or candidate.fragment
or candidate.username is not None
or candidate.password is not None
or not expected.netloc
or expected.path
or expected.query
or expected.fragment
or expected.username is not None
or expected.password is not None
):
return False
candidate_host = candidate.hostname
expected_host = expected.hostname
if candidate_host is None or expected_host is None:
return False
default_port = 443 if normalized_scheme == "https" else 80
candidate_port = candidate.port or default_port
expected_port = expected.port or default_port
except (UnicodeError, ValueError):
return False
return (
candidate_host.casefold() == expected_host.casefold()
and candidate_port == expected_port
)
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()