feat(api): add personal API tokens and enhance webhook integration UI
- Add ApiToken model with SHA-256 hashed storage and usage tracking - Create API token CRUD endpoints (POST/GET/DELETE /api/api-tokens/) - Add Bearer token authentication to require_login decorator - Exempt Bearer-authenticated requests from CSRF validation - Add API tokens management page with create/revoke/copy UI - Enhance webhook integration type with detailed explanation and code snippets - Add navigation links (desktop + mobile) to API tokens page - Include 19 tests covering CRUD, auth resolution, and utility functions - Create migration 024_add_api_tokens Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+87
-23
@@ -2,6 +2,7 @@ import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import pathlib
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -52,9 +53,61 @@ router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
# Check for Bearer token auth first (API tokens)
|
||||
api_user = getattr(request.state, "api_token_user", None)
|
||||
if api_user:
|
||||
return api_user
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
"""Resolve a user from a Bearer API token in the Authorization header.
|
||||
|
||||
If the header is present and the token is valid, updates usage tracking
|
||||
(last_used_at, last_used_ip) and returns a synthetic user dict compatible
|
||||
with the session user format.
|
||||
|
||||
Returns:
|
||||
A user dict or ``None`` if no valid Bearer token is present.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
raw_token = auth_header[7:]
|
||||
if not raw_token:
|
||||
return None
|
||||
|
||||
from app.models import ApiToken
|
||||
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first()
|
||||
if db_token is None:
|
||||
return None
|
||||
|
||||
# Update usage tracking
|
||||
try:
|
||||
db_token.last_used_at = datetime.now(timezone.utc)
|
||||
# Extract client IP (respect X-Forwarded-For from reverse proxy)
|
||||
client_ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if not client_ip and request.client:
|
||||
client_ip = request.client.host
|
||||
db_token.last_used_ip = client_ip or None
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.debug("Failed to update API token usage tracking for token_id=%s", db_token.id)
|
||||
|
||||
# Build a synthetic user dict that mimics the session user format
|
||||
return {
|
||||
"id": db_token.owner_id,
|
||||
"email": db_token.owner_id,
|
||||
"preferred_username": db_token.owner_id,
|
||||
"is_admin": False,
|
||||
"_api_token_id": db_token.id,
|
||||
}
|
||||
|
||||
|
||||
def get_current_user_id(request: Request) -> str:
|
||||
"""Return a stable string identifier for the authenticated user.
|
||||
|
||||
@@ -82,28 +135,39 @@ def require_login(func):
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
if not request.session.get("user"):
|
||||
# For API endpoints return 401 instead of storing the URL in the session
|
||||
# and redirecting to /login. Without this guard, the /api/auth/whoami
|
||||
# probe issued by common.js on every page load would overwrite
|
||||
# redirect_after_login with the API URL, causing the post-login redirect
|
||||
# to land on a JSON endpoint rather than the original page.
|
||||
url_path = urlparse(str(request.url)).path
|
||||
if url_path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={"error": "Not authenticated"},
|
||||
)
|
||||
request.session["redirect_after_login"] = str(request.url)
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||||
# Pass request as a keyword argument so that endpoints whose first
|
||||
# parameter is a path variable (e.g. pipeline_id) are not accidentally
|
||||
# bound to the request object when FastAPI supplies all arguments as
|
||||
# keyword arguments.
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
# Check session auth first
|
||||
if request.session.get("user"):
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
|
||||
# Fall back to Bearer token auth for API endpoints
|
||||
url_path = urlparse(str(request.url)).path
|
||||
if url_path.startswith("/api/"):
|
||||
from app.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
api_user = _resolve_bearer_user(request, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if api_user:
|
||||
request.state.api_token_user = api_user
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={"error": "Not authenticated"},
|
||||
)
|
||||
|
||||
# Non-API endpoint with no session — redirect to login
|
||||
request.session["redirect_after_login"] = str(request.url)
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -443,7 +507,7 @@ if AUTH_ENABLED:
|
||||
@require_login
|
||||
async def whoami(request: Request):
|
||||
"""API endpoint to get current user information"""
|
||||
user = request.session.get("user")
|
||||
user = get_current_user(request)
|
||||
return user or {"error": "Not authenticated"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user