Jetons MCP chiffrés au repos + bouton « Copier le jeton » sur /compte/mcp
Les jetons MCP sont désormais chiffrés (Fernet dérivée de SECRET_KEY) en plus du hash conservé pour l'auth, permettant leur ré-affichage/copie à tout moment. Getter scopé au propriétaire, dégradation propre si clé absente/changée. Migration 0013 + colonne token_enc. UI alignée sur « Copier le lien » des vues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,9 @@ MATOMO_TRACKING_ENABLED=true
|
||||
|
||||
# Comptes utilisateurs
|
||||
USERS_DB_PATH=./users.sqlite
|
||||
# Sert aussi à chiffrer les jetons MCP stockés (fonction « Copier le jeton » sur
|
||||
# /compte/mcp). Sa rotation déconnecte les sessions ET rend les jetons existants
|
||||
# non ré-affichables (les utilisateurs devront en régénérer).
|
||||
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
|
||||
APP_BASE_URL=http://localhost:8050
|
||||
|
||||
|
||||
+45
-3
@@ -1,16 +1,21 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
TOKEN_PREFIX = "colibre_"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_enc TEXT,
|
||||
label TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -31,6 +36,19 @@ def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _fernet_key() -> bytes | None:
|
||||
"""Clé Fernet dérivée de SECRET_KEY (séparation de domaine).
|
||||
|
||||
Renvoie None si SECRET_KEY est absent : le jeton n'est alors pas chiffré
|
||||
ni ré-affichable (dégradation propre, sans échec).
|
||||
"""
|
||||
secret = os.getenv("SECRET_KEY")
|
||||
if not secret:
|
||||
return None
|
||||
digest = hashlib.sha256(f"mcp-token-enc:{secret}".encode()).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _connect(db_path):
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
@@ -52,11 +70,13 @@ def create_token(
|
||||
db_path, label: str, user_id: int | None = None, kind: str = "api"
|
||||
) -> tuple[str, int]:
|
||||
token = TOKEN_PREFIX + secrets.token_hex(32)
|
||||
key = _fernet_key()
|
||||
token_enc = Fernet(key).encrypt(token.encode()).decode() if key else None
|
||||
with _connect(db_path) as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (token_hash, label, user_id, kind, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(_hash(token), label, user_id, kind, _utcnow_iso()),
|
||||
"INSERT INTO api_tokens (token_hash, token_enc, label, user_id, kind, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(_hash(token), token_enc, label, user_id, kind, _utcnow_iso()),
|
||||
)
|
||||
conn.commit()
|
||||
return token, cur.lastrowid
|
||||
@@ -71,6 +91,28 @@ def get_token_by_plaintext(db_path, token: str) -> dict | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_token_plaintext_for_user(db_path, token_id: int, user_id: int) -> str | None:
|
||||
"""Déchiffre et renvoie le jeton en clair, uniquement pour son propriétaire.
|
||||
|
||||
Renvoie None si : SECRET_KEY absent, jeton inexistant/appartenant à un autre
|
||||
utilisateur, jamais chiffré (token_enc NULL), ou indéchiffrable (clé changée).
|
||||
"""
|
||||
key = _fernet_key()
|
||||
if key is None:
|
||||
return None
|
||||
with _connect(db_path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT token_enc FROM api_tokens WHERE id = ? AND user_id = ?",
|
||||
(token_id, user_id),
|
||||
).fetchone()
|
||||
if row is None or row["token_enc"] is None:
|
||||
return None
|
||||
try:
|
||||
return Fernet(key).decrypt(row["token_enc"].encode()).decode()
|
||||
except InvalidToken:
|
||||
return None
|
||||
|
||||
|
||||
def revoke_token(db_path, token_id: int) -> None:
|
||||
with _connect(db_path) as conn:
|
||||
conn.execute(
|
||||
|
||||
@@ -78,6 +78,10 @@ _MIGRATIONS: list[tuple[str, str]] = [
|
||||
"0012_add_token_to_saved_views",
|
||||
"ALTER TABLE saved_views ADD COLUMN token TEXT",
|
||||
),
|
||||
(
|
||||
"0013_add_token_enc_to_api_tokens",
|
||||
"ALTER TABLE api_tokens ADD COLUMN token_enc TEXT",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+49
-7
@@ -140,14 +140,48 @@ def prompt_tips():
|
||||
)
|
||||
|
||||
|
||||
def _copy_token_button(token: str):
|
||||
"""Bouton « Copier le jeton » (même design que « Copier le lien » des vues)."""
|
||||
return dcc.Clipboard(
|
||||
content=token,
|
||||
title="Copier le jeton",
|
||||
className="btn btn-outline-secondary btn-sm d-inline-flex "
|
||||
"align-items-center me-2",
|
||||
children=[
|
||||
html.Img(
|
||||
src="/assets/copy.svg",
|
||||
alt="",
|
||||
style={
|
||||
"height": "1em",
|
||||
"verticalAlign": "-0.15em",
|
||||
"marginRight": "0.35em",
|
||||
},
|
||||
),
|
||||
"Copier le jeton",
|
||||
],
|
||||
copied_children="✓ Copié",
|
||||
)
|
||||
|
||||
|
||||
def _token_row(row: dict):
|
||||
statut = "Révoqué" if row["revoked_at"] else "Actif"
|
||||
actions = []
|
||||
if not row["revoked_at"]:
|
||||
actions = [
|
||||
token_plain = row.get("token_plain")
|
||||
if token_plain:
|
||||
actions.append(_copy_token_button(token_plain))
|
||||
else:
|
||||
actions.append(
|
||||
html.Span(
|
||||
"Régénérez pour copier",
|
||||
className="text-muted small me-2",
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
html.Form(
|
||||
method="POST",
|
||||
action=f"/compte/mcp/revoquer/{row['id']}",
|
||||
className="d-inline",
|
||||
children=[
|
||||
_csrf(f"revoke-{row['id']}"),
|
||||
dbc.Button(
|
||||
@@ -159,7 +193,7 @@ def _token_row(row: dict):
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
return html.Tr(
|
||||
[
|
||||
html.Td(row["label"]),
|
||||
@@ -196,17 +230,25 @@ def layout(**_):
|
||||
alerts.append(
|
||||
dbc.Alert(
|
||||
[
|
||||
html.Strong("Votre nouveau jeton (copiez-le maintenant, "),
|
||||
html.Strong("il ne sera plus affiché) :"),
|
||||
html.Strong(
|
||||
"Votre nouveau jeton (vous pourrez le copier à tout "
|
||||
"moment via « Copier le jeton ») :"
|
||||
),
|
||||
html.Pre(html.Code(new_token)),
|
||||
],
|
||||
color="success",
|
||||
)
|
||||
)
|
||||
|
||||
tokens = tokens_db.list_user_tokens(
|
||||
os.environ["USERS_DB_PATH"], current_user.id, "mcp"
|
||||
db_path = os.environ["USERS_DB_PATH"]
|
||||
tokens = tokens_db.list_user_tokens(db_path, current_user.id, "mcp")
|
||||
token_rows = []
|
||||
for r in tokens:
|
||||
row = dict(r)
|
||||
row["token_plain"] = tokens_db.get_token_plaintext_for_user(
|
||||
db_path, row["id"], current_user.id
|
||||
)
|
||||
token_rows.append(_token_row(row))
|
||||
table = (
|
||||
dbc.Table(
|
||||
[
|
||||
@@ -221,7 +263,7 @@ def layout(**_):
|
||||
]
|
||||
)
|
||||
),
|
||||
html.Tbody([_token_row(dict(r)) for r in tokens]),
|
||||
html.Tbody(token_rows),
|
||||
],
|
||||
striped=True,
|
||||
bordered=False,
|
||||
|
||||
@@ -101,3 +101,34 @@ def test_revoke_user_token_already_revoked_returns_false(temp_db):
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
|
||||
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is False
|
||||
|
||||
|
||||
def test_create_token_stores_recoverable_encrypted_token(temp_db, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "s3cr3t-test-key")
|
||||
token, token_id = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp")
|
||||
with sqlite3.connect(str(temp_db)) as conn:
|
||||
enc = conn.execute(
|
||||
"SELECT token_enc FROM api_tokens WHERE id = ?", (token_id,)
|
||||
).fetchone()[0]
|
||||
assert enc is not None
|
||||
assert token not in enc # chiffré, jamais en clair dans la base
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 7) == token
|
||||
|
||||
|
||||
def test_get_token_plaintext_scoped_to_owner(temp_db, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "s3cr3t-test-key")
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 999) is None
|
||||
|
||||
|
||||
def test_get_token_plaintext_none_when_no_key(temp_db, monkeypatch):
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 1) is None
|
||||
|
||||
|
||||
def test_get_token_plaintext_none_after_key_rotation(temp_db, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "key-A")
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
monkeypatch.setenv("SECRET_KEY", "key-B") # rotation → indéchiffrable
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 1) is None
|
||||
|
||||
@@ -44,6 +44,45 @@ def test_prompt_tips_mentions_columns_and_examples():
|
||||
assert "lien" in text # mention du lien vers la fiche marché
|
||||
|
||||
|
||||
def test_token_row_has_copy_button_when_token_available():
|
||||
from src.app import app # noqa: F401
|
||||
from src.pages.compte.mcp import _token_row
|
||||
|
||||
text = str(
|
||||
_token_row(
|
||||
{
|
||||
"id": 42,
|
||||
"label": "l",
|
||||
"created_at": "c",
|
||||
"last_used_at": None,
|
||||
"revoked_at": None,
|
||||
"token_plain": "colibre_abc123",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "Copier le jeton" in text
|
||||
assert "colibre_abc123" in text # contenu du presse-papier (dcc.Clipboard)
|
||||
|
||||
|
||||
def test_token_row_no_copy_button_when_token_unavailable():
|
||||
from src.app import app # noqa: F401
|
||||
from src.pages.compte.mcp import _token_row
|
||||
|
||||
text = str(
|
||||
_token_row(
|
||||
{
|
||||
"id": 42,
|
||||
"label": "l",
|
||||
"created_at": "c",
|
||||
"last_used_at": None,
|
||||
"revoked_at": None,
|
||||
"token_plain": None, # ancien jeton haché, non récupérable
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "Copier le jeton" not in text
|
||||
|
||||
|
||||
def _collect_titles(component):
|
||||
# Parcourt récursivement les AccordionItem pour collecter leurs `title`.
|
||||
found = []
|
||||
|
||||
Reference in New Issue
Block a user