perf: run bcrypt on a thread so it doesn't block the event loop

verify_password / get_password_hash are CPU-bound and take ~250ms each
at rounds=12. Called directly from async endpoints, they stall every
other coroutine for that window — the single biggest single-worker
bottleneck on the login path.

Adds averify_password / ahash_password that wrap the sync versions in
asyncio.to_thread. Sync versions stay put because _ensure_admin_user and
tests still use them.

5 call sites updated: login, change-password, create-user, reset-password.
tests/test_auth_async.py asserts parallel averify runs concurrently (~1x
of a single verify, not 2x).
This commit is contained in:
2026-04-17 14:52:22 -04:00
parent bd406090a7
commit 3945e72e11
15 changed files with 724 additions and 42 deletions

View File

@@ -1,3 +1,4 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Optional, Any
import jwt
@@ -24,6 +25,15 @@ def get_password_hash(password: str) -> str:
return _hashed.decode("utf-8")
async def averify_password(plain_password: str, hashed_password: str) -> bool:
# bcrypt is CPU-bound and ~250ms/call; keep it off the event loop.
return await asyncio.to_thread(verify_password, plain_password, hashed_password)
async def ahash_password(password: str) -> str:
return await asyncio.to_thread(get_password_hash, password)
def create_access_token(data: dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
_to_encode: dict[str, Any] = data.copy()
_expire: datetime

View File

@@ -3,7 +3,7 @@ from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from decnet.telemetry import traced as _traced
from decnet.web.auth import get_password_hash, verify_password
from decnet.web.auth import ahash_password, averify_password
from decnet.web.dependencies import get_current_user_unchecked, repo
from decnet.web.db.models import ChangePasswordRequest
@@ -22,12 +22,12 @@ router = APIRouter()
@_traced("api.change_password")
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user_unchecked)) -> dict[str, str]:
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
if not _user or not verify_password(request.old_password, _user["password_hash"]):
if not _user or not await averify_password(request.old_password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect old password",
)
_new_hash: str = get_password_hash(request.new_password)
_new_hash: str = await ahash_password(request.new_password)
await repo.update_user_password(current_user, _new_hash, must_change_password=False)
return {"message": "Password updated successfully"}

View File

@@ -6,8 +6,8 @@ from fastapi import APIRouter, HTTPException, status
from decnet.telemetry import traced as _traced
from decnet.web.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
averify_password,
create_access_token,
verify_password,
)
from decnet.web.dependencies import repo
from decnet.web.db.models import LoginRequest, Token
@@ -28,7 +28,7 @@ router = APIRouter()
@_traced("api.login")
async def login(request: LoginRequest) -> dict[str, Any]:
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
if not _user or not verify_password(request.password, _user["password_hash"]):
if not _user or not await averify_password(request.password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",

View File

@@ -3,7 +3,7 @@ import uuid as _uuid
from fastapi import APIRouter, Depends, HTTPException
from decnet.telemetry import traced as _traced
from decnet.web.auth import get_password_hash
from decnet.web.auth import ahash_password
from decnet.web.dependencies import require_admin, repo
from decnet.web.db.models import (
CreateUserRequest,
@@ -39,7 +39,7 @@ async def api_create_user(
await repo.create_user({
"uuid": user_uuid,
"username": req.username,
"password_hash": get_password_hash(req.password),
"password_hash": await ahash_password(req.password),
"role": req.role,
"must_change_password": True, # nosec B105 — not a password
})
@@ -125,7 +125,7 @@ async def api_reset_user_password(
await repo.update_user_password(
user_uuid,
get_password_hash(req.new_password),
await ahash_password(req.new_password),
must_change_password=True,
)
return {"message": "Password reset successfully"}