feat(mcp): store MCP tokens as SHA-256 hashes instead of plaintext

Replace plaintext token storage with SHA-256 hashes so leaked database
contents cannot be used to authenticate. Plaintext is generated, shown
once at creation time, and never persisted.

- Add `hash_token()` helper and `MCPTokenManager.create_token()` that
  returns `(instance, plaintext)`.
- Replace `token` field with indexed `token_hash`; look up bearers by
  hashing the incoming value.
- Update dashboard, management command, and admin to surface plaintext
  only at creation. Disable admin "add" since it cannot reveal plaintext.
- Migration drops the old `token` column and adds `token_hash`;
  pre-existing tokens are invalidated and must be reissued.
This commit is contained in:
2026-04-27 09:01:36 -04:00
parent 2df22941d2
commit 81426327bf
24 changed files with 950 additions and 50 deletions

View File

@@ -12,7 +12,7 @@ from fastmcp.server.dependencies import get_http_request
from fastmcp.server.middleware import Middleware, MiddlewareContext
from .metrics import mcp_auth_failures_total
from .models import MCPToken
from .models import MCPToken, hash_token
logger = logging.getLogger(__name__)
@@ -25,9 +25,17 @@ class MCPAuthError(Exception):
def resolve_mcp_user(token_string: str):
"""Resolve a bearer token to (user, MCPToken). Raises MCPAuthError on any failure."""
"""Resolve a bearer token to (user, MCPToken). Raises MCPAuthError on any failure.
Hashes the incoming bearer and looks up by the hash — plaintext is never
stored or compared directly.
"""
try:
token = MCPToken.objects.select_related("user").get(token=token_string)
token = (
MCPToken.objects
.select_related("user")
.get(token_hash=hash_token(token_string))
)
except MCPToken.DoesNotExist:
raise MCPAuthError("Invalid MCP token.")