Skip to main content

yggdrasil/mcp/
auth.rs

1//! MCP 请求鉴权:bearer token → 已认证主体。
2//!
3//! 流程(每请求):
4//! 1. axum `from_fn` 中间件 [`mcp_auth_middleware`] 解析 `Authorization: Bearer ygg_...`;
5//! 2. SHA-256 哈希后在 `mcp_tokens` 表常量查找未撤销、未过期的行,并 JOIN `users`
6//!    要求持有人当前角色仍允许该作用域(见 [`role_can_hold_scope`]);
7//! 3. 命中则把 [`McpPrincipal`] { user_id, scope, token_id } 注入 request.extensions,
8//!    并异步刷新 `last_used_at`;
9//! 4. 未命中/缺失 → 401(不区分原因,避免探测)。
10//!
11//! Origin→403、协议版本头、体积上限由 rmcp 的 `StreamableHttpServerConfig` 内置,
12//! 本模块只管 bearer 鉴权。`McpPrincipal` 经 rmcp 的 `Extension<http::request::Parts>`
13//! 提取器在工具内读取(见 `server.rs`)。
14
15use axum::body::Body;
16use axum::http::{HeaderMap, Request, StatusCode};
17use axum::middleware::Next;
18use axum::response::Response;
19
20use crate::api::rate_limit::nz;
21use crate::db::pool::get_conn;
22use crate::models::mcp_token::TokenScope;
23use crate::models::user::UserRole;
24
25/// Bearer token 前缀(明文形式以 `ygg_` 起头,便于人眼识别与日志脱敏)。
26pub const TOKEN_PREFIX: &str = "ygg_";
27
28/// 已认证主体:由中间件注入 request.extensions,工具经 Extension 提取器读取。
29#[derive(Clone, Debug)]
30pub struct McpPrincipal {
31    pub user_id: i32,
32    pub scope: TokenScope,
33    /// 令牌 DB id(String,与 model 一致;用于审计/last_used_at 刷新)。
34    pub token_id: String,
35}
36
37/// 从 Authorization 头解析 bearer 明文(去前缀后返回完整 token 字符串)。
38/// 非 bearer、缺前缀、解码失败统一返回 None。
39pub fn extract_bearer(headers: &HeaderMap) -> Option<String> {
40    let raw = headers
41        .get(axum::http::header::AUTHORIZATION)?
42        .to_str()
43        .ok()?;
44    let scheme = raw.strip_prefix("Bearer ")?.trim();
45    if scheme.starts_with(TOKEN_PREFIX) {
46        Some(scheme.to_string())
47    } else {
48        None
49    }
50}
51
52/// 明文 token → SHA-256 hex(与 mcp_tokens.token_hash 列一致,用于 DB 查找)。
53pub fn hash_token(token: &str) -> String {
54    crate::utils::server::sha256_hex(token)
55}
56
57/// MCP 限流:按 token 计数的 governor 桶(与 web 的 IP-keyed 限流隔离)。
58///
59/// key 是 token_id(而非 user_id):同一用户的多个令牌各自有独立配额,
60/// 避免一个泄露的高频令牌耗尽其它令牌的额度。阈值来自 settings 表
61/// `ratelimit_mcp_*`(后台设置 → 接口限流,重启生效;env `RATE_LIMIT_MCP_*`
62/// 仅在键缺失时首启播种)。键是已通过鉴权的 token_id,集合有界,无需 GC。
63static MCP_LIMITER: std::sync::LazyLock<governor::DefaultKeyedRateLimiter<String>> =
64    std::sync::LazyLock::new(|| {
65        let c = crate::config::rate_limit();
66        governor::RateLimiter::keyed(
67            governor::Quota::per_second(nz(c.mcp_per_sec)).allow_burst(nz(c.mcp_burst)),
68        )
69    });
70
71/// MCP 上传专用限流(与 MCP_LIMITER 隔离):上传是重操作(转码 + 落盘),
72/// 单独配额避免一个高频令牌的普通请求耗尽上传额度,反之亦然。
73/// 阈值来自 settings 表 `ratelimit_mcp_upload_*`(后台设置 → 接口限流,重启生效;
74/// env `RATE_LIMIT_MCP_UPLOAD_*` 仅在键缺失时首启播种)。
75static MCP_UPLOAD_LIMITER: std::sync::LazyLock<governor::DefaultKeyedRateLimiter<String>> =
76    std::sync::LazyLock::new(|| {
77        let c = crate::config::rate_limit();
78        governor::RateLimiter::keyed(
79            governor::Quota::per_second(nz(c.mcp_upload_per_sec))
80                .allow_burst(nz(c.mcp_upload_burst)),
81        )
82    });
83
84/// bearer 端点专用:按 token_id 检查上传配额。超限返回 Err(脱敏消息)。
85pub(crate) fn check_mcp_upload_limit(token_id: &str) -> Result<(), &'static str> {
86    // governor 的 keyed 限流器要求 &String 键;token_id 是 &str,转一次。
87    let key = token_id.to_string();
88    if MCP_UPLOAD_LIMITER.check_key(&key).is_err() {
89        return Err("上传过于频繁,请稍后再试");
90    }
91    Ok(())
92}
93
94/// `last_used_at` 刷新节流:同一令牌的 UPDATE 至少间隔 60s,避免高频请求
95/// 每次都写库。窗口外才刷新,窗口内跳过(best-effort,失败静默)。
96const LAST_USED_REFRESH_SECS: i64 = 60;
97
98/// MCP 鉴权中间件:无 bearer 或 token 无效 → 401;否则注入 McpPrincipal。
99///
100/// 注意:这是 T1 的最小实现——每请求同步查库 + 同步更新 last_used_at。
101/// T6 会把 last_used_at 刷新改为节流(批量/惰性)以减负,鉴权查询本身保持同步
102/// (这是认证的必要代价,无法乐观)。
103pub async fn mcp_auth_middleware(
104    mut req: Request<Body>,
105    next: Next,
106) -> Result<Response, StatusCode> {
107    let token = match extract_bearer(req.headers()) {
108        Some(t) => t,
109        None => return Err(StatusCode::UNAUTHORIZED),
110    };
111    // 解析令牌要查库:先按 IP 限流,伪造令牌洪泛不再无节制占用连接池。
112    // 未配置可信代理时按 TCP 对端(startup 注入的 ConnectInfo)计数,不能落到共享的 "unknown" 桶。
113    let peer = req
114        .extensions()
115        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
116        .map(|axum::extract::ConnectInfo(addr)| *addr);
117    let ip = crate::api::rate_limit::get_client_ip_with_peer(req.headers(), peer).await;
118    if crate::api::rate_limit::check_mcp_preauth_limit(&ip).is_err() {
119        return Err(StatusCode::TOO_MANY_REQUESTS);
120    }
121    let principal = resolve_principal(&token)
122        .await
123        .ok_or(StatusCode::UNAUTHORIZED)?;
124
125    // 限流:按 token_id 计数。超限返回 429(Too Many Requests)。
126    if MCP_LIMITER.check_key(&principal.token_id).is_err() {
127        return Err(StatusCode::TOO_MANY_REQUESTS);
128    }
129
130    // 审计:记录每次已认证的 MCP 请求(token_id + scope + user_id)。
131    // MCP 令牌是自主 AI 客户端,记录其活动有安全价值(事后可追溯滥用)。
132    tracing::info!(
133        user_id = principal.user_id,
134        scope = principal.scope.as_str(),
135        token_id = %principal.token_id,
136        "mcp request authenticated"
137    );
138
139    req.extensions_mut().insert(principal);
140    Ok(next.run(req).await)
141}
142
143/// 令牌持有人的当前角色是否仍允许持有该作用域的令牌。
144///
145/// 与签发规则一致:只有管理员能签发令牌(任意作用域),被封禁的用户不能
146/// 持有任何令牌。账号被封禁或降级后,名下令牌在下一次请求时即失效,
147/// 不需要逐个手工吊销。
148pub(crate) fn role_can_hold_scope(role: &UserRole, scope: TokenScope) -> bool {
149    match (role, scope) {
150        (UserRole::Admin, TokenScope::Read | TokenScope::Write | TokenScope::Admin) => true,
151        (UserRole::Blocked, _) => false,
152    }
153}
154
155/// 查库解析 token → 主体;未撤销、未过期且持有人角色仍允许该作用域才返回 Some。
156pub(crate) async fn resolve_principal(token: &str) -> Option<McpPrincipal> {
157    let hash = hash_token(token);
158    let client = get_conn().await.ok()?;
159    // 一次查询取出 + 校验所有条件(含持有人当前角色),并带出 last_used_at 供节流判断。
160    let row = client
161        .query_opt(
162            "SELECT t.id, t.user_id, t.scope, t.last_used_at, u.role
163             FROM mcp_tokens t
164             JOIN users u ON u.id = t.user_id
165             WHERE t.token_hash = $1
166               AND t.revoked_at IS NULL
167               AND (t.expires_at IS NULL OR t.expires_at > NOW())",
168            &[&hash],
169        )
170        .await
171        .ok()??;
172
173    let token_id: uuid::Uuid = row.get(0);
174    let user_id: i32 = row.get(1);
175    let scope_str: &str = row.get(2);
176    let scope = TokenScope::from_db(scope_str)?;
177    let last_used: Option<chrono::DateTime<chrono::Utc>> = row.get(3);
178    let role = UserRole::from_str(row.get(4))?;
179    if !role_can_hold_scope(&role, scope) {
180        return None;
181    }
182
183    // 节流刷新 last_used_at:仅在 NULL 或距上次 ≥ 60s 时写库,避免高频请求每次 UPDATE。
184    let needs_refresh = last_used
185        .map(|t| (chrono::Utc::now() - t).num_seconds() >= LAST_USED_REFRESH_SECS)
186        .unwrap_or(true);
187    if needs_refresh {
188        let _ = client
189            .execute(
190                "UPDATE mcp_tokens SET last_used_at = NOW() WHERE id = $1",
191                &[&token_id],
192            )
193            .await;
194    }
195
196    Some(McpPrincipal {
197        user_id,
198        scope,
199        token_id: token_id.to_string(),
200    })
201}
202/// bearer 端点鉴权:从 Authorization 头解析 bearer → 查库解析主体。
203///
204/// 供 `/api/mcp/upload` 等不在 `/mcp` 中间件链上的端点复用——这些端点
205/// 自行做 bearer 鉴权(不经 rmcp,不走 McpPrincipal→extensions 注入路径)。
206/// 失败统一返回 401(不区分缺失/无效/过期,避免 token 探测)。
207pub(crate) async fn resolve_bearer_principal(
208    headers: &HeaderMap,
209) -> Result<McpPrincipal, StatusCode> {
210    let token = extract_bearer(headers).ok_or(StatusCode::UNAUTHORIZED)?;
211    resolve_principal(&token)
212        .await
213        .ok_or(StatusCode::UNAUTHORIZED)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn admin_role_keeps_every_token_scope() {
222        for scope in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
223            assert!(role_can_hold_scope(&UserRole::Admin, scope), "{scope:?}");
224        }
225    }
226
227    #[test]
228    fn blocked_role_loses_every_token_scope() {
229        for scope in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
230            assert!(!role_can_hold_scope(&UserRole::Blocked, scope), "{scope:?}");
231        }
232    }
233}