Spaces:
Sleeping
Sleeping
| """Implement password login and signed-session lifecycle routes. | |
| Login attempts are rate-limited in memory by the direct client address. | |
| """ | |
| import time | |
| from collections import defaultdict, deque | |
| from threading import Lock | |
| from fastapi import APIRouter, Depends, Request, status | |
| from app.deps import require_login | |
| from app.models import ( | |
| ApiEnvelope, | |
| AuthenticatedData, | |
| ChangePasswordRequest, | |
| LoginRequest, | |
| MeData, | |
| StoredConfig, | |
| err, | |
| ok, | |
| ) | |
| router = APIRouter(prefix="/api/auth", tags=["auth"]) | |
| _attempts: dict[str, deque[float]] = defaultdict(deque) | |
| _attempts_lock = Lock() | |
| def _client_key(request: Request) -> str: | |
| return request.client.host if request.client else "unknown" | |
| def _rate_limited(request: Request) -> bool: | |
| settings = request.app.state.settings | |
| now = time.monotonic() | |
| cutoff = now - settings.login_rate_window_sec | |
| key = _client_key(request) | |
| with _attempts_lock: | |
| attempts = _attempts[key] | |
| while attempts and attempts[0] <= cutoff: | |
| attempts.popleft() | |
| if len(attempts) >= settings.login_rate_limit: | |
| return True | |
| attempts.append(now) | |
| return False | |
| def _clear_attempts(request: Request) -> None: | |
| with _attempts_lock: | |
| _attempts.pop(_client_key(request), None) | |
| def _set_session(request: Request, config: StoredConfig) -> None: | |
| max_age = request.app.state.settings.session_max_age_sec | |
| request.session.clear() | |
| request.session.update( | |
| { | |
| "auth": True, | |
| "sv": config.session_version, | |
| "exp": int(time.time()) + max_age, | |
| } | |
| ) | |
| def _session_is_current(request: Request, config: StoredConfig | None) -> bool: | |
| if config is None: | |
| return False | |
| session = request.session | |
| expiry = session.get("exp") | |
| return ( | |
| session.get("auth") is True | |
| and session.get("sv") == config.session_version | |
| and isinstance(expiry, (int, float)) | |
| and expiry >= time.time() | |
| ) | |
| def login(request: Request, body: LoginRequest) -> object: | |
| """Authenticate and establish a signed cookie session.""" | |
| config = request.app.state.config_store.load() | |
| if config is None: | |
| return err( | |
| "setup_required", | |
| "Application setup is required", | |
| status.HTTP_503_SERVICE_UNAVAILABLE, | |
| ) | |
| if _rate_limited(request): | |
| return err( | |
| "rate_limited", | |
| "Too many login attempts", | |
| status.HTTP_429_TOO_MANY_REQUESTS, | |
| ) | |
| if not request.app.state.config_store.password_matches(body.password): | |
| return err( | |
| "unauthorized", | |
| "Invalid credentials", | |
| status.HTTP_401_UNAUTHORIZED, | |
| ) | |
| _clear_attempts(request) | |
| _set_session(request, config) | |
| return ok(AuthenticatedData(authenticated=True)) | |
| def logout( | |
| request: Request, | |
| _config: StoredConfig = Depends(require_login), | |
| ) -> dict[str, object]: | |
| """Clear the current signed session.""" | |
| request.session.clear() | |
| return ok(AuthenticatedData(authenticated=False)) | |
| def me(request: Request) -> dict[str, object]: | |
| """Return public app identity and current session state.""" | |
| config = request.app.state.config_store.load() | |
| authenticated = _session_is_current(request, config) | |
| if not authenticated: | |
| request.session.clear() | |
| return ok( | |
| MeData( | |
| authenticated=authenticated, | |
| app_name=request.app.state.settings.app_name, | |
| ) | |
| ) | |
| def change_password( | |
| request: Request, | |
| body: ChangePasswordRequest, | |
| _config: StoredConfig = Depends(require_login), | |
| ) -> object: | |
| """Rotate the shared password and invalidate older sessions.""" | |
| changed = request.app.state.config_store.change_password( | |
| body.old_password, | |
| body.new_password, | |
| ) | |
| if not changed: | |
| return err( | |
| "unauthorized", | |
| "Invalid credentials", | |
| status.HTTP_401_UNAUTHORIZED, | |
| ) | |
| config = request.app.state.config_store.load() | |
| if config is None: | |
| return err( | |
| "internal", | |
| "Configuration unavailable", | |
| status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| ) | |
| _set_session(request, config) | |
| return ok(AuthenticatedData(authenticated=True)) | |