feat: Add API key authentication system for external service access (#1757)

* Initial plan

* feat: Add API key authentication system backend

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* feat: Add API key management UI in frontend sidebar

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: Correct import paths in API controller groups

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: Address code review feedback - add i18n and validation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* refactor: Enable API key auth on existing endpoints instead of creating separate service API

- Added USER_TOKEN_OR_API_KEY auth type that accepts both authentication methods
- Removed separate /api/service/v1/models endpoints
- Updated existing endpoints (models, bots, pipelines) to accept API keys
- External services can now use API keys to access all existing LangBot APIs
- Updated documentation to reflect unified API approach

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* docs: Add OpenAPI specification for API key authenticated endpoints

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: rename openapi spec

* perf: ui and i18n

* fix: ui bug

* chore: tidy docs

* chore: fix linter errors

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-07 14:08:11 +08:00
committed by GitHub
parent af82227dff
commit a076ce5756
18 changed files with 2899 additions and 13 deletions

View File

@@ -34,6 +34,8 @@ class AuthType(enum.Enum):
NONE = 'none'
USER_TOKEN = 'user-token'
API_KEY = 'api-key'
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
class RouterGroup(abc.ABC):
@@ -87,6 +89,63 @@ class RouterGroup(abc.ABC):
except Exception as e:
return self.http_status(401, -1, str(e))
elif auth_type == AuthType.API_KEY:
# get API key from Authorization header or X-API-Key header
api_key = quart.request.headers.get('X-API-Key', '')
if not api_key:
auth_header = quart.request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
api_key = auth_header.replace('Bearer ', '')
if not api_key:
return self.http_status(401, -1, 'No valid API key provided')
try:
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
if not is_valid:
return self.http_status(401, -1, 'Invalid API key')
except Exception as e:
return self.http_status(401, -1, str(e))
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
# Try API key first (check X-API-Key header)
api_key = quart.request.headers.get('X-API-Key', '')
if api_key:
# API key authentication
try:
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
if not is_valid:
return self.http_status(401, -1, 'Invalid API key')
except Exception as e:
return self.http_status(401, -1, str(e))
else:
# Try user token authentication (Authorization header)
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return self.http_status(401, -1, 'No valid authentication provided (user token or API key required)')
try:
user_email = await self.ap.user_service.verify_jwt_token(token)
# check if this account exists
user = await self.ap.user_service.get_user_by_email(user_email)
if not user:
return self.http_status(401, -1, 'User not found')
# check if f accepts user_email parameter
if 'user_email' in f.__code__.co_varnames:
kwargs['user_email'] = user_email
except Exception:
# If user token fails, maybe it's an API key in Authorization header
try:
is_valid = await self.ap.apikey_service.verify_api_key(token)
if not is_valid:
return self.http_status(401, -1, 'Invalid authentication credentials')
except Exception as e:
return self.http_status(401, -1, str(e))
try:
return await f(*args, **kwargs)

View File

@@ -0,0 +1,43 @@
import quart
from .. import group
@group.group_class('apikeys', '/api/v1/apikeys')
class ApiKeysRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
async def _() -> str:
if quart.request.method == 'GET':
keys = await self.ap.apikey_service.get_api_keys()
return self.success(data={'keys': keys})
elif quart.request.method == 'POST':
json_data = await quart.request.json
name = json_data.get('name', '')
description = json_data.get('description', '')
if not name:
return self.http_status(400, -1, 'Name is required')
key = await self.ap.apikey_service.create_api_key(name, description)
return self.success(data={'key': key})
@self.route('/<int:key_id>', methods=['GET', 'PUT', 'DELETE'])
async def _(key_id: int) -> str:
if quart.request.method == 'GET':
key = await self.ap.apikey_service.get_api_key(key_id)
if key is None:
return self.http_status(404, -1, 'API key not found')
return self.success(data={'key': key})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
name = json_data.get('name')
description = json_data.get('description')
await self.ap.apikey_service.update_api_key(key_id, name, description)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.apikey_service.delete_api_key(key_id)
return self.success()

View File

@@ -8,7 +8,7 @@ from ... import group
@group.group_class('pipelines', '/api/v1/pipelines')
class PipelinesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
sort_by = quart.request.args.get('sort_by', 'created_at')
@@ -23,11 +23,11 @@ class PipelinesRouterGroup(group.RouterGroup):
return self.success(data={'uuid': pipeline_uuid})
@self.route('/_/metadata', methods=['GET'])
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'])
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET':
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
@@ -47,7 +47,7 @@ class PipelinesRouterGroup(group.RouterGroup):
return self.success()
@self.route('/<pipeline_uuid>/extensions', methods=['GET', 'PUT'])
@self.route('/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET':
# Get current extensions and available plugins

View File

@@ -6,7 +6,7 @@ from ... import group
@group.group_class('bots', '/api/v1/platform/bots')
class BotsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
@@ -15,7 +15,7 @@ class BotsRouterGroup(group.RouterGroup):
bot_uuid = await self.ap.bot_service.create_bot(json_data)
return self.success(data={'uuid': bot_uuid})
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'])
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
bot = await self.ap.bot_service.get_bot(bot_uuid)
@@ -30,7 +30,7 @@ class BotsRouterGroup(group.RouterGroup):
await self.ap.bot_service.delete_bot(bot_uuid)
return self.success()
@self.route('/<bot_uuid>/logs', methods=['POST'])
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
json_data = await quart.request.json
from_index = json_data.get('from_index', -1)

View File

@@ -6,7 +6,7 @@ from ... import group
@group.group_class('models/llm', '/api/v1/provider/models/llm')
class LLMModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
@@ -17,7 +17,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'])
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
@@ -37,7 +37,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'])
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
@@ -49,7 +49,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
class EmbeddingModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
@@ -60,7 +60,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'])
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
@@ -80,7 +80,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'])
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import secrets
import sqlalchemy
from ....core import app
from ....entity.persistence import apikey
class ApiKeyService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_api_keys(self) -> list[dict]:
"""Get all API keys"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))
keys = result.all()
return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]
async def create_api_key(self, name: str, description: str = '') -> dict:
"""Create a new API key"""
# Generate a secure random API key
key = f'lbk_{secrets.token_urlsafe(32)}'
key_data = {'name': name, 'key': key, 'description': description}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))
# Retrieve the created key
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
)
created_key = result.first()
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)
async def get_api_key(self, key_id: int) -> dict | None:
"""Get a specific API key by ID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
)
key = result.first()
if key is None:
return None
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
async def verify_api_key(self, key: str) -> bool:
"""Verify if an API key is valid"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
)
key_obj = result.first()
return key_obj is not None
async def delete_api_key(self, key_id: int) -> None:
"""Delete an API key"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
)
async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
"""Update an API key's metadata (name, description)"""
update_data = {}
if name is not None:
update_data['name'] = name
if description is not None:
update_data['description'] = description
if update_data:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)
)