fix: harden public api token storage
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user