fix bug api key managemtn for admin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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_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}
|
||||
),
|
||||
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"
|
||||
|
||||
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."
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -31,5 +31,7 @@ class Settings(BaseSettings):
|
||||
ADMIN_USERNAME: str
|
||||
ADMIN_PASSWORD: str
|
||||
|
||||
API_KEY_ENC_SECRET: str | None = None
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -13,4 +13,5 @@ python-multipart==0.0.20
|
||||
httpx==0.28.1
|
||||
WTForms
|
||||
#==3.2.1
|
||||
cryptography==42.0.5
|
||||
|
||||
|
||||
Reference in New Issue
Block a user