yggdrasil/src/mcp/auth.rs
xfy 45356c3766 feat(mcp): 端点加固 — token 限流、last_used_at 节流、审计日志(T6)
- token-keyed 限流:MCP_LIMITER 按 token_id 计数(与 web 的 IP-keyed 限流隔离),
  默认 10/s burst 30,经 RATE_LIMIT_MCP_PER_SEC/_BURST 可调,超限返回 429。
- last_used_at 节流:仅在 NULL 或距上次 ≥ 60s 时写库,避免高频请求每次 UPDATE。
  resolve_principal 查询带出 last_used_at 做窗口判断。
- 审计日志:鉴权中间件记录每次已认证 MCP 请求(user_id/scope/token_id),
  为自主 AI 客户端的活动留下事后追溯痕迹。
- 输出清理:经设计保证——JSON 序列化转义控制字符,搜索结果硬上限 50,
  rmcp 默认 4MiB 请求体上限兜底。间接提示注入靠作用域隔离 + 客户端人工审批。

验证:clippy --all-features -D warnings 干净;659 单测 + 1 集成全绿;web/server 双目标编译通过。
2026-07-28 11:57:00 +08:00

164 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! MCP 请求鉴权bearer token → 已认证主体。
//!
//! 流程(每请求):
//! 1. axum `from_fn` 中间件 [`mcp_auth_middleware`] 解析 `Authorization: Bearer ygg_...`
//! 2. SHA-256 哈希后在 `mcp_tokens` 表常量查找未撤销、未过期的行;
//! 3. 命中则把 [`McpPrincipal`] { user_id, scope, token_id } 注入 request.extensions
//! 并异步刷新 `last_used_at`
//! 4. 未命中/缺失 → 401不区分原因避免探测
//!
//! Origin→403、协议版本头、体积上限由 rmcp 的 `StreamableHttpServerConfig` 内置,
//! 本模块只管 bearer 鉴权。`McpPrincipal` 经 rmcp 的 `Extension<http::request::Parts>`
//! 提取器在工具内读取(见 `server.rs`)。
use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use sha2::{Digest, Sha256};
use crate::db::pool::get_conn;
use crate::models::mcp_token::TokenScope;
/// Bearer token 前缀(明文形式以 `ygg_` 起头,便于人眼识别与日志脱敏)。
pub const TOKEN_PREFIX: &str = "ygg_";
/// 已认证主体:由中间件注入 request.extensions工具经 Extension 提取器读取。
#[derive(Clone, Debug)]
pub struct McpPrincipal {
pub user_id: i32,
pub scope: TokenScope,
/// 令牌 DB idString与 model 一致;用于审计/last_used_at 刷新)。
pub token_id: String,
}
/// 从 Authorization 头解析 bearer 明文(去前缀后返回完整 token 字符串)。
/// 非 bearer、缺前缀、解码失败统一返回 None。
pub fn extract_bearer(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
let scheme = raw.strip_prefix("Bearer ")?.trim();
if scheme.starts_with(TOKEN_PREFIX) {
Some(scheme.to_string())
} else {
None
}
}
/// 明文 token → SHA-256 hex与 mcp_tokens.token_hash 列一致,用于 DB 查找)。
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
/// MCP 鉴权中间件:无 bearer 或 token 无效 → 401否则注入 McpPrincipal。
///
/// 注意:这是 T1 的最小实现——每请求同步查库 + 同步更新 last_used_at。
/// T6 会把 last_used_at 刷新改为节流(批量/惰性)以减负,鉴权查询本身保持同步
/// (这是认证的必要代价,无法乐观)。
/// MCP 限流:按 token 计数的 governor 桶(与 web 的 IP-keyed 限流隔离)。
///
/// key 是 token_id而非 user_id同一用户的多个令牌各自有独立配额
/// 避免一个泄露的高频令牌耗尽其它令牌的额度。阈值经
/// RATE_LIMIT_MCP_PER_SEC / RATE_LIMIT_MCP_BURST 可调(默认 10/s, burst 30
static MCP_LIMITER: std::sync::LazyLock<governor::DefaultKeyedRateLimiter<String>> =
std::sync::LazyLock::new(|| {
governor::RateLimiter::keyed(
governor::Quota::per_second(mcp_rate_per_sec())
.allow_burst(mcp_rate_burst()),
)
});
fn mcp_rate_per_sec() -> std::num::NonZeroU32 {
let v = std::env::var("RATE_LIMIT_MCP_PER_SEC")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(10);
std::num::NonZeroU32::new(v.max(1)).expect("v.max(1) 保证非零")
}
fn mcp_rate_burst() -> std::num::NonZeroU32 {
let v = std::env::var("RATE_LIMIT_MCP_BURST")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(30);
std::num::NonZeroU32::new(v.max(1)).expect("v.max(1) 保证非零")
}
/// `last_used_at` 刷新节流:同一令牌的 UPDATE 至少间隔 60s避免高频请求
/// 每次都写库。窗口外才刷新窗口内跳过best-effort失败静默
const LAST_USED_REFRESH_SECS: i64 = 60;
pub async fn mcp_auth_middleware(
mut req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let token = match extract_bearer(req.headers()) {
Some(t) => t,
None => return Err(StatusCode::UNAUTHORIZED),
};
let principal = resolve_principal(&token)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
// 限流:按 token_id 计数。超限返回 429Too Many Requests
if MCP_LIMITER.check_key(&principal.token_id).is_err() {
return Err(StatusCode::TOO_MANY_REQUESTS);
}
// 审计:记录每次已认证的 MCP 请求token_id + scope + user_id
// MCP 令牌是自主 AI 客户端,记录其活动有安全价值(事后可追溯滥用)。
tracing::info!(
user_id = principal.user_id,
scope = principal.scope.as_str(),
token_id = %principal.token_id,
"mcp request authenticated"
);
req.extensions_mut().insert(principal);
Ok(next.run(req).await)
}
/// 查库解析 token → 主体;未撤销、未过期才返回 Some。
async fn resolve_principal(token: &str) -> Option<McpPrincipal> {
let hash = hash_token(token);
let client = get_conn().await.ok()?;
// 一次查询取出 + 校验所有条件,并带出 last_used_at 供节流判断。
let row = client
.query_opt(
"SELECT id, user_id, scope, expires_at, revoked_at, last_used_at
FROM mcp_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())",
&[&hash],
)
.await
.ok()??;
let token_id: uuid::Uuid = row.get(0);
let user_id: i32 = row.get(1);
let scope_str: &str = row.get(2);
let scope = TokenScope::from_db(scope_str)?;
let last_used: Option<chrono::DateTime<chrono::Utc>> = row.get(5);
// 节流刷新 last_used_at仅在 NULL 或距上次 ≥ 60s 时写库,避免高频请求每次 UPDATE。
let needs_refresh = last_used
.map(|t| (chrono::Utc::now() - t).num_seconds() >= LAST_USED_REFRESH_SECS)
.unwrap_or(true);
if needs_refresh {
let _ = client
.execute(
"UPDATE mcp_tokens SET last_used_at = NOW() WHERE id = $1",
&[&token_id],
)
.await;
}
Some(McpPrincipal {
user_id,
scope,
token_id: token_id.to_string(),
})
}