172 lines
5.5 KiB
Python
172 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
|
from datetime import datetime, timedelta, timezone
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
from typing import Callable
|
|
|
|
from fastapi import Depends, Header, HTTPException
|
|
|
|
from services.shared.core import Role
|
|
|
|
|
|
def _bool_env(name: str, default: bool) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def legacy_header_auth_allowed() -> bool:
|
|
return _bool_env("ALLOW_LEGACY_HEADER_AUTH", True)
|
|
|
|
|
|
def _app_token_secret() -> str:
|
|
return os.getenv("APP_TOKEN_SECRET", "dev-secret-change-me")
|
|
|
|
|
|
def _app_token_ttl_seconds() -> int:
|
|
raw = os.getenv("APP_TOKEN_TTL_SECONDS", "3600").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 3600
|
|
return max(value, 60)
|
|
|
|
|
|
def _b64url_encode(raw: bytes) -> str:
|
|
return urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
|
|
|
|
|
|
def _b64url_decode(raw: str) -> bytes:
|
|
padding = "=" * (-len(raw) % 4)
|
|
return urlsafe_b64decode((raw + padding).encode("utf-8"))
|
|
|
|
|
|
def _sign(message: str) -> str:
|
|
digest = hmac.new(
|
|
_app_token_secret().encode("utf-8"),
|
|
message.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).digest()
|
|
return _b64url_encode(digest)
|
|
|
|
|
|
def issue_app_token(
|
|
*,
|
|
subject: str,
|
|
username: str,
|
|
role: str,
|
|
auth_source: str,
|
|
provider: str | None = None,
|
|
full_name: str | None = None,
|
|
email: str | None = None,
|
|
tenant_id: str | None = None,
|
|
ttl_seconds: int | None = None,
|
|
) -> str:
|
|
now = datetime.now(timezone.utc)
|
|
ttl = ttl_seconds if ttl_seconds is not None else _app_token_ttl_seconds()
|
|
header = {"alg": "HS256", "typ": "JWT"}
|
|
payload = {
|
|
"sub": subject,
|
|
"username": username,
|
|
"role": role,
|
|
"auth_source": auth_source,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int((now + timedelta(seconds=ttl)).timestamp()),
|
|
}
|
|
if provider:
|
|
payload["provider"] = provider
|
|
if full_name:
|
|
payload["full_name"] = full_name
|
|
if email:
|
|
payload["email"] = email
|
|
if tenant_id:
|
|
payload["tenant_id"] = tenant_id
|
|
|
|
encoded_header = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
|
encoded_payload = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
|
message = f"{encoded_header}.{encoded_payload}"
|
|
signature = _sign(message)
|
|
return f"{message}.{signature}"
|
|
|
|
|
|
def decode_app_token(token: str) -> dict:
|
|
try:
|
|
encoded_header, encoded_payload, encoded_signature = token.split(".")
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=401, detail="Invalid token format") from exc
|
|
|
|
message = f"{encoded_header}.{encoded_payload}"
|
|
expected_signature = _sign(message)
|
|
if not hmac.compare_digest(encoded_signature, expected_signature):
|
|
raise HTTPException(status_code=401, detail="Invalid token signature")
|
|
|
|
try:
|
|
payload = json.loads(_b64url_decode(encoded_payload).decode("utf-8"))
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=401, detail="Invalid token payload") from exc
|
|
|
|
exp = int(payload.get("exp", 0) or 0)
|
|
now_ts = int(datetime.now(timezone.utc).timestamp())
|
|
if exp and exp < now_ts:
|
|
raise HTTPException(status_code=401, detail="Token expired")
|
|
|
|
return payload
|
|
|
|
|
|
def get_actor(
|
|
authorization: str | None = Header(default=None, alias="Authorization"),
|
|
x_user: str | None = Header(default=None, alias="X-User"),
|
|
x_role: str | None = Header(default=None, alias="X-Role"),
|
|
x_tenant_id: str | None = Header(default=None, alias="X-Tenant-ID"),
|
|
) -> dict:
|
|
if authorization:
|
|
scheme, _, value = authorization.partition(" ")
|
|
if scheme.lower() != "bearer" or not value.strip():
|
|
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
|
payload = decode_app_token(value.strip())
|
|
tenant_id = str(payload.get("tenant_id") or "").strip()
|
|
tenant_source = "token" if tenant_id else None
|
|
if not tenant_id:
|
|
tenant_id = str(x_tenant_id or "").strip()
|
|
tenant_source = "header" if tenant_id else None
|
|
return {
|
|
"sub": payload.get("sub", ""),
|
|
"user": payload.get("username", "anonymous"),
|
|
"role": str(payload.get("role", "anonymous")).strip().lower() or "anonymous",
|
|
"auth_source": payload.get("auth_source", "token"),
|
|
"provider": payload.get("provider"),
|
|
"full_name": payload.get("full_name"),
|
|
"email": payload.get("email"),
|
|
"tenant_id": tenant_id or None,
|
|
"tenant_source": tenant_source,
|
|
}
|
|
|
|
if legacy_header_auth_allowed():
|
|
role = (x_role or "").strip().lower()
|
|
tenant_id = str(x_tenant_id or "").strip()
|
|
return {
|
|
"user": (x_user or "anonymous").strip(),
|
|
"role": role or "anonymous",
|
|
"auth_source": "legacy",
|
|
"tenant_id": tenant_id or None,
|
|
"tenant_source": "header" if tenant_id else None,
|
|
}
|
|
|
|
return {"user": "anonymous", "role": "anonymous", "auth_source": "none", "tenant_id": None, "tenant_source": None}
|
|
|
|
|
|
def require_roles(*allowed: Role) -> Callable:
|
|
allowed_values = {a.value for a in allowed}
|
|
|
|
def dependency(actor: dict = Depends(get_actor)) -> dict:
|
|
if actor["role"] not in allowed_values:
|
|
raise HTTPException(status_code=403, detail="Insufficient role")
|
|
return actor
|
|
|
|
return dependency
|