From 1d9bdb7daa5301da27605aeed4feeb0ad9dcfe55 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 23:28:27 +0800 Subject: [PATCH] feat(admin): add api client management UI and backend CRUD/rotate endpoints --- backend/app/api/admin_catalog.py | 132 +++++++++ backend/app/schemas/api_clients.py | 49 ++++ backend/app/security/api_client_auth.py | 4 + frontend/src/App.vue | 3 +- frontend/src/api/api-clients.js | 6 + frontend/src/pages/admin/ApiClientsPage.vue | 282 ++++++++++++++++++++ frontend/src/router/index.js | 6 + 7 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 backend/app/schemas/api_clients.py create mode 100644 frontend/src/api/api-clients.js create mode 100644 frontend/src/pages/admin/ApiClientsPage.vue diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py index 8823057..d795953 100644 --- a/backend/app/api/admin_catalog.py +++ b/backend/app/api/admin_catalog.py @@ -1,9 +1,12 @@ +import secrets from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select from sqlalchemy.orm import Session from app.core.keygen import generate_key from app.core.config import get_settings from app.db.session import get_db +from app.models.api_client import ApiClient from app.repositories.companies_repo import CompaniesRepository from app.repositories.modules_repo import ModulesRepository from app.repositories.permission_groups_repo import PermissionGroupsRepository @@ -38,8 +41,16 @@ from app.schemas.catalog import ( SystemItem, SystemUpdateRequest, ) +from app.schemas.api_clients import ( + ApiClientCreateRequest, + ApiClientCreateResponse, + ApiClientItem, + ApiClientRotateKeyResponse, + ApiClientUpdateRequest, +) from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.security.admin_guard import require_admin_principal +from app.security.api_client_auth import hash_api_key from app.services.authentik_admin_service import AuthentikAdminService router = APIRouter( @@ -99,6 +110,27 @@ def _generate_unique_key(prefix: str, exists_fn) -> str: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_to_generate_{prefix.lower()}_key") +def _serialize_api_client(item: ApiClient) -> ApiClientItem: + return ApiClientItem( + id=item.id, + client_key=item.client_key, + name=item.name, + status=item.status, + allowed_origins=item.allowed_origins or [], + allowed_ips=item.allowed_ips or [], + allowed_paths=item.allowed_paths or [], + rate_limit_per_min=item.rate_limit_per_min, + expires_at=item.expires_at, + last_used_at=item.last_used_at, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _generate_api_key() -> str: + return secrets.token_urlsafe(36) + + def _sync_member_to_authentik( *, authentik_sub: str | None, @@ -627,6 +659,106 @@ def set_member_permission_groups( return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys) +@router.get("/api-clients") +def list_api_clients( + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> dict: + stmt = select(ApiClient) + count_stmt = select(ApiClient) + if keyword: + pattern = f"%{keyword}%" + filter_cond = (ApiClient.client_key.ilike(pattern)) | (ApiClient.name.ilike(pattern)) + stmt = stmt.where(filter_cond) + count_stmt = count_stmt.where(filter_cond) + items = list(db.scalars(stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset)).all()) + total = len(list(db.scalars(count_stmt))) + return { + "items": [_serialize_api_client(item).model_dump() for item in items], + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.post("/api-clients", response_model=ApiClientCreateResponse) +def create_api_client( + payload: ApiClientCreateRequest, + db: Session = Depends(get_db), +) -> ApiClientCreateResponse: + status_value = payload.status.strip().lower() + if status_value not in {"active", "inactive"}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status") + client_key = payload.client_key or _generate_unique_key( + "AC", lambda value: db.scalar(select(ApiClient).where(ApiClient.client_key == value)) is not None + ) + exists = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) + if exists: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_already_exists") + api_key = _generate_api_key() + row = ApiClient( + client_key=client_key, + name=payload.name, + status=status_value, + api_key_hash=hash_api_key(api_key), + allowed_origins=payload.allowed_origins, + allowed_ips=payload.allowed_ips, + allowed_paths=payload.allowed_paths, + rate_limit_per_min=payload.rate_limit_per_min, + expires_at=payload.expires_at, + ) + db.add(row) + db.commit() + db.refresh(row) + return ApiClientCreateResponse(item=_serialize_api_client(row), api_key=api_key) + + +@router.patch("/api-clients/{client_key}", response_model=ApiClientItem) +def update_api_client( + client_key: str, + payload: ApiClientUpdateRequest, + db: Session = Depends(get_db), +) -> ApiClientItem: + row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") + + if payload.name is not None: + row.name = payload.name + if payload.status is not None: + next_status = payload.status.strip().lower() + if next_status not in {"active", "inactive"}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status") + row.status = next_status + if payload.allowed_origins is not None: + row.allowed_origins = payload.allowed_origins + if payload.allowed_ips is not None: + row.allowed_ips = payload.allowed_ips + if payload.allowed_paths is not None: + row.allowed_paths = payload.allowed_paths + row.rate_limit_per_min = payload.rate_limit_per_min + row.expires_at = payload.expires_at + db.commit() + db.refresh(row) + return _serialize_api_client(row) + + +@router.post("/api-clients/{client_key}/rotate-key", response_model=ApiClientRotateKeyResponse) +def rotate_api_client_key( + client_key: str, + db: Session = Depends(get_db), +) -> ApiClientRotateKeyResponse: + row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") + api_key = _generate_api_key() + row.api_key_hash = hash_api_key(api_key) + db.commit() + return ApiClientRotateKeyResponse(client_key=row.client_key, api_key=api_key) + + @router.get("/permission-groups") def list_permission_groups( db: Session = Depends(get_db), diff --git a/backend/app/schemas/api_clients.py b/backend/app/schemas/api_clients.py new file mode 100644 index 0000000..66a7961 --- /dev/null +++ b/backend/app/schemas/api_clients.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ApiClientItem(BaseModel): + id: str + client_key: str + name: str + status: str + allowed_origins: list[str] = Field(default_factory=list) + allowed_ips: list[str] = Field(default_factory=list) + allowed_paths: list[str] = Field(default_factory=list) + rate_limit_per_min: int | None = None + expires_at: datetime | None = None + last_used_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class ApiClientCreateRequest(BaseModel): + name: str + client_key: str | None = None + status: str = "active" + allowed_origins: list[str] = Field(default_factory=list) + allowed_ips: list[str] = Field(default_factory=list) + allowed_paths: list[str] = Field(default_factory=list) + rate_limit_per_min: int | None = None + expires_at: datetime | None = None + + +class ApiClientUpdateRequest(BaseModel): + name: str | None = None + status: str | None = None + allowed_origins: list[str] | None = None + allowed_ips: list[str] | None = None + allowed_paths: list[str] | None = None + rate_limit_per_min: int | None = None + expires_at: datetime | None = None + + +class ApiClientCreateResponse(BaseModel): + item: ApiClientItem + api_key: str + + +class ApiClientRotateKeyResponse(BaseModel): + client_key: str + api_key: str diff --git a/backend/app/security/api_client_auth.py b/backend/app/security/api_client_auth.py index 4854ae4..c51b6f6 100644 --- a/backend/app/security/api_client_auth.py +++ b/backend/app/security/api_client_auth.py @@ -13,6 +13,10 @@ from app.models.api_client import ApiClient pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto") +def hash_api_key(plain_key: str) -> str: + return pwd_context.hash(plain_key) + + def _verify_api_key(plain_key: str, stored_hash: str) -> bool: # Support sha256: for bootstrap, and bcrypt/argon2 for production. if stored_hash.startswith("sha256:"): diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 92e211f..04b74c2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -61,7 +61,8 @@ const adminTabs = [ { to: '/admin/companies', label: '公司' }, { to: '/admin/sites', label: '站台' }, { to: '/admin/members', label: '會員' }, - { to: '/admin/permission-groups', label: '群組' } + { to: '/admin/permission-groups', label: '群組' }, + { to: '/admin/api-clients', label: 'API Clients' } ] // 行內 NavTab 元件:避免另開檔案 diff --git a/frontend/src/api/api-clients.js b/frontend/src/api/api-clients.js new file mode 100644 index 0000000..48eceb3 --- /dev/null +++ b/frontend/src/api/api-clients.js @@ -0,0 +1,6 @@ +import { adminHttp } from './http' + +export const getApiClients = (params) => adminHttp.get('/admin/api-clients', { params }) +export const createApiClient = (data) => adminHttp.post('/admin/api-clients', data) +export const updateApiClient = (clientKey, data) => adminHttp.patch(`/admin/api-clients/${clientKey}`, data) +export const rotateApiClientKey = (clientKey) => adminHttp.post(`/admin/api-clients/${clientKey}/rotate-key`) diff --git a/frontend/src/pages/admin/ApiClientsPage.vue b/frontend/src/pages/admin/ApiClientsPage.vue new file mode 100644 index 0000000..6408fc4 --- /dev/null +++ b/frontend/src/pages/admin/ApiClientsPage.vue @@ -0,0 +1,282 @@ + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e0bbed3..b776b21 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -66,6 +66,12 @@ const routes = [ name: 'admin-permission-groups', component: () => import('@/pages/admin/PermissionGroupsPage.vue'), meta: { requiresAuth: true } + }, + { + path: '/admin/api-clients', + name: 'admin-api-clients', + component: () => import('@/pages/admin/ApiClientsPage.vue'), + meta: { requiresAuth: true } } ]