230 lines
7.2 KiB
Python
230 lines
7.2 KiB
Python
"""Auth capture API — remote browser for manual login + credential extraction."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.services.auth_capture_service import extract_all
|
|
from app.services.browser_import_service import (
|
|
ImportSessionError,
|
|
browser_imports,
|
|
build_import_result,
|
|
)
|
|
from app.services.browser_session_service import (
|
|
BrowserDependencyError,
|
|
BrowserSessionError,
|
|
browser_sessions,
|
|
)
|
|
from app.utils.auth import get_current_user, get_user_from_token_param
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
|
|
|
|
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"})
|
|
|
|
|
|
class CaptureSessionCreate(BaseModel):
|
|
url: str = Field(..., description="Target login page URL to open in browser")
|
|
width: int = Field(default=1280, ge=320, le=2560)
|
|
height: int = Field(default=720, ge=240, le=1600)
|
|
|
|
|
|
class CaptureSessionResponse(BaseModel):
|
|
session_id: str
|
|
ws_url: str
|
|
|
|
|
|
class CaptureExtractResponse(BaseModel):
|
|
cookies: list[dict] = []
|
|
storage: dict[str, str] = {}
|
|
session_storage: dict[str, str] = {}
|
|
auth_headers: list[dict] = []
|
|
candidates: list[dict] = []
|
|
|
|
|
|
class ImportSessionCreate(BaseModel):
|
|
target_url: str = Field(..., description="Target page URL opened in the user's real browser")
|
|
|
|
|
|
class ImportSessionCreateResponse(BaseModel):
|
|
session_id: str
|
|
secret: str
|
|
expires_in_seconds: int
|
|
|
|
|
|
class ImportSessionStatusResponse(BaseModel):
|
|
session_id: str
|
|
ready: bool = False
|
|
expires_at: float
|
|
result: Optional[CaptureExtractResponse] = None
|
|
|
|
|
|
class ImportSessionSubmit(BaseModel):
|
|
secret: str
|
|
page_url: str = ""
|
|
cookies: list[dict] = []
|
|
local_storage: dict[str, Any] = {}
|
|
session_storage: dict[str, Any] = {}
|
|
auth_headers: list[dict] = []
|
|
|
|
|
|
def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
key: value
|
|
for key, value in candidate.items()
|
|
if key not in SENSITIVE_CANDIDATE_FIELDS
|
|
}
|
|
|
|
|
|
def _browser_error(exc: Exception) -> HTTPException:
|
|
if isinstance(exc, BrowserDependencyError):
|
|
return HTTPException(503, str(exc))
|
|
if isinstance(exc, BrowserSessionError):
|
|
return HTTPException(409, str(exc))
|
|
if isinstance(exc, KeyError):
|
|
return HTTPException(404, "session not found")
|
|
if isinstance(exc, ValueError):
|
|
return HTTPException(400, str(exc))
|
|
logger.exception("auth-capture error")
|
|
return HTTPException(500, "internal error")
|
|
|
|
|
|
def _ws_url(session_id: str, token: str) -> str:
|
|
"""Build WebSocket URL for the remote browser viewer."""
|
|
return f"/api/browser-sessions/{session_id}/ws?token={token}"
|
|
|
|
|
|
@router.post("/sessions", response_model=CaptureSessionResponse, status_code=201)
|
|
async def create_capture_session(
|
|
body: CaptureSessionCreate,
|
|
_=Depends(get_current_user),
|
|
):
|
|
"""Create a temporary browser session pointing at the given URL.
|
|
|
|
Returns a session_id and ws_url for the frontend to view/interact.
|
|
The user should manually log in, then call GET /extract.
|
|
"""
|
|
try:
|
|
session = await browser_sessions.create_ephemeral(
|
|
url=body.url,
|
|
width=body.width,
|
|
height=body.height,
|
|
)
|
|
except Exception as exc:
|
|
raise _browser_error(exc)
|
|
|
|
# Build a short-lived token for WS auth (reuse current user's token logic)
|
|
# The frontend already has the user's Bearer token, pass it via query param
|
|
return CaptureSessionResponse(
|
|
session_id=session.id,
|
|
ws_url=f"/api/browser-sessions/{session.id}/ws",
|
|
)
|
|
|
|
|
|
@router.get("/sessions/{session_id}/extract", response_model=CaptureExtractResponse)
|
|
async def extract_credentials(
|
|
session_id: str,
|
|
include_raw: bool = Query(default=False, description="Include full cookies/storage/headers in response"),
|
|
_=Depends(get_current_user),
|
|
):
|
|
"""Extract auth credentials from the browser session.
|
|
|
|
By default only returns curated candidates (typed, scored, with masked preview).
|
|
Pass include_raw=true to also get full cookies, localStorage, and headers.
|
|
"""
|
|
try:
|
|
session = browser_sessions.get_session(session_id)
|
|
except KeyError:
|
|
raise HTTPException(404, "session not found")
|
|
|
|
try:
|
|
result = await extract_all(session)
|
|
except Exception as exc:
|
|
raise _browser_error(exc)
|
|
|
|
if not include_raw:
|
|
# Strip raw data — only keep curated candidates with masked previews
|
|
candidates = [_sanitize_candidate(candidate) for candidate in result.get("candidates", [])]
|
|
return CaptureExtractResponse(candidates=candidates)
|
|
return CaptureExtractResponse(**result)
|
|
|
|
|
|
@router.delete("/sessions/{session_id}", status_code=204)
|
|
async def close_capture_session(
|
|
session_id: str,
|
|
_=Depends(get_current_user),
|
|
):
|
|
"""Close and release the auth-capture browser session."""
|
|
try:
|
|
await browser_sessions.close(session_id)
|
|
except Exception as exc:
|
|
raise _browser_error(exc)
|
|
|
|
|
|
@router.post("/import-sessions", response_model=ImportSessionCreateResponse, status_code=201)
|
|
async def create_import_session(
|
|
body: ImportSessionCreate,
|
|
user=Depends(get_current_user),
|
|
):
|
|
"""Create a one-time real-browser import session.
|
|
|
|
The returned secret is intended for the local browser extension only.
|
|
"""
|
|
target_url = body.target_url.strip()
|
|
if not target_url.startswith(("http://", "https://")):
|
|
raise HTTPException(400, "Only http/https URLs are allowed")
|
|
session, secret = browser_imports.create(target_url=target_url, created_by=user.email)
|
|
return ImportSessionCreateResponse(
|
|
session_id=session.id,
|
|
secret=secret,
|
|
expires_in_seconds=600,
|
|
)
|
|
|
|
|
|
@router.get("/import-sessions/{session_id}", response_model=ImportSessionStatusResponse)
|
|
async def get_import_session(
|
|
session_id: str,
|
|
include_raw: bool = Query(default=False),
|
|
user=Depends(get_current_user),
|
|
):
|
|
try:
|
|
session = browser_imports.get(session_id, created_by=user.email)
|
|
except ImportSessionError as exc:
|
|
raise HTTPException(404, str(exc))
|
|
|
|
result = None
|
|
if session.payload is not None:
|
|
full_result = build_import_result(session.payload)
|
|
if not include_raw:
|
|
candidates = [_sanitize_candidate(candidate) for candidate in full_result.get("candidates", [])]
|
|
result = CaptureExtractResponse(candidates=candidates)
|
|
else:
|
|
result = CaptureExtractResponse(**full_result)
|
|
return ImportSessionStatusResponse(
|
|
session_id=session.id,
|
|
ready=session.payload is not None,
|
|
expires_at=session.expires_at,
|
|
result=result,
|
|
)
|
|
|
|
|
|
@router.post("/import-sessions/{session_id}/submit", status_code=204)
|
|
async def submit_import_session(
|
|
session_id: str,
|
|
body: ImportSessionSubmit,
|
|
):
|
|
try:
|
|
browser_imports.submit(
|
|
session_id=session_id,
|
|
secret=body.secret,
|
|
payload=body.model_dump(exclude={"secret"}),
|
|
)
|
|
except ImportSessionError as exc:
|
|
raise HTTPException(400, str(exc))
|