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_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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user