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:
95
mnemosyne/mcp_server/views.py
Normal file
95
mnemosyne/mcp_server/views.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Self-service dashboard for MCP bearer tokens.
|
||||
|
||||
Mirrors the Themis API-keys flow visually but stores hashed tokens. Plaintext
|
||||
is shown to the user exactly once (on the create-success page) and never
|
||||
persisted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from .forms import MCPTokenCreateForm, MCPTokenEditForm
|
||||
from .models import MCPToken
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def mcp_token_list(request: HttpRequest) -> HttpResponse:
|
||||
tokens = MCPToken.objects.filter(user=request.user).order_by("-created_at")
|
||||
return render(request, "mcp_server/tokens/list.html", {"tokens": tokens})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def mcp_token_create(request: HttpRequest) -> HttpResponse:
|
||||
if request.method == "POST":
|
||||
form = MCPTokenCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
token, plaintext = MCPToken.objects.create_token(
|
||||
user=request.user,
|
||||
name=form.cleaned_data["name"],
|
||||
allowed_tools=form.cleaned_data.get("allowed_tools") or [],
|
||||
expires_at=form.cleaned_data.get("expires_at") or None,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"mcp_server/tokens/created.html",
|
||||
{"token": token, "plaintext": plaintext},
|
||||
)
|
||||
else:
|
||||
form = MCPTokenCreateForm()
|
||||
|
||||
return render(request, "mcp_server/tokens/create.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def mcp_token_detail(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
||||
return render(request, "mcp_server/tokens/detail.html", {"token": token})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
form = MCPTokenEditForm(request.POST, instance=token)
|
||||
if form.is_valid():
|
||||
instance = form.save(commit=False)
|
||||
instance.allowed_tools = form.cleaned_data.get("allowed_tools") or []
|
||||
instance.save()
|
||||
messages.success(request, "MCP token updated.")
|
||||
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
|
||||
else:
|
||||
form = MCPTokenEditForm(instance=token)
|
||||
|
||||
return render(
|
||||
request, "mcp_server/tokens/edit.html", {"form": form, "token": token}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def mcp_token_revoke(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
||||
token.is_active = False
|
||||
token.save(update_fields=["is_active", "updated_at"])
|
||||
messages.success(request, f"Revoked “{token.name}”. The token can no longer be used.")
|
||||
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def mcp_token_delete(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
||||
name = token.name
|
||||
token.delete()
|
||||
messages.success(request, f"Deleted “{name}”.")
|
||||
return redirect("mcp_server:mcp-token-list")
|
||||
Reference in New Issue
Block a user