init
This commit is contained in:
Executable
+226
@@ -0,0 +1,226 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user