diff --git a/03-apiservice/.env.example b/03-apiservice/.env.example index 5926027..efc2d67 100644 --- a/03-apiservice/.env.example +++ b/03-apiservice/.env.example @@ -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 diff --git a/03-apiservice/Dockerfile b/03-apiservice/Dockerfile index e75b84d..2659009 100644 --- a/03-apiservice/Dockerfile +++ b/03-apiservice/Dockerfile @@ -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"] diff --git a/03-apiservice/app/admin.py b/03-apiservice/app/admin.py index 4330a71..344c780 100644 --- a/03-apiservice/app/admin.py +++ b/03-apiservice/app/admin.py @@ -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." diff --git a/03-apiservice/app/api/v1/routes.py b/03-apiservice/app/api/v1/routes.py index e05b407..d1a3021 100644 --- a/03-apiservice/app/api/v1/routes.py +++ b/03-apiservice/app/api/v1/routes.py @@ -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 = [] diff --git a/03-apiservice/app/core/config.py b/03-apiservice/app/core/config.py index a626f99..7de53af 100644 --- a/03-apiservice/app/core/config.py +++ b/03-apiservice/app/core/config.py @@ -31,5 +31,7 @@ class Settings(BaseSettings): ADMIN_USERNAME: str ADMIN_PASSWORD: str + API_KEY_ENC_SECRET: str | None = None + settings = Settings() diff --git a/03-apiservice/app/db/models.py b/03-apiservice/app/db/models.py index c1d8c5e..d596918 100644 --- a/03-apiservice/app/db/models.py +++ b/03-apiservice/app/db/models.py @@ -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) diff --git a/03-apiservice/app/main.py b/03-apiservice/app/main.py index 97a4489..ebe9878 100644 --- a/03-apiservice/app/main.py +++ b/03-apiservice/app/main.py @@ -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) diff --git a/03-apiservice/app/security/api_key.py b/03-apiservice/app/security/api_key.py index 9a28d98..3c29717 100644 --- a/03-apiservice/app/security/api_key.py +++ b/03-apiservice/app/security/api_key.py @@ -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 diff --git a/03-apiservice/app/templates/apikey_details.html b/03-apiservice/app/templates/apikey_edit.html similarity index 80% rename from 03-apiservice/app/templates/apikey_details.html rename to 03-apiservice/app/templates/apikey_edit.html index 54bcb53..585d490 100644 --- a/03-apiservice/app/templates/apikey_details.html +++ b/03-apiservice/app/templates/apikey_edit.html @@ -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') %} + +
API Key Actions
- - @@ -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', } diff --git a/03-apiservice/docker-compose.yml b/03-apiservice/docker-compose.yml index 0e6a808..1ab1219 100644 --- a/03-apiservice/docker-compose.yml +++ b/03-apiservice/docker-compose.yml @@ -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: diff --git a/03-apiservice/requirements.txt b/03-apiservice/requirements.txt index f04f728..58c0093 100644 --- a/03-apiservice/requirements.txt +++ b/03-apiservice/requirements.txt @@ -13,4 +13,5 @@ python-multipart==0.0.20 httpx==0.28.1 WTForms #==3.2.1 +cryptography==42.0.5