fix: harden public api token storage

This commit is contained in:
Christian Krakau-Louis
2026-05-23 17:47:13 +02:00
parent ee663afffb
commit e2b95f183d
6 changed files with 23 additions and 11 deletions
@@ -24,7 +24,7 @@ def upgrade() -> None:
"api_tokens", "api_tokens",
sa.Column("id", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), 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("key_prefix", sa.String(length=16), nullable=False),
sa.Column("scopes", sa.Text(), nullable=False), sa.Column("scopes", sa.Text(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False), sa.Column("active", sa.Boolean(), nullable=False),
+1 -2
View File
@@ -240,8 +240,7 @@ def require_api_token_scope(required_scope: str) -> Callable:
token = find_api_token(db, api_key) token = find_api_token(db, api_key)
if token is None: if token is None:
suffix = api_key[-8:] if len(api_key) >= 8 else "invalid" logger.warning("Invalid public API token attempt")
logger.warning("Invalid public API token attempt: ...%s", suffix)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API token", detail="Invalid API token",
+1 -1
View File
@@ -12,7 +12,7 @@ class APIToken(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String(120), nullable=False) 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) key_prefix = Column(String(16), nullable=False, index=True)
scopes = Column(Text, nullable=False) scopes = Column(Text, nullable=False)
active = Column(Boolean, default=True, nullable=False, index=True) active = Column(Boolean, default=True, nullable=False, index=True)
+17 -5
View File
@@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Iterable, List, Optional, Set from typing import Iterable, List, Optional, Set
import bcrypt
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.api_token import APIToken from app.models.api_token import APIToken
@@ -59,7 +59,15 @@ def generate_public_api_key() -> str:
def hash_api_key(secret: str) -> str: def hash_api_key(secret: str) -> str:
"""Hash an API token for database storage.""" """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: 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.""" """Return the active token row matching *secret*, if any."""
if not secret: if not secret:
return None return None
return ( candidates = (
db.query(APIToken) db.query(APIToken)
.filter(APIToken.key_hash == hash_api_key(secret), APIToken.active == True) # noqa: E712 .filter(APIToken.key_prefix == secret[:12], APIToken.active == True) # noqa: E712
.first() .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: def record_api_token_use(db: Session, token: APIToken, *, ip_address: Optional[str]) -> None:
+2 -1
View File
@@ -5,6 +5,7 @@ pydantic>=2.0.0
pydantic-settings>=2.0.0 pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6
logto>=0.2.0 logto>=0.2.0
aiohttp>=3.8.0 aiohttp>=3.8.0
@@ -28,4 +29,4 @@ aiosmtplib>=2.0.2
jinja2>=3.1.2 jinja2>=3.1.2
google-auth>=2.0.0 google-auth>=2.0.0
google-api-python-client>=2.0.0 google-api-python-client>=2.0.0
google-auth-httplib2>=0.2.0 google-auth-httplib2>=0.2.0
+1 -1
View File
@@ -124,7 +124,7 @@ and are never stored.
|--------|------|-------------| |--------|------|-------------|
| id | INTEGER | Primary key | | id | INTEGER | Primary key |
| name | VARCHAR(120) | Name/description of the token | | 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 | | key_prefix | VARCHAR(16) | Non-secret prefix for operator identification |
| scopes | TEXT | Comma-separated scopes such as `reports:read` | | scopes | TEXT | Comma-separated scopes such as `reports:read` |
| active | BOOLEAN | Whether the token can be used | | active | BOOLEAN | Whether the token can be used |