From a79bc7dd2033edbe69a26a10b815868e20e4acde Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 22:54:03 +0800 Subject: [PATCH] feat: Add API Key support for MCP authentication - New db model: api_keys table with bcrypt-hashed keys - New API: POST/GET/DELETE /v3/access/api-keys endpoints - MCP _resolve_token: validates API keys, resolves to 5-min JWT - API keys inherit the creating user's RBAC permissions - MCP auth supports both JWT (24h) and API key (permanent) tokens --- gns3server/api/routes/controller/__init__.py | 7 + gns3server/api/routes/controller/api_keys.py | 127 ++++++++++++++++++ gns3server/api/routes/mcp/__init__.py | 57 +++++++- gns3server/db/models/__init__.py | 1 + gns3server/db/models/api_keys.py | 33 +++++ gns3server/db/repositories/api_keys.py | 94 +++++++++++++ .../versions/f0b0de2a9_add_api_keys_table.py | 42 ++++++ gns3server/schemas/controller/tokens.py | 6 + 8 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 gns3server/api/routes/controller/api_keys.py create mode 100644 gns3server/db/models/api_keys.py create mode 100644 gns3server/db/repositories/api_keys.py create mode 100644 gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index f34d342c3..2891642f9 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -60,6 +60,7 @@ from . import roles from . import acl from . import pools from . import privileges +from . import api_keys from .dependencies.authentication import get_current_active_user @@ -192,3 +193,9 @@ router.include_router( dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"] ) + +router.include_router( + api_keys.router, + dependencies=[Depends(get_current_active_user)], + tags=["API Keys"] +) diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py new file mode 100644 index 000000000..4a6260761 --- /dev/null +++ b/gns3server/api/routes/controller/api_keys.py @@ -0,0 +1,127 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +API routes for API key management. +""" + +import secrets +import bcrypt +from uuid import uuid4, UUID + +from fastapi import APIRouter, Depends, status + +from gns3server import schemas +from gns3server.schemas.controller.tokens import TokenData +from gns3server.db.repositories.api_keys import ApiKeysRepository +from gns3server.db.repositories.users import UsersRepository +from .dependencies.database import get_repository +from .dependencies.authentication import get_current_active_user + +import logging + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/access/api-keys", tags=["API Keys"]) + +API_KEY_PREFIX = "gns3_" +API_KEY_BYTES = 32 # 256-bit key, results in 64 hex chars + + +def _generate_api_key() -> tuple[str, str, str]: + """Generate a new API key. + + Returns: + Tuple of (full_key, key_hash, key_prefix) + """ + random_bytes = secrets.token_hex(API_KEY_BYTES) + raw_key = API_KEY_PREFIX + random_bytes + key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode() + key_prefix = raw_key[: len(API_KEY_PREFIX) + 8] # gns3_ + first 8 hex chars + return raw_key, key_hash, key_prefix + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, +) +async def create_api_key( + api_key_data: schemas.ApiKeyCreate, + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> dict: + """Create a new API key. The full key is returned only once.""" + + raw_key, key_hash, key_prefix = _generate_api_key() + db_key = await api_keys_repo.create_api_key( + api_key_id=uuid4(), + user_id=current_user.user_id, + name=api_key_data.name, + key_hash=key_hash, + key_prefix=key_prefix, + ) + return { + "api_key_id": str(db_key.api_key_id), + "api_key": raw_key, + "name": db_key.name, + "key_prefix": db_key.key_prefix, + "created_at": db_key.created_at.isoformat() if db_key.created_at else None, + } + + +@router.get("") +async def list_api_keys( + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> list[dict]: + """List all API keys for the current user.""" + + keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id) + return [ + { + "api_key_id": str(k.api_key_id), + "name": k.name, + "key_prefix": k.key_prefix, + "created_at": k.created_at.isoformat() if k.created_at else None, + "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, + "revoked": k.revoked, + } + for k in keys + ] + + +@router.delete( + "/{api_key_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def revoke_api_key( + api_key_id: UUID, + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> None: + """Revoke an API key (soft delete — sets revoked=True).""" + + key = await api_keys_repo.get_api_key(api_key_id) + if not key: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="API key not found") + + # Only the key owner can revoke it + if key.user_id != current_user.user_id: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Cannot revoke another user's API key") + + await api_keys_repo.revoke_api_key(api_key_id) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index cff1ae774..01066209c 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -32,6 +32,7 @@ import asyncio import logging import socket import uuid +import bcrypt from typing import Any, Annotated from urllib.parse import parse_qs @@ -43,9 +44,16 @@ from pydantic import Field from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + from gns3server.config import Config +from gns3server.services.authentication import AuthService +import gns3server.db.models as models from gns3server.services import auth_service from gns3server.utils.request_utils import extract_client_info +from gns3server.db.repositories.api_keys import ApiKeysRepository +from gns3server.db.repositories.users import UsersRepository from .projects import ( list_projects_handler, get_project_handler, create_project_handler, delete_project_handler, open_project_handler, close_project_handler, @@ -111,6 +119,9 @@ from .drawings import ( log = logging.getLogger(__name__) +# Database engine reference — set during register_starlette_routes +_db_engine = None + # ── Server ready state ──────────────────────────────────────────────── # Tracks whether GNS3 server has completed initialization. @@ -176,13 +187,40 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( # ── Token validation ────────────────────────────────────────────────── -async def _validate_token(token: str) -> bool: - """Return True if token is a valid GNS3 JWT.""" +async def _resolve_token(token: str) -> str | None: + """Validate a token (JWT or API key) and return the effective JWT to use. + + For JWT tokens, returns the token as-is. + For API keys, validates against the database and returns a fresh short-lived JWT. + + Returns None if the token is invalid. + """ + # Try JWT first try: auth_service.get_username_from_token(token) - return True + return token except Exception: - return False + pass + + # Try API key + if token.startswith("gns3_") and _db_engine is not None: + try: + async with AsyncSession(_db_engine, expire_on_commit=False) as db_session: + repo = ApiKeysRepository(db_session) + query = select(models.ApiKey).where(models.ApiKey.revoked == False) + result = await db_session.execute(query) + for db_key in result.scalars().all(): + if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): + await repo.update_last_used(db_key.api_key_id) + user_repo = UsersRepository(db_session) + user = await user_repo.get_user(db_key.user_id) + if user: + svc = AuthService() + return svc.create_access_token(user.username, expires_in=5) + except Exception: + pass + + return None # ── Server URL helper ───────────────────────────────────────────────── @@ -1305,11 +1343,16 @@ def _make_auth_wrapper(inner_app): tokens = params.get("token", []) if tokens: token = tokens[0] - if not token or not await _validate_token(token): + if not token: response = Response("Missing or invalid token", status_code=401) await response(scope, receive, send) return - _jwt_token_var.set(token) + resolved = await _resolve_token(token) + if not resolved: + response = Response("Missing or invalid token", status_code=401) + await response(scope, receive, send) + return + _jwt_token_var.set(resolved) await inner_app(scope, receive, send) return auth_wrapper @@ -1335,6 +1378,8 @@ async def mcp_root(): def register_starlette_routes(app): """Mount MCP transports on the FastAPI app.""" + global _db_engine + _db_engine = getattr(app.state, "_db_engine", None) sse_app = _make_auth_wrapper(mcp.sse_app(mount_path="")) app.mount("/v3/mcp/transport", sse_app, name="mcp-sse") log.info("MCP SSE server mounted at /v3/mcp/transport") diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py index 5e7afb07b..cd538b80d 100644 --- a/gns3server/db/models/__init__.py +++ b/gns3server/db/models/__init__.py @@ -24,6 +24,7 @@ from .computes import Compute from .images import Image from .pools import Resource, ResourcePool from .llm_model_configs import LLMModelConfig +from .api_keys import ApiKey from .templates import ( Template, CloudTemplate, diff --git a/gns3server/db/models/api_keys.py b/gns3server/db/models/api_keys.py new file mode 100644 index 000000000..b43ecf5f2 --- /dev/null +++ b/gns3server/db/models/api_keys.py @@ -0,0 +1,33 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, func + +from .base import BaseTable, GUID + + +class ApiKey(BaseTable): + + __tablename__ = "api_keys" + + api_key_id = Column(GUID, primary_key=True) + user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(128), nullable=False) + key_hash = Column(String(128), nullable=False) + key_prefix = Column(String(8), nullable=False) + last_used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False) + revoked = Column(Boolean, default=False, nullable=False) diff --git a/gns3server/db/repositories/api_keys.py b/gns3server/db/repositories/api_keys.py new file mode 100644 index 000000000..8b635565a --- /dev/null +++ b/gns3server/db/repositories/api_keys.py @@ -0,0 +1,94 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from uuid import UUID +from typing import Optional, List +from datetime import datetime, timezone +from sqlalchemy import select, update, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from .base import BaseRepository +import gns3server.db.models as models + +import logging + +log = logging.getLogger(__name__) + + +class ApiKeysRepository(BaseRepository): + + def __init__(self, db_session: AsyncSession) -> None: + super().__init__(db_session) + + async def create_api_key( + self, api_key_id: UUID, user_id: UUID, name: str, key_hash: str, key_prefix: str + ) -> models.ApiKey: + db_api_key = models.ApiKey( + api_key_id=api_key_id, + user_id=user_id, + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + ) + self._db_session.add(db_api_key) + await self._db_session.commit() + await self._db_session.refresh(db_api_key) + return db_api_key + + async def get_api_key(self, api_key_id: UUID) -> Optional[models.ApiKey]: + query = select(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def get_api_keys_by_user(self, user_id: UUID) -> List[models.ApiKey]: + query = ( + select(models.ApiKey) + .where(models.ApiKey.user_id == user_id) + .order_by(models.ApiKey.created_at.desc()) + ) + result = await self._db_session.execute(query) + return list(result.scalars().all()) + + async def get_api_key_by_hash(self, key_hash: str) -> Optional[models.ApiKey]: + query = select(models.ApiKey).where(models.ApiKey.key_hash == key_hash) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def update_last_used(self, api_key_id: UUID) -> None: + query = ( + update(models.ApiKey) + .where(models.ApiKey.api_key_id == api_key_id) + .values(last_used_at=func.now()) + ) + await self._db_session.execute(query) + await self._db_session.commit() + + async def revoke_api_key(self, api_key_id: UUID) -> bool: + query = ( + update(models.ApiKey) + .where(models.ApiKey.api_key_id == api_key_id) + .values(revoked=True) + ) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + async def delete_api_key(self, api_key_id: UUID) -> bool: + query = delete(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 diff --git a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py new file mode 100644 index 000000000..5c438678e --- /dev/null +++ b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py @@ -0,0 +1,42 @@ +"""add api_keys table + +Revision ID: f0b0de2a9 +Revises: a8829e6c069b +Create Date: 2026-06-11 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import gns3server.db.models.base as models + +# revision identifiers, used by Alembic. +revision = 'f0b0de2a9' +down_revision = 'a8829e6c069b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.create_table( + 'api_keys', + sa.Column('api_key_id', models.GUID(), nullable=False), + sa.Column('user_id', models.GUID(), nullable=False), + sa.Column('name', sa.String(128), nullable=False), + sa.Column('key_hash', sa.String(128), nullable=False), + sa.Column('key_prefix', sa.String(8), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column('revoked', sa.Boolean(), default=False, nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('api_key_id'), + ) + op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id']) + op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash']) + + +def downgrade() -> None: + + op.drop_index('ix_api_keys_key_hash', table_name='api_keys') + op.drop_index('ix_api_keys_user_id', table_name='api_keys') + op.drop_table('api_keys') diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index 86c1a9377..e36a1d35c 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -28,3 +28,9 @@ class TokenData(BaseModel): username: Optional[str] = None token_version: int = 0 + + +class ApiKeyCreate(BaseModel): + """Schema for creating a new API key.""" + + name: str