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_SECRET_KEY=your-secret-key-here
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-admin-password 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 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.core.config import settings
from app.db.engine import engine from app.db.engine import engine
from app.db.models import ApiClient, ApiKey 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): class AdminAuth(AuthenticationBackend):
@@ -38,45 +44,31 @@ class ApiClientAdmin(ModelView, model=ApiClient):
class ApiKeyAdmin(ModelView, model=ApiKey): class ApiKeyAdmin(ModelView, model=ApiKey):
form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at] 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.is_active, ApiKey.created_at] 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.permissions, 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]
details_template = "apikey_details.html" edit_template = "apikey_edit.html"
form_extra_fields = { column_formatters = {
"permissions": TextAreaField( ApiKey.encrypted_key: lambda m, a: (m.encrypted_key[:16] + "...") if m.encrypted_key else "-"
"Permissions (JSON Array)", }
validators=[Optional()],
description='Example: ["feed.waiting-time:write", "feed.opd-checkpoint:write"]', form_args = {
render_kw={"placeholder": '["feed.waiting-time:write"]', "rows": 3} "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: 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 # Auto-generate key for new records if not provided
if is_created and not model.key_hash: if is_created and not model.key_hash:
plain_key = generate_api_key() plain_key = generate_api_key()
model.key_prefix = get_prefix(plain_key) model.key_prefix = get_prefix(plain_key)
model.key_hash = hash_api_key(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 # 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): def mount_admin(app):
@@ -128,6 +120,7 @@ def mount_admin(app):
name=name, name=name,
key_prefix=get_prefix(plain_key), key_prefix=get_prefix(plain_key),
key_hash=hash_api_key(plain_key), key_hash=hash_api_key(plain_key),
encrypted_key=encrypt_api_key(plain_key),
permissions=perms, permissions=perms,
is_active=True, is_active=True,
) )
@@ -155,6 +148,7 @@ def mount_admin(app):
plain_key = generate_api_key() plain_key = generate_api_key()
api_key.key_prefix = get_prefix(plain_key) api_key.key_prefix = get_prefix(plain_key)
api_key.key_hash = hash_api_key(plain_key) api_key.key_hash = hash_api_key(plain_key)
api_key.encrypted_key = encrypt_api_key(plain_key)
db.commit() db.commit()
db.refresh(api_key) db.refresh(api_key)
@@ -170,7 +164,7 @@ def mount_admin(app):
finally: finally:
db.close() 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): async def _admin_view_api_key(request: Request, key_id: int):
"""View API key from session (only works for newly created/regenerated keys).""" """View API key from session (only works for newly created/regenerated keys)."""
if not request.session.get("admin"): if not request.session.get("admin"):
@@ -192,10 +186,20 @@ def mount_admin(app):
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"api_key": plain_key, "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!" "message": "This is the only time you can view this key!"
}) })
else: 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({ return JSONResponse({
"success": False, "success": False,
"message": "API key cannot be retrieved. Keys can only be viewed once after creation or regeneration." "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") router = APIRouter(prefix="/api/v1")
PERM_FEED_WAITING_TIME_WRITE = "feed.waiting-time:write" PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write"
PERM_FEED_OPD_CHECKPOINT_WRITE = "/api/v1/feed/opd-checkpoint:write" PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write"
PERM_FEED_OPD_CHECKPOINT_WRITE_LEGACY = "feed.opd-checkpoint:write"
def _to_tz(dt): def _to_tz(dt):
@@ -42,7 +41,7 @@ def _to_iso(dt):
@router.post("/feed/checkpoint") @router.post("/feed/checkpoint")
def upsert_feed_checkpoint( def upsert_feed_checkpoint(
payload: list[FeedWaitingTimeIn], 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)], db: Annotated[Session, Depends(get_db)],
): ):
rows = [] rows = []
@@ -89,6 +88,7 @@ def upsert_feed_checkpoint(
@router.post("/feed/old-checkpoint") @router.post("/feed/old-checkpoint")
def upsert_opd_checkpoint( def upsert_opd_checkpoint(
payload: list[FeedCheckpointIn], payload: list[FeedCheckpointIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_OLD_CHECKPOINT_WRITE))],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
): ):
rows = [] rows = []

View File

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

View File

@@ -80,8 +80,9 @@ class ApiKey(Base):
) )
name: Mapped[str | None] = mapped_column(String(128), nullable=True) 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) 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) permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View File

@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging
import os import os
from fastapi import FastAPI 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.core.config import settings
from app.db.init_db import init_db 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): class ForceHTTPSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next): 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") statics_path = os.path.join(sqladmin_dir, "statics")
app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan) 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(ForceHTTPSMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY) app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.add_middleware(ForwardedProtoMiddleware) app.add_middleware(ForwardedProtoMiddleware)

View File

@@ -1,6 +1,10 @@
import secrets import secrets
from typing import Optional
import bcrypt 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: 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: 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")) 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 %} {% block content %}
{{ super() }} {{ 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 mt-3">
<div class="card-header"> <div class="card-header">
<h5>API Key Actions</h5> <h5>API Key Actions</h5>
</div> </div>
<div class="card-body"> <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 <i class="fa fa-refresh"></i> Regenerate API Key
</button> </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) <i class="fa fa-eye"></i> View API Key (if just created)
</button> </button>
@@ -38,7 +42,10 @@ async function regenerateApiKey(keyId) {
} }
try { 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -63,8 +70,11 @@ async function regenerateApiKey(keyId) {
async function viewApiKey(keyId) { async function viewApiKey(keyId) {
try { try {
const response = await fetch(`/apiservice/admin/api-keys/${keyId}/view`, { const rootPath = '{{ request.scope.get("root_path", "") }}';
method: 'GET', const url = `${rootPath}/admin/api-keys/${keyId}/view`;
const response = await fetch(url, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }

View File

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

View File

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