fix bug api key managemtn for admin

This commit is contained in:
jigoong
2026-02-25 02:08:34 +07:00
parent 649473d2cc
commit c57755c09c
11 changed files with 126 additions and 46 deletions

View File

@@ -29,3 +29,6 @@ SUPABASE_API_KEY=your-supabase-anon-or-service-role-key
ADMIN_SECRET_KEY=your-secret-key-here
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-admin-password
# API Key Encryption (for storing encrypted keys in DB)
API_KEY_ENC_SECRET=your-encryption-secret-key-here

View File

@@ -14,4 +14,4 @@ ENV TZ=Asia/Bangkok
EXPOSE 8040
CMD ["gunicorn","-k","uvicorn.workers.UvicornWorker","app.main:app","--bind","0.0.0.0:8040","--workers","2","--access-logfile","-","--error-logfile","-"]
CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","8040","--log-level","debug"]

View File

@@ -11,7 +11,13 @@ from wtforms.validators import Optional
from app.core.config import settings
from app.db.engine import engine
from app.db.models import ApiClient, ApiKey
from app.security.api_key import generate_api_key, get_prefix, hash_api_key
from app.security.api_key import (
decrypt_api_key,
encrypt_api_key,
generate_api_key,
get_prefix,
hash_api_key,
)
class AdminAuth(AuthenticationBackend):
@@ -38,45 +44,31 @@ class ApiClientAdmin(ModelView, model=ApiClient):
class ApiKeyAdmin(ModelView, model=ApiKey):
form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at]
column_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.is_active, ApiKey.created_at]
column_details_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.permissions, ApiKey.is_active, ApiKey.created_at]
details_template = "apikey_details.html"
form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at, ApiKey.encrypted_key]
column_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.encrypted_key, ApiKey.is_active, ApiKey.created_at]
column_details_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.encrypted_key, ApiKey.permissions, ApiKey.is_active, ApiKey.created_at]
edit_template = "apikey_edit.html"
form_extra_fields = {
"permissions": TextAreaField(
"Permissions (JSON Array)",
validators=[Optional()],
description='Example: ["feed.waiting-time:write", "feed.opd-checkpoint:write"]',
render_kw={"placeholder": '["feed.waiting-time:write"]', "rows": 3}
),
column_formatters = {
ApiKey.encrypted_key: lambda m, a: (m.encrypted_key[:16] + "...") if m.encrypted_key else "-"
}
form_args = {
"permissions": {
"label": "Permissions (JSON Array)",
"description": 'Example: ["feed.waiting-time:write", "feed.opd-checkpoint:write"]'
}
}
async def on_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None:
import json
# Handle permissions from textarea (JSON format)
permissions_str = data.get("permissions")
if permissions_str:
try:
perms = json.loads(permissions_str)
if isinstance(perms, list):
model.permissions = perms
else:
raise ValueError("Permissions must be a JSON array")
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid permissions format: {str(e)}. Use JSON array like: [\"feed.waiting-time:write\"]"
)
# Auto-generate key for new records if not provided
if is_created and not model.key_hash:
plain_key = generate_api_key()
model.key_prefix = get_prefix(plain_key)
model.key_hash = hash_api_key(plain_key)
model.encrypted_key = encrypt_api_key(plain_key)
# Store in session for display after creation
request.session[f"new_api_key_{model.client_id}"] = plain_key
request.session[f"new_api_key_{model.id or 'new'}"] = plain_key
def mount_admin(app):
@@ -128,6 +120,7 @@ def mount_admin(app):
name=name,
key_prefix=get_prefix(plain_key),
key_hash=hash_api_key(plain_key),
encrypted_key=encrypt_api_key(plain_key),
permissions=perms,
is_active=True,
)
@@ -155,6 +148,7 @@ def mount_admin(app):
plain_key = generate_api_key()
api_key.key_prefix = get_prefix(plain_key)
api_key.key_hash = hash_api_key(plain_key)
api_key.encrypted_key = encrypt_api_key(plain_key)
db.commit()
db.refresh(api_key)
@@ -170,7 +164,7 @@ def mount_admin(app):
finally:
db.close()
@app.get("/admin/api-keys/{key_id}/view")
@app.post("/admin/api-keys/{key_id}/view")
async def _admin_view_api_key(request: Request, key_id: int):
"""View API key from session (only works for newly created/regenerated keys)."""
if not request.session.get("admin"):
@@ -192,10 +186,20 @@ def mount_admin(app):
return JSONResponse({
"success": True,
"api_key": plain_key,
"key_prefix": api_key.key_prefix,
"key_prefix": get_prefix(api_key.key_prefix),
"message": "This is the only time you can view this key!"
})
else:
# fallback to encrypted_key if exists
if api_key.encrypted_key:
decrypted = decrypt_api_key(api_key.encrypted_key)
if decrypted:
return JSONResponse({
"success": True,
"api_key": decrypted,
"key_prefix": get_prefix(decrypted),
"message": "Retrieved from encrypted storage."
})
return JSONResponse({
"success": False,
"message": "API key cannot be retrieved. Keys can only be viewed once after creation or regeneration."

View File

@@ -19,9 +19,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
PERM_FEED_WAITING_TIME_WRITE = "feed.waiting-time:write"
PERM_FEED_OPD_CHECKPOINT_WRITE = "/api/v1/feed/opd-checkpoint:write"
PERM_FEED_OPD_CHECKPOINT_WRITE_LEGACY = "feed.opd-checkpoint:write"
PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write"
PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write"
def _to_tz(dt):
@@ -42,7 +41,7 @@ def _to_iso(dt):
@router.post("/feed/checkpoint")
def upsert_feed_checkpoint(
payload: list[FeedWaitingTimeIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_WAITING_TIME_WRITE))],
_: Annotated[object, Depends(require_permission(PERM_FEED_CHECKPOINT_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = []
@@ -89,6 +88,7 @@ def upsert_feed_checkpoint(
@router.post("/feed/old-checkpoint")
def upsert_opd_checkpoint(
payload: list[FeedCheckpointIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_OLD_CHECKPOINT_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = []

View File

@@ -31,5 +31,7 @@ class Settings(BaseSettings):
ADMIN_USERNAME: str
ADMIN_PASSWORD: str
API_KEY_ENC_SECRET: str | None = None
settings = Settings()

View File

@@ -80,8 +80,9 @@ class ApiKey(Base):
)
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False)
key_prefix: Mapped[str] = mapped_column(Text, nullable=False)
key_hash: Mapped[str] = mapped_column(Text, nullable=False)
encrypted_key: Mapped[str | None] = mapped_column(Text, nullable=True)
permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View File

@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager
import logging
import os
from fastapi import FastAPI
@@ -14,6 +15,16 @@ from app.api.v1.routes import router as v1_router
from app.core.config import settings
from app.db.init_db import init_db
# Configure logging for better error visibility
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logging.getLogger("uvicorn.error").setLevel(logging.DEBUG)
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
logging.getLogger("sqladmin").setLevel(logging.DEBUG)
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
class ForceHTTPSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
@@ -57,6 +68,18 @@ sqladmin_dir = os.path.dirname(sqladmin.__file__)
statics_path = os.path.join(sqladmin_dir, "statics")
app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan)
# Add exception handler to log all errors with traceback
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
import traceback
logging.error(f"Unhandled exception: {exc}")
logging.error(traceback.format_exc())
from starlette.responses import JSONResponse
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "error": str(exc)}
)
app.add_middleware(ForceHTTPSMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.add_middleware(ForwardedProtoMiddleware)

View File

@@ -1,6 +1,10 @@
import secrets
from typing import Optional
import bcrypt
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
def generate_api_key(prefix_len: int = 8, token_bytes: int = 32) -> str:
@@ -20,3 +24,34 @@ def hash_api_key(api_key: str) -> str:
def verify_api_key(api_key: str, api_key_hash: str) -> bool:
return bcrypt.checkpw(api_key.encode("utf-8"), api_key_hash.encode("utf-8"))
def _get_fernet() -> Fernet:
if not settings.API_KEY_ENC_SECRET:
raise ValueError("API_KEY_ENC_SECRET is not configured")
# Expect a base64 urlsafe key; if plaintext is provided, derive a Fernet key
secret = settings.API_KEY_ENC_SECRET
# If length is 44 and endswith '=', assume already a Fernet key
if len(secret) == 44 and secret.endswith("="):
key = secret.encode("utf-8")
else:
# Derive deterministic Fernet key from secret (simple approach)
import base64
import hashlib
digest = hashlib.sha256(secret.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_api_key(plain_key: str) -> str:
f = _get_fernet()
return f.encrypt(plain_key.encode("utf-8")).decode("utf-8")
def decrypt_api_key(cipher_text: str) -> Optional[str]:
try:
f = _get_fernet()
return f.decrypt(cipher_text.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
return None

View File

@@ -1,17 +1,21 @@
{% extends "sqladmin/details.html" %}
{% extends "sqladmin/edit.html" %}
{% block content %}
{{ super() }}
{# key_id ใช้ model.id ถ้ามี ไม่งั้น fallback จาก path params #}
{% set key_id = model.id if (model is defined and model) else request.path_params.get('pk') %}
<div class="card mt-3">
<div class="card-header">
<h5>API Key Actions</h5>
</div>
<div class="card-body">
<button type="button" class="btn btn-warning me-2" onclick="regenerateApiKey({{ model.id }})">
<button type="button" class="btn btn-warning me-2" onclick="regenerateApiKey({{ key_id|default('null') }})">
<i class="fa fa-refresh"></i> Regenerate API Key
</button>
<button type="button" class="btn btn-info" onclick="viewApiKey({{ model.id }})">
<button type="button" class="btn btn-info" onclick="viewApiKey({{ key_id|default('null') }})">
<i class="fa fa-eye"></i> View API Key (if just created)
</button>
@@ -38,7 +42,10 @@ async function regenerateApiKey(keyId) {
}
try {
const response = await fetch(`/apiservice/admin/api-keys/${keyId}/regenerate`, {
const rootPath = '{{ request.scope.get("root_path", "") }}';
const url = `${rootPath}/admin/api-keys/${keyId}/regenerate`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -63,8 +70,11 @@ async function regenerateApiKey(keyId) {
async function viewApiKey(keyId) {
try {
const response = await fetch(`/apiservice/admin/api-keys/${keyId}/view`, {
method: 'GET',
const rootPath = '{{ request.scope.get("root_path", "") }}';
const url = `${rootPath}/admin/api-keys/${keyId}/view`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}

View File

@@ -3,7 +3,7 @@ services:
build: .
container_name: apiservice
env_file:
- ../.env.global
- .env
environment:
- TZ=${TZ:-Asia/Bangkok}
- DB_HOST=${DB_HOST}
@@ -17,6 +17,7 @@ services:
- ADMIN_SECRET_KEY=${ADMIN_SECRET_KEY}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- LOG_LEVEL=debug
ports:
- "8040:8040"
networks:

View File

@@ -13,4 +13,5 @@ python-multipart==0.0.20
httpx==0.28.1
WTForms
#==3.2.1
cryptography==42.0.5