From e2b95f183d61a9117ad75b94a69c00f1562c0380 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 17:47:13 +0200 Subject: [PATCH] fix: harden public api token storage --- .../0a1b2c3d4e5f_add_scoped_api_tokens.py | 2 +- backend/app/core/security.py | 3 +-- backend/app/models/api_token.py | 2 +- backend/app/services/api_tokens.py | 22 ++++++++++++++----- backend/requirements.txt | 3 ++- docs/reference/database.md | 2 +- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py b/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py index 716ea96..c80f2d4 100644 --- a/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py +++ b/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py @@ -24,7 +24,7 @@ def upgrade() -> None: "api_tokens", sa.Column("id", sa.Integer(), nullable=False), sa.Column("name", sa.String(length=120), nullable=False), - sa.Column("key_hash", sa.String(length=64), nullable=False), + sa.Column("key_hash", sa.String(length=255), nullable=False), sa.Column("key_prefix", sa.String(length=16), nullable=False), sa.Column("scopes", sa.Text(), nullable=False), sa.Column("active", sa.Boolean(), nullable=False), diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 6ac558f..b961b32 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -240,8 +240,7 @@ def require_api_token_scope(required_scope: str) -> Callable: token = find_api_token(db, api_key) if token is None: - suffix = api_key[-8:] if len(api_key) >= 8 else "invalid" - logger.warning("Invalid public API token attempt: ...%s", suffix) + logger.warning("Invalid public API token attempt") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token", diff --git a/backend/app/models/api_token.py b/backend/app/models/api_token.py index ce8bdec..388b014 100644 --- a/backend/app/models/api_token.py +++ b/backend/app/models/api_token.py @@ -12,7 +12,7 @@ class APIToken(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String(120), nullable=False) - key_hash = Column(String(64), unique=True, nullable=False, index=True) + key_hash = Column(String(255), unique=True, nullable=False, index=True) key_prefix = Column(String(16), nullable=False, index=True) scopes = Column(Text, nullable=False) active = Column(Boolean, default=True, nullable=False, index=True) diff --git a/backend/app/services/api_tokens.py b/backend/app/services/api_tokens.py index 9ad3ca4..4a92387 100644 --- a/backend/app/services/api_tokens.py +++ b/backend/app/services/api_tokens.py @@ -2,12 +2,12 @@ from __future__ import annotations -import hashlib import secrets from dataclasses import dataclass from datetime import datetime from typing import Iterable, List, Optional, Set +import bcrypt from sqlalchemy.orm import Session from app.models.api_token import APIToken @@ -59,7 +59,15 @@ def generate_public_api_key() -> str: def hash_api_key(secret: str) -> str: """Hash an API token for database storage.""" - return hashlib.sha256(secret.encode("utf-8")).hexdigest() + return bcrypt.hashpw(secret.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_api_key_secret(secret: str, hashed_secret: str) -> bool: + """Return True when a raw API token matches the stored hash.""" + try: + return bcrypt.checkpw(secret.encode("utf-8"), hashed_secret.encode("utf-8")) + except ValueError: + return False def create_api_token(db: Session, *, name: str, scopes: Iterable[str]) -> CreatedAPIToken: @@ -85,11 +93,15 @@ def find_api_token(db: Session, secret: str) -> Optional[APIToken]: """Return the active token row matching *secret*, if any.""" if not secret: return None - return ( + candidates = ( db.query(APIToken) - .filter(APIToken.key_hash == hash_api_key(secret), APIToken.active == True) # noqa: E712 - .first() + .filter(APIToken.key_prefix == secret[:12], APIToken.active == True) # noqa: E712 + .all() ) + for token in candidates: + if verify_api_key_secret(secret, token.key_hash): + return token + return None def record_api_token_use(db: Session, token: APIToken, *, ip_address: Optional[str]) -> None: diff --git a/backend/requirements.txt b/backend/requirements.txt index 8e82af1..703232c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ pydantic>=2.0.0 pydantic-settings>=2.0.0 python-jose[cryptography]>=3.3.0 passlib[bcrypt]>=1.7.4 +bcrypt>=4.0.0 python-multipart>=0.0.6 logto>=0.2.0 aiohttp>=3.8.0 @@ -28,4 +29,4 @@ aiosmtplib>=2.0.2 jinja2>=3.1.2 google-auth>=2.0.0 google-api-python-client>=2.0.0 -google-auth-httplib2>=0.2.0 \ No newline at end of file +google-auth-httplib2>=0.2.0 diff --git a/docs/reference/database.md b/docs/reference/database.md index de09330..5afe60c 100644 --- a/docs/reference/database.md +++ b/docs/reference/database.md @@ -124,7 +124,7 @@ and are never stored. |--------|------|-------------| | id | INTEGER | Primary key | | name | VARCHAR(120) | Name/description of the token | -| key_hash | VARCHAR(64) | SHA-256 hash of the token secret | +| key_hash | VARCHAR(255) | Bcrypt hash of the token secret | | key_prefix | VARCHAR(16) | Non-secret prefix for operator identification | | scopes | TEXT | Comma-separated scopes such as `reports:read` | | active | BOOLEAN | Whether the token can be used |