From f522c947bcdd83c822984c242be958874577a47b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 15 Jun 2026 23:56:36 +0800 Subject: [PATCH] Optimize API key auth: O(1) lookup via UUID-embedded key format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New format: gns3__ - Auth extracts api_key_id from token → single DB query by UUID → one bcrypt - No more scanning all keys (was O(n) with bcrypt per key) - bcrypt.checkpw offloaded to thread pool to prevent event loop blocking - Legacy gns3_ format removed (compatibility break) --- gns3server/api/routes/controller/api_keys.py | 15 +++--- .../controller/dependencies/authentication.py | 47 ++++++++++--------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py index 4d6e456d6..bccc11a0d 100644 --- a/gns3server/api/routes/controller/api_keys.py +++ b/gns3server/api/routes/controller/api_keys.py @@ -39,12 +39,15 @@ API_KEY_PREFIX = "gns3_" API_KEY_BYTES = 32 -def _generate_api_key() -> tuple[str, str, str]: +def _generate_api_key(api_key_id: UUID = None) -> tuple[str, str, str, UUID]: + if api_key_id is None: + api_key_id = uuid4() 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() + raw_key = f"gns3_{api_key_id}_{random_bytes}" + # Only hash the random secret part, so auth can extract api_key_id and do O(1) lookup + key_hash = bcrypt.hashpw(random_bytes.encode(), bcrypt.gensalt()).decode() key_prefix = raw_key[: len(API_KEY_PREFIX) + 8] - return raw_key, key_hash, key_prefix + return raw_key, key_hash, key_prefix, api_key_id @router.post("", status_code=status.HTTP_201_CREATED) @@ -55,9 +58,9 @@ async def create_api_key( ) -> dict: """Create a new API key. The full key is returned only once.""" - raw_key, key_hash, key_prefix = _generate_api_key() + raw_key, key_hash, key_prefix, new_key_id = _generate_api_key() db_key = await api_keys_repo.create_api_key( - api_key_id=uuid4(), + api_key_id=new_key_id, user_id=current_user.user_id, name=api_key_data.name, key_hash=key_hash, diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index 9fdddd30c..770c9f66e 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -57,31 +57,32 @@ async def get_user_from_token( headers={"WWW-Authenticate": "Bearer"}, ) - # API Key authentication + # API Key authentication — format: gns3__ + # Direct lookup by UUID avoids O(n) scan of all keys. if token.startswith("gns3_"): log.info(f"[CTRL-TIMING] get_user_from_token API_KEY auth elapsed={time.time()-_t0:.3f}s") - query = select(models.ApiKey).where(models.ApiKey.revoked == False) - result = await api_keys_repo._db_session.execute(query) - api_keys_list = result.scalars().all() - log.info(f"[CTRL-TIMING] get_user_from_token api_keys_count={len(api_keys_list)} elapsed={time.time()-_t0:.3f}s") - for db_key in api_keys_list: - # bcrypt.checkpw is CPU-bound and blocks the event loop; run in thread - if await asyncio.to_thread(bcrypt.checkpw, token.encode(), db_key.key_hash.encode()): - await api_keys_repo.update_last_used(db_key.api_key_id) - user = await user_repo.get_user(db_key.user_id) - if user: - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not an active user", - headers={"WWW-Authenticate": "Bearer"}, - ) - return user - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - headers={"WWW-Authenticate": "Bearer"}, - ) + parts = token.split("_", 2) + if len(parts) != 3: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format") + try: + key_id = UUID(parts[1]) + except ValueError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format") + secret = parts[2] + db_key = await api_keys_repo.get_api_key(key_id) + if not db_key or db_key.revoked: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + if not await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.encode()): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + await api_keys_repo.update_last_used(db_key.api_key_id) + user = await user_repo.get_user(db_key.user_id) + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not an active user", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user # JWT authentication token_data = auth_service.get_token_data(token)