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
This commit is contained in:
YueGuobin 2026-06-11 22:54:03 +08:00
parent 8e9afbcf92
commit a79bc7dd20
No known key found for this signature in database
8 changed files with 361 additions and 6 deletions

View File

@ -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"]
)

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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)

View File

@ -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")

View File

@ -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,

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
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

View File

@ -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')

View File

@ -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