278 lines
9.3 KiB
Python
Executable File
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()
|