mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
180 lines
5.7 KiB
Python
180 lines
5.7 KiB
Python
"""Remote access authentication middleware."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
import string
|
|
import time
|
|
from collections.abc import Awaitable, Callable, Iterable
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse, Response
|
|
from loguru import logger
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
from backend.modules.auth.utils import validate_session
|
|
|
|
|
|
AUTH_COOKIE_NAME = "CountBot_token"
|
|
SETUP_SECRET_HEADER_NAME = "x-setup-secret"
|
|
SETUP_SECRET_LENGTH = 8
|
|
SETUP_SECRET_ALPHABET = string.ascii_letters
|
|
SETUP_SECRET_TTL_MINUTES_ENV = "REMOTE_SETUP_SECRET_TTL_MINUTES"
|
|
SETUP_SECRET_TTL_MINUTES_MIN = 10
|
|
SETUP_SECRET_TTL_MINUTES_MAX = 120
|
|
SETUP_SECRET_TTL_MINUTES_DEFAULT = 30
|
|
|
|
_FORWARDED_HEADER_NAMES = {
|
|
"x-forwarded-for",
|
|
"x-real-ip",
|
|
"forwarded",
|
|
}
|
|
|
|
_PUBLIC_PATH_PREFIXES = (
|
|
"/api/auth/",
|
|
"/api/health",
|
|
"/api/system/health",
|
|
)
|
|
|
|
_PROTECTED_PATH_PREFIXES = (
|
|
"/api/",
|
|
)
|
|
|
|
_LOCAL_ONLY_SETUP_PATHS = (
|
|
"/api/auth/setup",
|
|
)
|
|
|
|
|
|
def get_remote_setup_secret(app) -> str:
|
|
return getattr(app.state, "remote_setup_secret", "")
|
|
|
|
|
|
def get_remote_setup_secret_expires_at(app) -> float:
|
|
return float(getattr(app.state, "remote_setup_secret_expires_at", 0.0) or 0.0)
|
|
|
|
|
|
def get_remote_setup_secret_ttl_minutes() -> int:
|
|
raw_value = os.getenv(SETUP_SECRET_TTL_MINUTES_ENV, str(SETUP_SECRET_TTL_MINUTES_DEFAULT)).strip()
|
|
try:
|
|
ttl_minutes = int(raw_value)
|
|
except ValueError:
|
|
ttl_minutes = SETUP_SECRET_TTL_MINUTES_DEFAULT
|
|
return max(SETUP_SECRET_TTL_MINUTES_MIN, min(SETUP_SECRET_TTL_MINUTES_MAX, ttl_minutes))
|
|
|
|
|
|
def is_remote_setup_secret_expired(app) -> bool:
|
|
expires_at = get_remote_setup_secret_expires_at(app)
|
|
return expires_at > 0 and time.time() >= expires_at
|
|
|
|
|
|
def ensure_remote_setup_secret(app) -> str:
|
|
secret = get_remote_setup_secret(app)
|
|
had_secret = bool(secret)
|
|
expired = had_secret and is_remote_setup_secret_expired(app)
|
|
if not secret or expired:
|
|
secret = "".join(secrets.choice(SETUP_SECRET_ALPHABET) for _ in range(SETUP_SECRET_LENGTH))
|
|
app.state.remote_setup_secret = secret
|
|
app.state.remote_setup_secret_expires_at = time.time() + get_remote_setup_secret_ttl_minutes() * 60
|
|
if expired:
|
|
logger.info(
|
|
f"Remote setup secret expired and was refreshed for another {get_remote_setup_secret_ttl_minutes()} minute(s): /setup/{secret}"
|
|
)
|
|
return secret
|
|
|
|
|
|
def clear_remote_setup_secret(app) -> None:
|
|
app.state.remote_setup_secret = ""
|
|
app.state.remote_setup_secret_expires_at = 0.0
|
|
|
|
|
|
def has_valid_remote_setup_secret(app, candidate: str | None) -> bool:
|
|
if is_remote_setup_secret_expired(app):
|
|
ensure_remote_setup_secret(app)
|
|
return False
|
|
secret = get_remote_setup_secret(app)
|
|
provided = (candidate or "").strip()
|
|
return bool(secret and provided and secrets.compare_digest(secret, provided))
|
|
|
|
|
|
def request_has_valid_remote_setup_secret(request: Request) -> bool:
|
|
return has_valid_remote_setup_secret(
|
|
request.app,
|
|
request.headers.get(SETUP_SECRET_HEADER_NAME, ""),
|
|
)
|
|
|
|
|
|
def _is_loopback_host(host: str | None) -> bool:
|
|
if not host:
|
|
return False
|
|
normalized = host.strip().lower()
|
|
return normalized in {"127.0.0.1", "::1", "localhost"}
|
|
|
|
|
|
def is_direct_local_client(client_ip: str | None, header_keys: Iterable[str]) -> bool:
|
|
"""Return True only for direct loopback requests without proxy headers."""
|
|
if not _is_loopback_host(client_ip):
|
|
return False
|
|
|
|
normalized_headers = {key.lower() for key in header_keys}
|
|
return not any(header in normalized_headers for header in _FORWARDED_HEADER_NAMES)
|
|
|
|
|
|
def _is_local_request(request: Request) -> bool:
|
|
client_ip = request.client.host if request.client and request.client.host else None
|
|
return is_direct_local_client(client_ip, request.headers.keys())
|
|
|
|
|
|
class RemoteAuthMiddleware(BaseHTTPMiddleware):
|
|
"""Protect non-local HTTP requests with cookie or bearer-token auth."""
|
|
|
|
def __init__(
|
|
self,
|
|
app,
|
|
get_password_hash_fn: Callable[[], Awaitable[str]],
|
|
) -> None:
|
|
super().__init__(app)
|
|
self._get_password_hash = get_password_hash_fn
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
path = request.url.path
|
|
|
|
if _is_local_request(request):
|
|
return await call_next(request)
|
|
|
|
if not any(path.startswith(prefix) for prefix in _PROTECTED_PATH_PREFIXES):
|
|
return await call_next(request)
|
|
|
|
if any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES):
|
|
if (
|
|
path in _LOCAL_ONLY_SETUP_PATHS
|
|
and not _is_local_request(request)
|
|
and not request_has_valid_remote_setup_secret(request)
|
|
):
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={"detail": "首次初始化只能在本机完成", "code": "SETUP_LOCAL_ONLY"},
|
|
)
|
|
return await call_next(request)
|
|
|
|
password_hash = await self._get_password_hash()
|
|
if not password_hash:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "Authentication setup required", "code": "AUTH_SETUP_REQUIRED"},
|
|
)
|
|
|
|
token = request.cookies.get(AUTH_COOKIE_NAME)
|
|
if not token:
|
|
auth_header = request.headers.get("authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
token = auth_header[7:]
|
|
|
|
if not token or not validate_session(token):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "Authentication required", "code": "AUTH_REQUIRED"},
|
|
)
|
|
|
|
return await call_next(request)
|