c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
322 lines
10 KiB
Python
322 lines
10 KiB
Python
"""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, timedelta, 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"
|
||
|
||
#: Name prefix used for tokens created by the mobile app flow.
|
||
MOBILE_TOKEN_PREFIX = "Mobile App"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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()
|
||
|
||
|
||
def _token_to_dict(t: ApiToken) -> dict[str, Any]:
|
||
"""Convert an ``ApiToken`` ORM instance to a serialisable dict."""
|
||
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,
|
||
"expires_at": t.expires_at,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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")
|
||
expires_in_days: int | None = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=3650, # Maximum 10 years; keeps tokens from being effectively permanent while allowing long-lived CI/CD tokens.
|
||
description="Optional lifetime in days. If omitted the token never expires.",
|
||
)
|
||
|
||
|
||
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
|
||
expires_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
|
||
|
||
expires_at = None
|
||
if body.expires_in_days is not None:
|
||
expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)
|
||
|
||
db_token = ApiToken(
|
||
owner_id=owner_id,
|
||
name=body.name,
|
||
token_hash=token_hash_value,
|
||
token_prefix=prefix,
|
||
expires_at=expires_at,
|
||
)
|
||
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,
|
||
"expires_at": db_token.expires_at,
|
||
"token": plaintext,
|
||
}
|
||
|
||
|
||
@router.get("/", response_model=list[TokenResponse])
|
||
async def list_tokens(
|
||
owner_id: CurrentOwner,
|
||
db: DbSession,
|
||
) -> list[dict[str, Any]]:
|
||
"""List non-mobile API tokens for the authenticated user.
|
||
|
||
Mobile tokens (whose names start with ``"Mobile App"``) are excluded
|
||
from this list; they are managed on the dedicated Devices page via
|
||
``GET /api/api-tokens/mobile``.
|
||
"""
|
||
tokens = (
|
||
db.query(ApiToken)
|
||
.filter(
|
||
ApiToken.owner_id == owner_id,
|
||
~ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
|
||
)
|
||
.order_by(ApiToken.created_at.desc())
|
||
.all()
|
||
)
|
||
return [_token_to_dict(t) for t in tokens]
|
||
|
||
|
||
@router.get("/mobile", response_model=list[TokenResponse])
|
||
async def list_mobile_tokens(
|
||
owner_id: CurrentOwner,
|
||
db: DbSession,
|
||
) -> list[dict[str, Any]]:
|
||
"""List mobile API tokens for the authenticated user.
|
||
|
||
Returns tokens whose names start with ``"Mobile App"`` — these are
|
||
created via the mobile SSO flow or QR code login.
|
||
"""
|
||
tokens = (
|
||
db.query(ApiToken)
|
||
.filter(
|
||
ApiToken.owner_id == owner_id,
|
||
ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
|
||
)
|
||
.order_by(ApiToken.created_at.desc())
|
||
.all()
|
||
)
|
||
return [_token_to_dict(t) for t in tokens]
|
||
|
||
|
||
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
|
||
async def revoke_or_delete_token(
|
||
token_id: int,
|
||
owner_id: CurrentOwner,
|
||
db: DbSession,
|
||
) -> dict[str, str]:
|
||
"""Revoke or permanently delete an API token.
|
||
|
||
* **Active token** – soft-revoked: the row is kept for audit purposes
|
||
but marked inactive with a ``revoked_at`` timestamp.
|
||
* **Already-revoked token** – hard-deleted: the row is permanently
|
||
removed from the database.
|
||
"""
|
||
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 db_token.is_active:
|
||
# Soft-revoke the active token.
|
||
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"}
|
||
|
||
# Hard-delete an already-revoked token.
|
||
try:
|
||
db.delete(db_token)
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
logger.info("API token permanently deleted: id=%s owner=%s", token_id, owner_id)
|
||
return {"detail": "Token deleted"}
|
||
|
||
|
||
@router.post("/{token_id}/reactivate", status_code=status.HTTP_200_OK, response_model=TokenResponse)
|
||
async def reactivate_token(
|
||
token_id: int,
|
||
owner_id: CurrentOwner,
|
||
db: DbSession,
|
||
) -> dict[str, Any]:
|
||
"""Reactivate a previously revoked API token.
|
||
|
||
Clears the ``revoked_at`` timestamp and sets ``is_active`` back to
|
||
``True``. The token can be used for authentication again immediately.
|
||
If the token had an ``expires_at`` in the past the caller should
|
||
consider re-creating a new token instead.
|
||
"""
|
||
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 db_token.is_active:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already active")
|
||
|
||
try:
|
||
db_token.is_active = True
|
||
db_token.revoked_at = None
|
||
db.commit()
|
||
db.refresh(db_token)
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
|
||
logger.info("API token reactivated: id=%s owner=%s", token_id, owner_id)
|
||
return _token_to_dict(db_token)
|