Merge pull request #560 from christianlouis/copilot/add-webhook-snippet-and-api-tokens
fix(security): resolve CodeQL clear-text logging and weak hashing alerts
This commit is contained in:
@@ -7,6 +7,7 @@ import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.admin_users import router as admin_users_router
|
||||
from app.api.api_tokens import router as api_tokens_router
|
||||
from app.api.azure import router as azure_router
|
||||
from app.api.backup import router as backup_router
|
||||
from app.api.billing import router as billing_router
|
||||
@@ -45,6 +46,7 @@ router = APIRouter()
|
||||
|
||||
# Include all the routers
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(api_tokens_router)
|
||||
router.include_router(user_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(files_router)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""API endpoints for managing personal API tokens.
|
||||
|
||||
Provides CRUD operations so users can create, list, and revoke tokens
|
||||
that grant programmatic access to the DocuElevate API (e.g. webhook
|
||||
uploads, scripted integrations).
|
||||
|
||||
Tokens use ``secrets.token_urlsafe`` from the Python standard library
|
||||
(no extra dependencies) and are prefixed with ``de_`` for easy
|
||||
identification. Only a PBKDF2-HMAC-SHA256 hash is persisted; the
|
||||
plaintext is returned exactly once at creation time.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ApiToken
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api-tokens", tags=["api-tokens"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Prefix prepended to every generated token for easy identification.
|
||||
TOKEN_PREFIX = "de_"
|
||||
#: Number of random bytes for the token body (32 → 43 URL-safe chars).
|
||||
TOKEN_BYTES = 32
|
||||
#: PBKDF2 iteration count for hashing API tokens.
|
||||
TOKEN_HASH_ITERATIONS = 100_000
|
||||
#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism).
|
||||
TOKEN_HASH_SALT = b"api-token-v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_owner_id(request: Request) -> str:
|
||||
"""Return the current user's owner ID, raising 401 if unauthenticated."""
|
||||
owner_id = get_current_owner_id(request)
|
||||
if not owner_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return owner_id
|
||||
|
||||
|
||||
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_api_token() -> str:
|
||||
"""Generate a new API token with the ``de_`` prefix.
|
||||
|
||||
Returns:
|
||||
A URL-safe random token string, e.g. ``de_Ab3xY…``.
|
||||
"""
|
||||
return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_BYTES)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Return a PBKDF2-HMAC-SHA256 hex digest of *token*.
|
||||
|
||||
Args:
|
||||
token: The plaintext API token.
|
||||
|
||||
Returns:
|
||||
64-character lowercase hex string.
|
||||
"""
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
token.encode("utf-8"),
|
||||
TOKEN_HASH_SALT,
|
||||
TOKEN_HASH_ITERATIONS,
|
||||
)
|
||||
return dk.hex()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
"""Schema for creating a new API token."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token")
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema returned when listing tokens (plaintext is never included)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
token_prefix: str
|
||||
is_active: bool
|
||||
last_used_at: datetime | None
|
||||
last_used_ip: str | None
|
||||
created_at: datetime | None
|
||||
revoked_at: datetime | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TokenCreatedResponse(TokenResponse):
|
||||
"""Schema returned once at creation time — includes the full plaintext token."""
|
||||
|
||||
token: str = Field(..., description="The full API token. Store it securely — it will not be shown again.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=TokenCreatedResponse)
|
||||
async def create_token(
|
||||
body: TokenCreate,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new personal API token.
|
||||
|
||||
The full token is returned **only once** in the response. Subsequent
|
||||
``GET`` requests will only show the prefix for identification.
|
||||
"""
|
||||
plaintext = generate_api_token()
|
||||
token_hash_value = hash_token(plaintext)
|
||||
prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=owner_id,
|
||||
name=body.name,
|
||||
token_hash=token_hash_value,
|
||||
token_prefix=prefix,
|
||||
)
|
||||
try:
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("API token created: id=%s owner=%s name=%r", db_token.id, owner_id, body.name)
|
||||
|
||||
return {
|
||||
"id": db_token.id,
|
||||
"name": db_token.name,
|
||||
"token_prefix": db_token.token_prefix,
|
||||
"is_active": db_token.is_active,
|
||||
"last_used_at": db_token.last_used_at,
|
||||
"last_used_ip": db_token.last_used_ip,
|
||||
"created_at": db_token.created_at,
|
||||
"revoked_at": db_token.revoked_at,
|
||||
"token": plaintext,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TokenResponse])
|
||||
async def list_tokens(
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all API tokens for the authenticated user."""
|
||||
tokens = db.query(ApiToken).filter(ApiToken.owner_id == owner_id).order_by(ApiToken.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"is_active": t.is_active,
|
||||
"last_used_at": t.last_used_at,
|
||||
"last_used_ip": t.last_used_ip,
|
||||
"created_at": t.created_at,
|
||||
"revoked_at": t.revoked_at,
|
||||
}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
|
||||
async def revoke_token(
|
||||
token_id: int,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, str]:
|
||||
"""Revoke (soft-delete) an API token.
|
||||
|
||||
The token row is kept for audit purposes but marked inactive with a
|
||||
``revoked_at`` timestamp.
|
||||
"""
|
||||
db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first()
|
||||
if not db_token:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
|
||||
|
||||
if not db_token.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already revoked")
|
||||
|
||||
try:
|
||||
db_token.is_active = False
|
||||
db_token.revoked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("API token revoked: id=%s owner=%s", token_id, owner_id)
|
||||
return {"detail": "Token revoked"}
|
||||
@@ -291,15 +291,15 @@ def create_pipeline(request: Request, db: DbSession, body: PipelineCreate) -> di
|
||||
db.add(pipeline)
|
||||
db.commit()
|
||||
db.refresh(pipeline)
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(f"Failed to create pipeline user={user_id}: {exc}")
|
||||
logger.exception("Failed to create pipeline user=%s", user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create pipeline",
|
||||
)
|
||||
|
||||
logger.info(f"Pipeline created: id={pipeline.id}, owner={user_id}, name={name!r}")
|
||||
logger.info("Pipeline created: id=%s, owner=%s, name=%r", pipeline.id, user_id, name)
|
||||
return _serialize_pipeline(pipeline)
|
||||
|
||||
|
||||
@@ -387,15 +387,15 @@ def update_pipeline(pipeline_id: int, request: Request, db: DbSession, body: Pip
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(pipeline)
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(f"Failed to update pipeline id={pipeline_id}: {exc}")
|
||||
logger.exception("Failed to update pipeline id=%s", pipeline_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update pipeline",
|
||||
)
|
||||
|
||||
logger.info(f"Pipeline updated: id={pipeline_id}, user={user_id}")
|
||||
logger.info("Pipeline updated: id=%s, user=%s", pipeline_id, user_id)
|
||||
return _serialize_pipeline(pipeline, include_steps=True, db=db)
|
||||
|
||||
|
||||
@@ -425,15 +425,15 @@ def delete_pipeline(pipeline_id: int, request: Request, db: DbSession) -> None:
|
||||
db.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline_id).delete()
|
||||
db.delete(pipeline)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(f"Failed to delete pipeline id={pipeline_id}: {exc}")
|
||||
logger.exception("Failed to delete pipeline id=%s", pipeline_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete pipeline",
|
||||
)
|
||||
|
||||
logger.info(f"Pipeline deleted: id={pipeline_id}, user={user_id}")
|
||||
logger.info("Pipeline deleted: id=%s, user=%s", pipeline_id, user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -190,15 +190,15 @@ def create_saved_search(
|
||||
db.add(saved_search)
|
||||
db.commit()
|
||||
db.refresh(saved_search)
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(f"Failed to create saved search for user={user_id}: {exc}")
|
||||
logger.exception("Failed to create saved search for user=%s", user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to save search",
|
||||
)
|
||||
|
||||
logger.info(f"Saved search created: user={user_id}, name={name!r}")
|
||||
logger.info("Saved search created: user=%s, name=%r", user_id, name)
|
||||
return _serialize_saved_search(saved_search)
|
||||
|
||||
|
||||
@@ -263,15 +263,15 @@ def update_saved_search(
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(saved_search)
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(f"Failed to update saved search id={search_id}, user={user_id}: {exc}")
|
||||
logger.exception("Failed to update saved search id=%s, user=%s", search_id, user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update saved search",
|
||||
)
|
||||
|
||||
logger.info(f"Saved search updated: id={search_id}, user={user_id}")
|
||||
logger.info("Saved search updated: id=%s, user=%s", search_id, user_id)
|
||||
return _serialize_saved_search(saved_search)
|
||||
|
||||
|
||||
@@ -294,12 +294,12 @@ def delete_saved_search(search_id: int, request: Request, db: DbSession):
|
||||
try:
|
||||
db.delete(saved_search)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(f"Failed to delete saved search id={search_id}, user={user_id}: {exc}")
|
||||
logger.exception("Failed to delete saved search id=%s, user=%s", search_id, user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete saved search",
|
||||
)
|
||||
|
||||
logger.info(f"Saved search deleted: id={search_id}, user={user_id}")
|
||||
logger.info("Saved search deleted: id=%s, user=%s", search_id, user_id)
|
||||
|
||||
+91
-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,62 @@ 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 isinstance(api_user, dict):
|
||||
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 isinstance(auth_header, str) or not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
raw_token = auth_header[7:]
|
||||
if not raw_token or not isinstance(raw_token, str):
|
||||
return None
|
||||
|
||||
from app.api.api_tokens import hash_token
|
||||
from app.models import ApiToken
|
||||
|
||||
token_hash = hash_token(raw_token)
|
||||
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 +136,42 @@ 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/"):
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
api_user = _resolve_bearer_user(request, db)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
api_user = None
|
||||
|
||||
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 +511,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"}
|
||||
|
||||
|
||||
|
||||
@@ -109,6 +109,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Validate for state-changing methods on non-exempt paths.
|
||||
if request.method in CSRF_PROTECTED_METHODS and request.url.path not in CSRF_EXEMPT_PATHS:
|
||||
# Bearer-authenticated requests (API tokens) are exempt from CSRF
|
||||
# because the token itself acts as proof of intent — it cannot be
|
||||
# injected by a cross-site request from a browser.
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
return await call_next(request)
|
||||
|
||||
submitted_token = await self._get_submitted_token(request)
|
||||
if not submitted_token or not secrets.compare_digest(csrf_token, submitted_token):
|
||||
logger.warning(f"[SECURITY] CSRF_VALIDATION_FAILED method={request.method} path={request.url.path}")
|
||||
|
||||
@@ -600,3 +600,40 @@ class UserIntegration(Base):
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
"""Personal API token for programmatic access.
|
||||
|
||||
Users can create multiple tokens, each with a human-readable name.
|
||||
Only the SHA-256 hash of the token is stored; the plaintext is shown
|
||||
exactly once at creation time. A short prefix (first 8 chars) is
|
||||
persisted for easy identification in the UI.
|
||||
|
||||
Usage tracking records the timestamp and IP address of the most
|
||||
recent request that used the token.
|
||||
"""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Stable owner identifier — matches FileRecord.owner_id / UserIntegration.owner_id
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# Human-readable label chosen by the user (e.g. "CI Pipeline", "Webhook Upload")
|
||||
name = Column(String(255), nullable=False)
|
||||
|
||||
# SHA-256 hex digest of the full token value
|
||||
token_hash = Column(String(64), nullable=False, unique=True, index=True)
|
||||
|
||||
# First 12 characters of the token for display (e.g. "de_Ab3xY7kL…")
|
||||
token_prefix = Column(String(16), nullable=False)
|
||||
|
||||
# Usage tracking
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_used_ip = Column(String(45), nullable=True) # IPv6 max length
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ Aggregated view routers for the application.
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.views.admin_users import router as admin_users_router
|
||||
from app.views.api_tokens import router as api_tokens_router
|
||||
from app.views.backup import router as backup_router
|
||||
from app.views.db_wizard import router as db_wizard_router
|
||||
from app.views.dropbox import router as dropbox_router
|
||||
@@ -33,6 +34,7 @@ router = APIRouter()
|
||||
router.include_router(wizard_router) # Wizard first (for /setup)
|
||||
router.include_router(db_wizard_router) # Database wizard
|
||||
router.include_router(admin_users_router) # Admin user management
|
||||
router.include_router(api_tokens_router) # API token management
|
||||
router.include_router(backup_router) # Backup dashboard
|
||||
router.include_router(general_router)
|
||||
router.include_router(status_router)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""View route for the API Tokens management page.
|
||||
|
||||
Renders the ``api_tokens.html`` template where users can create, view,
|
||||
and revoke their personal API tokens for programmatic access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.views.base import require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api-tokens")
|
||||
@require_login
|
||||
async def api_tokens_page(request: Request):
|
||||
"""Render the API Tokens management page."""
|
||||
return templates.TemplateResponse(
|
||||
"api_tokens.html",
|
||||
{"request": request, "page_title": "API Tokens"},
|
||||
)
|
||||
+132
-1
@@ -86,7 +86,38 @@ def make_api_request(url, max_retries=3):
|
||||
|
||||
## Authentication
|
||||
|
||||
When authentication is enabled, you must include an authentication token in your requests:
|
||||
When authentication is enabled, you must include an authentication token in your requests.
|
||||
|
||||
### API Tokens (Recommended)
|
||||
|
||||
DocuElevate supports personal API tokens for programmatic access. Tokens are the recommended
|
||||
way to authenticate scripts, CI/CD pipelines, and webhook integrations.
|
||||
|
||||
**Creating a token:**
|
||||
|
||||
1. Log in to DocuElevate and navigate to **API Tokens** (available in your user menu or at `/api-tokens`).
|
||||
2. Enter a descriptive name (e.g. "CI Pipeline", "Scanner Integration") and click **Create Token**.
|
||||
3. Copy the token immediately — it is shown only once.
|
||||
|
||||
**Using a token:**
|
||||
|
||||
```bash
|
||||
curl -X GET "http://<your-docuelevate-instance>/api/files" \
|
||||
-H "Authorization: Bearer <your-api-token>"
|
||||
```
|
||||
|
||||
**Managing tokens programmatically:**
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/api-tokens/` | Create a new token |
|
||||
| `GET` | `/api/api-tokens/` | List all your tokens |
|
||||
| `DELETE` | `/api/api-tokens/{id}` | Revoke a token |
|
||||
|
||||
### Session Authentication
|
||||
|
||||
Browser-based users authenticate via OAuth or local login. Session cookies are set
|
||||
automatically and used for subsequent requests:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://<your-docuelevate-instance>/api/files" \
|
||||
@@ -1900,6 +1931,106 @@ Pass no `pipeline_id` query parameter (or omit it) to clear the assignment.
|
||||
```
|
||||
|
||||
|
||||
## API Tokens
|
||||
|
||||
Personal API tokens allow programmatic access to the DocuElevate API without
|
||||
session cookies. Tokens are ideal for CI/CD pipelines, webhook integrations,
|
||||
and automation scripts.
|
||||
|
||||
Each token is prefixed with `de_` for easy identification. Only a SHA-256 hash
|
||||
is stored server-side; the plaintext is returned exactly once at creation time.
|
||||
|
||||
Usage tracking records when each token was last used and from which IP address.
|
||||
|
||||
### POST /api/api-tokens/
|
||||
|
||||
Create a new API token.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "CI Pipeline"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "CI Pipeline",
|
||||
"token_prefix": "de_Ab3xY7kL",
|
||||
"token": "de_Ab3xY7kLmN9pQrStUvWxYz0123456789abcdef",
|
||||
"is_active": true,
|
||||
"last_used_at": null,
|
||||
"last_used_ip": null,
|
||||
"created_at": "2026-03-08T12:00:00Z",
|
||||
"revoked_at": null
|
||||
}
|
||||
```
|
||||
|
||||
> **Important:** The `token` field is only included in the creation response.
|
||||
> Copy it immediately — it will not be shown again.
|
||||
|
||||
### GET /api/api-tokens/
|
||||
|
||||
List all tokens for the authenticated user. The full token value is never included.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "CI Pipeline",
|
||||
"token_prefix": "de_Ab3xY7kL",
|
||||
"is_active": true,
|
||||
"last_used_at": "2026-03-08T15:30:00Z",
|
||||
"last_used_ip": "203.0.113.42",
|
||||
"created_at": "2026-03-08T12:00:00Z",
|
||||
"revoked_at": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### DELETE /api/api-tokens/{token_id}
|
||||
|
||||
Revoke a token. The token is soft-deleted (kept for audit purposes) and can no
|
||||
longer be used for authentication.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"detail": "Token revoked"
|
||||
}
|
||||
```
|
||||
|
||||
### Using API Tokens
|
||||
|
||||
Include the token in the `Authorization` header of any API request:
|
||||
|
||||
```bash
|
||||
# Upload a document
|
||||
curl -X POST "http://your-instance/api/files/ui-upload" \
|
||||
-H "Authorization: Bearer de_your_token_here" \
|
||||
-F "file=@/path/to/document.pdf"
|
||||
|
||||
# List files
|
||||
curl -X GET "http://your-instance/api/files" \
|
||||
-H "Authorization: Bearer de_your_token_here"
|
||||
```
|
||||
|
||||
**Python example:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
"http://your-instance/api/files/ui-upload",
|
||||
headers={"Authorization": "Bearer de_your_token_here"},
|
||||
files={"file": open("document.pdf", "rb")},
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
|
||||
## Further Assistance
|
||||
|
||||
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
|
||||
|
||||
@@ -165,6 +165,7 @@ The **Integrations** page (`/integrations`) provides a unified view of all your
|
||||
- **Email Forward** — recipient email address
|
||||
- **Watch Folder** — folder path
|
||||
- **Paperless NGX** — URL and API token
|
||||
- **Webhook** — no configuration needed; the form shows a quick-start guide with sample `curl` and Python snippets for uploading documents via the API
|
||||
5. Click **Test Connection** to verify the settings before saving.
|
||||
6. Click **Save** to persist the integration.
|
||||
|
||||
@@ -193,6 +194,52 @@ DocuElevate can poll an FTP or SFTP directory for new files. Enable this with `F
|
||||
|
||||
See [Configuration Guide — Watch Folder Ingestion](ConfigurationGuide.md#watch-folder-ingestion) for full setup instructions.
|
||||
|
||||
### Webhook Ingestion (API Upload)
|
||||
|
||||
Webhook integrations allow external systems to push documents directly into DocuElevate
|
||||
via the REST API. Instead of DocuElevate polling for new files, **your application sends
|
||||
files to DocuElevate** using an HTTP request with an API token for authentication.
|
||||
|
||||
This is ideal for:
|
||||
- **CI/CD pipelines** — upload build artifacts or generated reports
|
||||
- **Scanner integrations** — push scanned documents from network scanners
|
||||
- **Automation scripts** — send documents from any system with HTTP support
|
||||
|
||||
To set up webhook ingestion:
|
||||
|
||||
1. Go to **Integrations** and add a new **Source → Webhook** integration.
|
||||
The integration form shows copy-ready code snippets.
|
||||
2. Go to **API Tokens** (in your user menu) and create a personal API token.
|
||||
3. Use the token to upload documents:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-instance/api/files/ui-upload" \
|
||||
-H "Authorization: Bearer de_your_token_here" \
|
||||
-F "file=@/path/to/document.pdf"
|
||||
```
|
||||
|
||||
See the [API Documentation](API.md#api-tokens) for detailed API token management.
|
||||
|
||||
### API Tokens
|
||||
|
||||
API tokens provide secure, programmatic access to the DocuElevate API. Each user
|
||||
can create multiple tokens, each with a descriptive name.
|
||||
|
||||
**Features:**
|
||||
- **Multiple tokens** — create separate tokens for different scripts or integrations
|
||||
- **Usage tracking** — see when each token was last used and from which IP address
|
||||
- **Revocation** — instantly disable a compromised token without affecting others
|
||||
|
||||
**Managing tokens:**
|
||||
|
||||
1. Click your avatar in the top navigation and select **API Tokens**, or go to `/api-tokens`.
|
||||
2. Enter a descriptive name and click **Create Token**.
|
||||
3. Copy the token immediately — it is shown only once.
|
||||
4. To revoke a token, click **Revoke** next to it in the token list.
|
||||
|
||||
> **Security tip:** Create a dedicated token for each integration and revoke it
|
||||
> immediately if compromised. Never share tokens or commit them to source control.
|
||||
|
||||
## Managing Documents
|
||||
|
||||
The **Files** page provides access to all processed documents:
|
||||
|
||||
@@ -209,6 +209,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
|
||||
);
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
|
||||
);
|
||||
|
||||
// Divider + Sign Out
|
||||
const divider = document.createElement('div');
|
||||
@@ -278,6 +281,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
subLink.appendChild(document.createTextNode('My Subscription'));
|
||||
mobileAuthSection.appendChild(subLink);
|
||||
|
||||
// API Tokens link
|
||||
const tokensLink = document.createElement('a');
|
||||
tokensLink.href = '/api-tokens';
|
||||
tokensLink.className =
|
||||
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
|
||||
const tokensIcon = document.createElement('i');
|
||||
tokensIcon.className = 'fas fa-key mr-2 text-yellow-500';
|
||||
tokensIcon.setAttribute('aria-hidden', 'true');
|
||||
tokensLink.appendChild(tokensIcon);
|
||||
tokensLink.appendChild(document.createTextNode('API Tokens'));
|
||||
mobileAuthSection.appendChild(tokensLink);
|
||||
|
||||
// Logout link
|
||||
const logoutLink = document.createElement('a');
|
||||
logoutLink.href = '/logout';
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API Tokens – DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="apiTokens()" x-init="loadTokens()" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-key text-yellow-500" aria-hidden="true"></i>
|
||||
API Tokens
|
||||
</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
|
||||
Create personal API tokens to interact with the DocuElevate API programmatically.
|
||||
Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload
|
||||
or retrieve documents.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Create token section -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-token-heading">
|
||||
<h2 id="create-token-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Create New Token</h2>
|
||||
<form @submit.prevent="createToken()" class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<label for="token-name" class="sr-only">Token name</label>
|
||||
<input
|
||||
id="token-name"
|
||||
type="text"
|
||||
x-model="newTokenName"
|
||||
placeholder="e.g. CI Pipeline, Webhook Upload, My Script"
|
||||
required
|
||||
minlength="1"
|
||||
maxlength="255"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating || !newTokenName.trim()"
|
||||
class="inline-flex items-center px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md
|
||||
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50
|
||||
transition-colors"
|
||||
style="min-height:40px; min-width:44px;"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i>
|
||||
<span x-text="creating ? 'Creating…' : 'Create Token'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Newly created token display -->
|
||||
<template x-if="newlyCreatedToken">
|
||||
<div class="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg p-4" role="alert">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">Token created successfully!</p>
|
||||
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
|
||||
Copy this token now — it will <strong>not be shown again</strong>.
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<code
|
||||
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded px-3 py-2
|
||||
text-sm font-mono text-gray-900 dark:text-gray-100 select-all break-all"
|
||||
x-text="newlyCreatedToken"
|
||||
></code>
|
||||
<button
|
||||
type="button"
|
||||
@click="copyToken()"
|
||||
class="inline-flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300
|
||||
dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200
|
||||
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||
transition-colors"
|
||||
style="min-height:40px; min-width:44px;"
|
||||
:aria-label="copied ? 'Copied!' : 'Copy token to clipboard'"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
|
||||
<span class="ml-1 hidden sm:inline" x-text="copied ? 'Copied!' : 'Copy'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Usage example section -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="usage-heading">
|
||||
<h2 id="usage-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-code text-blue-500 mr-2" aria-hidden="true"></i>
|
||||
Usage Example
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">
|
||||
Use your API token in the <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">Authorization</code>
|
||||
header with any API request:
|
||||
</p>
|
||||
<div class="relative">
|
||||
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-sm overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="baseUrl"></span>/api/files/ui-upload" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-F "file=@/path/to/document.pdf"</code></pre>
|
||||
<button
|
||||
type="button"
|
||||
@click="copySnippet('upload')"
|
||||
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors"
|
||||
style="min-height:30px; min-width:30px;"
|
||||
aria-label="Copy upload example to clipboard"
|
||||
>
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tokens list -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden" aria-labelledby="tokens-heading">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 id="tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">Your Tokens</h2>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template x-if="loading">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">Loading tokens…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!loading && tokens.length === 0">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-key text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||
<p class="font-medium">No API tokens yet</p>
|
||||
<p class="text-sm mt-1">Create your first token above to get started.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tokens table -->
|
||||
<template x-if="!loading && tokens.length > 0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm" aria-label="API Tokens">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Name</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Token Prefix</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Created</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Last Used</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Last IP</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="token in tokens" :key="token.id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="token.name"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="token.last_used_at ? formatDate(token.last_used_at) : 'Never'"></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
|
||||
<span x-show="!token.last_used_ip" class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="token.is_active ? 'Active' : 'Revoked'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
x-show="token.is_active"
|
||||
type="button"
|
||||
@click="revokeToken(token)"
|
||||
:disabled="revoking === token.id"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'Revoke token ' + token.name"
|
||||
>
|
||||
<i :class="revoking === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error display -->
|
||||
<template x-if="error">
|
||||
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function apiTokens() {
|
||||
const csrfToken = '{{ csrf_token | default("") }}';
|
||||
return {
|
||||
tokens: [],
|
||||
loading: true,
|
||||
creating: false,
|
||||
revoking: null,
|
||||
error: null,
|
||||
newTokenName: '',
|
||||
newlyCreatedToken: null,
|
||||
copied: false,
|
||||
baseUrl: window.location.origin,
|
||||
|
||||
async loadTokens() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await fetch('/api/api-tokens/', {
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load tokens');
|
||||
this.tokens = await res.json();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async createToken() {
|
||||
if (!this.newTokenName.trim()) return;
|
||||
this.creating = true;
|
||||
this.error = null;
|
||||
this.newlyCreatedToken = null;
|
||||
try {
|
||||
const res = await fetch('/api/api-tokens/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ name: this.newTokenName.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to create token');
|
||||
}
|
||||
const data = await res.json();
|
||||
this.newlyCreatedToken = data.token;
|
||||
this.newTokenName = '';
|
||||
await this.loadTokens();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async revokeToken(token) {
|
||||
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
|
||||
this.revoking = token.id;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to revoke token');
|
||||
}
|
||||
await this.loadTokens();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.revoking = null;
|
||||
}
|
||||
},
|
||||
|
||||
copyToken() {
|
||||
if (this.newlyCreatedToken) {
|
||||
navigator.clipboard.writeText(this.newlyCreatedToken);
|
||||
this.copied = true;
|
||||
setTimeout(() => { this.copied = false; }, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
copySnippet(type) {
|
||||
const snippets = {
|
||||
upload: `curl -X POST "${this.baseUrl}/api/files/ui-upload" \\\n -H "Authorization: Bearer YOUR_API_TOKEN" \\\n -F "file=@/path/to/document.pdf"`,
|
||||
};
|
||||
navigator.clipboard.writeText(snippets[type] || '');
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -628,8 +628,97 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Generic fallback for types without dedicated fields -->
|
||||
<template x-if="form.integration_type && !hasFormFields(form.integration_type)">
|
||||
<!-- Webhook explanation and sample snippets -->
|
||||
<template x-if="form.integration_type === 'WEBHOOK'">
|
||||
<div class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-purple-500 uppercase tracking-wider">
|
||||
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> Webhook Ingestion
|
||||
</p>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
How Webhook Ingestion Works
|
||||
</h4>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||
A webhook integration allows external systems to push documents directly into DocuElevate
|
||||
via the REST API. Instead of DocuElevate polling for new files (like IMAP), <strong>your
|
||||
application sends files to DocuElevate</strong> using an HTTP request with an API token for
|
||||
authentication.
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
|
||||
This is ideal for <strong>CI/CD pipelines</strong>, <strong>automation scripts</strong>,
|
||||
<strong>scanner integrations</strong>, or any system that generates documents and needs to
|
||||
send them for processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
|
||||
<i class="fas fa-terminal mr-1" aria-hidden="true"></i>
|
||||
Quick Start
|
||||
</h4>
|
||||
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
|
||||
<li>
|
||||
Go to <a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">API Tokens</a>
|
||||
and create a personal token.
|
||||
</li>
|
||||
<li>Use the token to upload documents via the API:</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-3 relative">
|
||||
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="window.location.origin"></span>/api/files/ui-upload" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-F "file=@/path/to/document.pdf"</code></pre>
|
||||
<button
|
||||
type="button"
|
||||
@click="navigator.clipboard.writeText('curl -X POST \'' + window.location.origin + '/api/files/ui-upload\' \\\n -H \'Authorization: Bearer YOUR_API_TOKEN\' \\\n -F \'file=@/path/to/document.pdf\'')"
|
||||
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:28px; min-width:28px;"
|
||||
aria-label="Copy curl example to clipboard"
|
||||
>
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mt-4 mb-2">
|
||||
<i class="fab fa-python mr-1" aria-hidden="true"></i>
|
||||
Python Example
|
||||
</h4>
|
||||
<div class="relative">
|
||||
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed"><code>import requests
|
||||
|
||||
response = requests.post(
|
||||
"<span x-text="window.location.origin"></span>/api/files/ui-upload",
|
||||
headers={"Authorization": "Bearer YOUR_API_TOKEN"},
|
||||
files={"file": open("document.pdf", "rb")},
|
||||
)
|
||||
print(response.json())</code></pre>
|
||||
<button
|
||||
type="button"
|
||||
@click="navigator.clipboard.writeText('import requests\n\nresponse = requests.post(\n \'' + window.location.origin + '/api/files/ui-upload\',\n headers={\'Authorization\': \'Bearer YOUR_API_TOKEN\'},\n files={\'file\': open(\'document.pdf\', \'rb\')},\n)\nprint(response.json())')"
|
||||
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:28px; min-width:28px;"
|
||||
aria-label="Copy Python example to clipboard"
|
||||
>
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i>
|
||||
<strong>Security tip:</strong> Create a dedicated API token for each integration and
|
||||
revoke it immediately if compromised. Tokens can be managed on the
|
||||
<a href="/api-tokens" class="underline font-medium">API Tokens</a> page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Generic fallback for types without dedicated fields (excludes WEBHOOK) -->
|
||||
<template x-if="form.integration_type && !hasFormFields(form.integration_type) && form.integration_type !== 'WEBHOOK'">
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add api_tokens table for personal API token authentication
|
||||
|
||||
Revision ID: 024_add_api_tokens
|
||||
Revises: 023_add_user_integrations
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "024_add_api_tokens"
|
||||
down_revision: Union[str, None] = "023_add_user_integrations"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create api_tokens table."""
|
||||
op.create_table(
|
||||
"api_tokens",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("token_prefix", sa.String(16), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_ip", sa.String(45), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("ix_api_tokens_id", "api_tokens", ["id"])
|
||||
op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"])
|
||||
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop api_tokens table."""
|
||||
op.drop_index("ix_api_tokens_token_hash", "api_tokens")
|
||||
op.drop_index("ix_api_tokens_owner_id", "api_tokens")
|
||||
op.drop_index("ix_api_tokens_id", "api_tokens")
|
||||
op.drop_table("api_tokens")
|
||||
@@ -60,6 +60,7 @@ from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
# Import models to register them with SQLAlchemy Base
|
||||
from app.models import ( # noqa: F401, E402
|
||||
ApiToken,
|
||||
DocumentMetadata,
|
||||
FileRecord,
|
||||
Pipeline,
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Tests for the personal API tokens feature (app/api/api_tokens.py + auth integration)."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import ApiToken
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OWNER = "tokenuser@example.com"
|
||||
_OTHER_OWNER = "other@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tok_engine():
|
||||
"""In-memory SQLite engine."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tok_session(tok_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=tok_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_client(tok_engine, owner_id: str = _OWNER) -> TestClient:
|
||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
||||
from app.api.api_tokens import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=tok_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _override_owner():
|
||||
return owner_id
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
app.dependency_overrides[_get_owner_id] = _override_owner
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
return client
|
||||
|
||||
|
||||
def _cleanup(app):
|
||||
"""Remove dependency overrides after test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Token CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenCreate:
|
||||
"""Tests for POST /api/api-tokens/."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_token_returns_full_token(self, tok_engine):
|
||||
"""Creating a token should return the full plaintext token exactly once."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/", json={"name": "Test Token"})
|
||||
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert data["token"].startswith("de_")
|
||||
assert data["name"] == "Test Token"
|
||||
assert data["is_active"] is True
|
||||
assert data["token_prefix"] == data["token"][:12]
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_token_stored_as_hash(self, tok_engine, tok_session):
|
||||
"""The database should only store a PBKDF2-HMAC-SHA256 hash, never the plaintext."""
|
||||
from app.api.api_tokens import hash_token
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/", json={"name": "Hash Check"})
|
||||
token_plaintext = resp.json()["token"]
|
||||
expected_hash = hash_token(token_plaintext)
|
||||
|
||||
db_token = tok_session.query(ApiToken).first()
|
||||
assert db_token is not None
|
||||
assert db_token.token_hash == expected_hash
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_token_empty_name_rejected(self, tok_engine):
|
||||
"""An empty token name should be rejected with 422."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/", json={"name": ""})
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
class TestTokenList:
|
||||
"""Tests for GET /api/api-tokens/."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_tokens_empty(self, tok_engine):
|
||||
"""Listing tokens when none exist should return an empty list."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.get("/api/api-tokens/")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_tokens_returns_multiple(self, tok_engine):
|
||||
"""Listing tokens should return all tokens for the current user."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
client.post("/api/api-tokens/", json={"name": "Token A"})
|
||||
client.post("/api/api-tokens/", json={"name": "Token B"})
|
||||
resp = client.get("/api/api-tokens/")
|
||||
assert resp.status_code == 200
|
||||
tokens = resp.json()
|
||||
assert len(tokens) == 2
|
||||
# Full plaintext should NOT appear in list
|
||||
for t in tokens:
|
||||
assert "token" not in t
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_tokens_isolation(self, tok_engine):
|
||||
"""Users should only see their own tokens."""
|
||||
from app.main import app
|
||||
|
||||
client_a = _make_client(tok_engine, _OWNER)
|
||||
try:
|
||||
client_a.post("/api/api-tokens/", json={"name": "Owner A Token"})
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
client_b = _make_client(tok_engine, _OTHER_OWNER)
|
||||
try:
|
||||
resp = client_b.get("/api/api-tokens/")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
class TestTokenRevoke:
|
||||
"""Tests for DELETE /api/api-tokens/{id}."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_token(self, tok_engine):
|
||||
"""Revoking a token should set is_active=False."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
create_resp = client.post("/api/api-tokens/", json={"name": "To Revoke"})
|
||||
token_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.delete(f"/api/api-tokens/{token_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
list_resp = client.get("/api/api-tokens/")
|
||||
revoked = [t for t in list_resp.json() if t["id"] == token_id][0]
|
||||
assert revoked["is_active"] is False
|
||||
assert revoked["revoked_at"] is not None
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_already_revoked_token(self, tok_engine):
|
||||
"""Revoking an already-revoked token should return 400."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
create_resp = client.post("/api/api-tokens/", json={"name": "Double Revoke"})
|
||||
token_id = create_resp.json()["id"]
|
||||
client.delete(f"/api/api-tokens/{token_id}")
|
||||
|
||||
resp = client.delete(f"/api/api-tokens/{token_id}")
|
||||
assert resp.status_code == 400
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_nonexistent_token(self, tok_engine):
|
||||
"""Revoking a token that doesn't exist should return 404."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.delete("/api/api-tokens/99999")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_other_users_token(self, tok_engine):
|
||||
"""A user should not be able to revoke another user's token."""
|
||||
from app.main import app
|
||||
|
||||
# Owner A creates a token
|
||||
client_a = _make_client(tok_engine, _OWNER)
|
||||
try:
|
||||
create_resp = client_a.post("/api/api-tokens/", json={"name": "A's Token"})
|
||||
token_id = create_resp.json()["id"]
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
# Owner B tries to revoke it
|
||||
client_b = _make_client(tok_engine, _OTHER_OWNER)
|
||||
try:
|
||||
resp = client_b.delete(f"/api/api-tokens/{token_id}")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Bearer token authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBearerAuth:
|
||||
"""Tests for API token authentication via Authorization: Bearer header."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bearer_resolve_user_with_valid_token(self, tok_engine, tok_session):
|
||||
"""_resolve_bearer_user should return a user dict for a valid token."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create a token directly in DB
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
plaintext = generate_api_token()
|
||||
token_hash = hash_token(plaintext)
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=_OWNER,
|
||||
name="Test Bearer",
|
||||
token_hash=token_hash,
|
||||
token_prefix=plaintext[:12],
|
||||
is_active=True,
|
||||
)
|
||||
tok_session.add(db_token)
|
||||
tok_session.commit()
|
||||
|
||||
# Build a mock request
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
user = _resolve_bearer_user(mock_request, tok_session)
|
||||
assert user is not None
|
||||
assert user["preferred_username"] == _OWNER
|
||||
assert user["_api_token_id"] == db_token.id
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bearer_resolve_user_invalid_token(self, tok_engine, tok_session):
|
||||
"""_resolve_bearer_user should return None for an invalid token."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "Bearer de_invalid_token"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
user = _resolve_bearer_user(mock_request, tok_session)
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bearer_resolve_no_header(self, tok_engine, tok_session):
|
||||
"""_resolve_bearer_user should return None when no Auth header present."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
user = _resolve_bearer_user(mock_request, tok_session)
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bearer_updates_usage_tracking(self, tok_engine, tok_session):
|
||||
"""Using a Bearer token should update last_used_at and last_used_ip."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
plaintext = generate_api_token()
|
||||
token_hash = hash_token(plaintext)
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=_OWNER,
|
||||
name="Usage Track",
|
||||
token_hash=token_hash,
|
||||
token_prefix=plaintext[:12],
|
||||
is_active=True,
|
||||
)
|
||||
tok_session.add(db_token)
|
||||
tok_session.commit()
|
||||
|
||||
assert db_token.last_used_at is None
|
||||
assert db_token.last_used_ip is None
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": f"Bearer {plaintext}",
|
||||
"x-forwarded-for": "203.0.113.42",
|
||||
}
|
||||
mock_request.client.host = "10.0.0.1"
|
||||
|
||||
_resolve_bearer_user(mock_request, tok_session)
|
||||
|
||||
tok_session.refresh(db_token)
|
||||
assert db_token.last_used_at is not None
|
||||
assert db_token.last_used_ip == "203.0.113.42"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoked_token_not_resolved(self, tok_engine, tok_session):
|
||||
"""A revoked token should not resolve to a user."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
plaintext = generate_api_token()
|
||||
token_hash = hash_token(plaintext)
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=_OWNER,
|
||||
name="Revoked Token",
|
||||
token_hash=token_hash,
|
||||
token_prefix=plaintext[:12],
|
||||
is_active=False, # Already revoked
|
||||
)
|
||||
tok_session.add(db_token)
|
||||
tok_session.commit()
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
user = _resolve_bearer_user(mock_request, tok_session)
|
||||
assert user is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Token generation utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenUtils:
|
||||
"""Tests for token generation and hashing utilities."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_api_token_format(self):
|
||||
"""Generated tokens should start with 'de_' prefix."""
|
||||
from app.api.api_tokens import generate_api_token
|
||||
|
||||
token = generate_api_token()
|
||||
assert token.startswith("de_")
|
||||
assert len(token) > 20 # Should be reasonably long
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_api_token_unique(self):
|
||||
"""Each generated token should be unique."""
|
||||
from app.api.api_tokens import generate_api_token
|
||||
|
||||
tokens = {generate_api_token() for _ in range(100)}
|
||||
assert len(tokens) == 100
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_hash_token_deterministic(self):
|
||||
"""Hashing the same token should always produce the same result."""
|
||||
from app.api.api_tokens import hash_token
|
||||
|
||||
token = "de_test_token_value"
|
||||
assert hash_token(token) == hash_token(token)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_hash_token_output_properties(self):
|
||||
"""Token hash should be a 64-character lowercase hex digest."""
|
||||
from app.api.api_tokens import hash_token
|
||||
|
||||
token = "de_test_token_value"
|
||||
h = hash_token(token)
|
||||
assert isinstance(h, str)
|
||||
assert len(h) == 64
|
||||
# All characters should be valid lowercase hex digits.
|
||||
int(h, 16)
|
||||
assert h == h.lower()
|
||||
Reference in New Issue
Block a user