- 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 双目标编译通过。
164 lines
6.2 KiB
Rust
164 lines
6.2 KiB
Rust
//! 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 id(String,与 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 计数。超限返回 429(Too 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(),
|
||
})
|
||
}
|