Skip to main content

yggdrasil/api/
mcp_tokens.rs

1//! MCP 访问令牌管理:Dioxus server functions。
2//!
3//! 管理员在后台 `/admin/mcp` 签发/查看/撤销为 AI 客户端(Claude Code / Cursor /
4//! Cline)准备的 bearer 令牌。明文 token 仅在签发与「重新查看」时返回给管理员,
5//! 数据库只存 AES-GCM 密文(`token_enc`,可解密重查)+ SHA-256 哈希(`token_hash`,
6//! 每请求 O(1) 常量查找,见 `src/mcp/auth.rs`)。
7//!
8//! 鉴权走 cookie session(`get_current_admin_user`),与其它后台 server-fn 一致;
9//! MCP 工具路径(bearer)无法调用这些 server-fn——那是 `src/mcp/tools/*` 的职责。
10
11#![allow(clippy::unused_unit, deprecated)]
12
13use dioxus::prelude::*;
14
15#[cfg(feature = "server")]
16use crate::models::mcp_token::McpToken;
17use crate::models::mcp_token::{CreateTokenResponse, McpTokenSummary, TokenScope};
18
19/// 令牌有效期预设:管理员在 UI 上从下拉菜单选择。
20///
21/// 序列化形式供前端选择回传(`days1` / `days7` / `days30` / `days90` / `never`)。
22/// `Never` 对应 `expires_at = NULL`(长期令牌);其余按当前时间 + N 天计算。
23#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
24#[serde(rename_all = "lowercase")]
25pub enum TokenLifetime {
26    /// 1 天(默认推荐:最小权限、轮换友好)。
27    Days1,
28    /// 7 天。
29    Days7,
30    /// 30 天。
31    Days30,
32    /// 90 天。
33    Days90,
34    /// 永不过期(`expires_at = NULL`)。仅用于可信长期客户端。
35    Never,
36}
37
38impl TokenLifetime {
39    /// 计算签发时刻对应的过期时间戳(UTC)。`Never` 返回 `None`。
40    #[cfg(feature = "server")]
41    fn expires_at(self) -> Option<chrono::DateTime<chrono::Utc>> {
42        let now = chrono::Utc::now();
43        match self {
44            TokenLifetime::Days1 => Some(now + chrono::Duration::days(1)),
45            TokenLifetime::Days7 => Some(now + chrono::Duration::days(7)),
46            TokenLifetime::Days30 => Some(now + chrono::Duration::days(30)),
47            TokenLifetime::Days90 => Some(now + chrono::Duration::days(90)),
48            TokenLifetime::Never => None,
49        }
50    }
51}
52
53/// 签发新的 MCP 令牌。
54///
55/// 生成明文 `ygg_<32 hex>`,AES-GCM 加密后存密文 + SHA-256 哈希;明文随响应一次性
56/// 返回给管理员(后续可经 [`reveal_mcp_token`] 重新查看)。仅 admin。
57#[server(CreateMcpToken, "/api")]
58pub async fn create_mcp_token(
59    name: String,
60    scope: TokenScope,
61    lifetime: TokenLifetime,
62) -> Result<CreateTokenResponse, ServerFnError> {
63    #[cfg(feature = "server")]
64    {
65        use crate::api::auth::get_current_admin_user;
66        use crate::api::error::AppError;
67        use crate::db::pool::get_conn;
68        use crate::mcp::auth::{hash_token, TOKEN_PREFIX};
69        use crate::mcp::crypto::encrypt_token;
70
71        let admin = get_current_admin_user().await?;
72
73        // 名称规范化与校验:去空白后非空,限制长度。
74        let name = name.trim().to_string();
75        if name.is_empty() {
76            return Err(AppError::BadRequest("令牌名称不能为空".to_string()).into());
77        }
78        if name.chars().count() > 64 {
79            return Err(AppError::BadRequest("令牌名称过长(上限 64 字符)".to_string()).into());
80        }
81
82        // 加密主密钥必须已配置,否则无法安全存储明文。
83        if crate::mcp::crypto::mcp_enc_key().is_none() {
84            return Err(AppError::Internal("MCP_TOKEN_ENC_KEY 未设置").into());
85        }
86
87        // 明文 token:`ygg_` + 32 字节随机数 hex(64 hex 字符)。
88        let mut bytes = [0u8; 32];
89        rand::TryRng::try_fill_bytes(&mut rand::rngs::SysRng, &mut bytes)
90            .map_err(|_| AppError::Internal("令牌随机数生成失败"))?;
91        let plaintext = format!("{TOKEN_PREFIX}{}", hex::encode(bytes));
92        let hash = hash_token(&plaintext);
93        let enc = encrypt_token(&plaintext).ok_or(AppError::Internal("MCP 令牌加密失败"))?;
94        let id = uuid::Uuid::new_v4();
95        let expires_at = lifetime.expires_at();
96        let scope_str = scope.as_str();
97
98        let client = get_conn().await.map_err(AppError::db_conn)?;
99
100        let row = client
101            .query_one(
102                "INSERT INTO mcp_tokens \
103                    (id, user_id, name, scope, token_enc, token_hash, expires_at) \
104                 VALUES ($1::uuid, $2, $3, $4, $5, $6, $7) \
105                 RETURNING id::text, user_id, name, scope, created_at, expires_at, \
106                           last_used_at, revoked_at",
107                &[&id, &admin.id, &name, &scope_str, &enc, &hash, &expires_at],
108            )
109            .await
110            .map_err(AppError::query)?;
111
112        let token = row_to_mcp_token_meta(&row);
113        Ok(CreateTokenResponse {
114            summary: token.into(),
115            plaintext,
116        })
117    }
118    #[cfg(not(feature = "server"))]
119    unreachable!()
120}
121
122/// 列出当前管理员名下的全部令牌(不含任何密钥材料,仅展示用元数据)。
123///
124/// 按 `created_at DESC` 排序,最近签发的在前。仅 admin。
125#[server(ListMcpTokens, "/api")]
126pub async fn list_mcp_tokens() -> Result<Vec<McpTokenSummary>, ServerFnError> {
127    #[cfg(feature = "server")]
128    {
129        use crate::api::auth::get_current_admin_user;
130        use crate::api::error::AppError;
131        use crate::db::pool::get_conn;
132
133        let admin = get_current_admin_user().await?;
134        let client = get_conn().await.map_err(AppError::db_conn)?;
135
136        let rows = client
137            .query(
138                "SELECT id::text, user_id, name, scope, created_at, expires_at, \
139                        last_used_at, revoked_at \
140                 FROM mcp_tokens \
141                 WHERE user_id = $1 \
142                 ORDER BY created_at DESC",
143                &[&admin.id],
144            )
145            .await
146            .map_err(AppError::query)?;
147
148        Ok(rows
149            .iter()
150            .map(row_to_mcp_token_meta)
151            .map(McpTokenSummary::from)
152            .collect())
153    }
154    #[cfg(not(feature = "server"))]
155    unreachable!()
156}
157
158/// 重新查看令牌明文(可多次调用:明文以密文形式落库,可解密还原)。
159///
160/// 找不到令牌、或令牌不属于当前管理员 → 返回 `None`(不区分原因,避免探测)。
161/// 仅 admin。
162#[server(RevealMcpToken, "/api")]
163pub async fn reveal_mcp_token(id: String) -> Result<Option<String>, ServerFnError> {
164    #[cfg(feature = "server")]
165    {
166        use crate::api::auth::get_current_admin_user;
167        use crate::api::error::AppError;
168        use crate::db::pool::get_conn;
169        use crate::mcp::crypto::decrypt_token;
170
171        let admin = get_current_admin_user().await?;
172        let client = get_conn().await.map_err(AppError::db_conn)?;
173
174        // id 由前端以字符串传入(表 id 列是 uuid):解析失败视作令牌不存在。
175        let id = match uuid::Uuid::parse_str(&id) {
176            Ok(u) => u,
177            Err(_) => return Ok(None),
178        };
179
180        // 仅取属于当前管理员的令牌的密文,避免越权解密他人令牌。
181        let row = client
182            .query_opt(
183                "SELECT token_enc FROM mcp_tokens WHERE id = $1::uuid AND user_id = $2",
184                &[&id, &admin.id],
185            )
186            .await
187            .map_err(AppError::query)?;
188
189        // 解密失败(密钥缺失/密文被篡改)也归一到 None:调用方无法区分,
190        // 按「该令牌不可解密」处理(等同于失效)。
191        Ok(row
192            .map(|r| r.get::<_, String>("token_enc"))
193            .and_then(|enc| decrypt_token(&enc)))
194    }
195    #[cfg(not(feature = "server"))]
196    unreachable!()
197}
198
199/// 撤销令牌(软删除:置 `revoked_at = now()`,行保留以备审计)。
200///
201/// 找不到或非本人令牌 → 静默无操作(不报错,避免探测)。仅 admin。
202#[server(RevokeMcpToken, "/api")]
203pub async fn revoke_mcp_token(id: String) -> Result<(), ServerFnError> {
204    #[cfg(feature = "server")]
205    {
206        use crate::api::auth::get_current_admin_user;
207        use crate::api::error::AppError;
208        use crate::db::pool::get_conn;
209
210        let admin = get_current_admin_user().await?;
211        let client = get_conn().await.map_err(AppError::db_conn)?;
212
213        // id 解析失败视作令牌不存在(静默无操作,避免探测)。
214        let id = match uuid::Uuid::parse_str(&id) {
215            Ok(u) => u,
216            Err(_) => return Ok(()),
217        };
218
219        client
220            .execute(
221                "UPDATE mcp_tokens SET revoked_at = NOW() \
222                 WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL",
223                &[&id, &admin.id],
224            )
225            .await
226            .map_err(AppError::query)?;
227
228        Ok(())
229    }
230    #[cfg(not(feature = "server"))]
231    unreachable!()
232}
233
234/// 把数据库行解析为令牌元数据(不含明文;密文/哈希已 `#[serde(skip)]`,这里置空)。
235///
236/// `scope` 列存的是字符串;非法值(理论不可能,除非手工改库)按 read 兜底并记日志,
237/// 不 panic。
238#[cfg(feature = "server")]
239fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
240    let scope_str: String = row.get("scope");
241    let scope = TokenScope::from_db(&scope_str).unwrap_or_else(|| {
242        tracing::warn!(scope = %scope_str, "mcp_tokens.scope 非法值,兜底为 read");
243        TokenScope::Read
244    });
245    McpToken {
246        id: row.get("id"),
247        user_id: row.get("user_id"),
248        name: row.get("name"),
249        scope,
250        token_enc: String::new(),
251        token_hash: String::new(),
252        created_at: row.get("created_at"),
253        expires_at: row.get("expires_at"),
254        last_used_at: row.get("last_used_at"),
255        revoked_at: row.get("revoked_at"),
256    }
257}
258
259/// 单个客户端配置片段:标题 + 原始文本(供复制)+ syntect 高亮 HTML(供展示)。
260///
261/// 高亮 HTML 由 `crate::highlight::server::highlight_code` 生成(spaced CSS class 风格,
262/// 配合 `public/highlight.css`);前端需将其置于 `.md-content pre code` 作用域下,
263/// 否则高亮 CSS 选择器不匹配(见 `src/bin/generate_highlight_css.rs` 的 base 重写)。
264#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
265pub struct McpConfigSnippet {
266    /// 显示标题(含客户端名与目标文件路径)。
267    pub title: String,
268    /// 原始配置文本(供「复制」按钮复制,未高亮)。
269    pub content: String,
270    /// syntect 高亮后的 HTML(`<span>` 序列,无 `<pre>/<code>` 外壳)。
271    pub content_html: String,
272}
273
274/// 各客户端 MCP 配置片段集合。
275///
276/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
277/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控);这里把每个片段的
278/// 原始文本与高亮 HTML 打包为可两端共享的 DTO,让 WASM 前端单次请求即可渲染带高亮的配置块。
279#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
280pub struct McpClientConfigs {
281    /// 有序配置片段(标题、原始文本、高亮 HTML)。
282    pub snippets: Vec<McpConfigSnippet>,
283}
284
285/// 根据明文令牌生成各客户端配置片段(含 syntect 高亮 HTML)。
286///
287/// 配置生成与高亮均在服务端完成(`crate::mcp::config` / `crate::highlight` 均为
288/// server-only),返回给前端展示。`APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
289#[server(GetMcpClientConfigs, "/api")]
290pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
291    #[cfg(feature = "server")]
292    {
293        use crate::api::auth::get_current_admin_user;
294        use crate::highlight::server::highlight_code;
295
296        let _admin = get_current_admin_user().await?;
297        let c = crate::mcp::config::generate_client_configs(
298            &crate::mcp::config::base_url_from_env(),
299            &token,
300        );
301        // (标题, 内容, 语言):JSON 配置用 json 语法高亮,CLI 一行命令用 bash。
302        let entries: [(&str, String, &str); 7] = [
303            (
304                "Oh-My-Pi(项目根 .mcp.json / ~/.omp/agent/mcp.json 或 ~/.mcp.json)",
305                c.omp_json,
306                "json",
307            ),
308            (
309                "OpenCode(~/.config/opencode/opencode.json 或项目根 opencode.json)",
310                c.opencode_json,
311                "json",
312            ),
313            (
314                "Claude Code(.mcp.json / ~/.claude.json)",
315                c.claude_code_json,
316                "json",
317            ),
318            ("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
319            ("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
320            ("通用(单 server entry)", c.generic_json, "json"),
321            ("Claude Code CLI", c.claude_cli, "bash"),
322        ];
323        let snippets = entries
324            .into_iter()
325            .map(|(title, content, lang)| McpConfigSnippet {
326                title: title.to_string(),
327                content_html: highlight_code(&content, Some(lang)),
328                content,
329            })
330            .collect();
331        Ok(McpClientConfigs { snippets })
332    }
333    #[cfg(not(feature = "server"))]
334    unreachable!()
335}
336
337#[cfg(all(test, feature = "server"))]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn lifetime_expires_at_days() {
343        let now = chrono::Utc::now();
344        let d1 = TokenLifetime::Days1.expires_at().unwrap();
345        let d7 = TokenLifetime::Days7.expires_at().unwrap();
346        assert!(d1 > now);
347        assert!(d7 > d1);
348        // 7 天与 1 天的差应≈6 天(容忍微量时钟漂移)。
349        let delta = (d7 - d1).num_seconds() as f64 / 86400.0;
350        assert!((5.9..6.1).contains(&delta));
351    }
352
353    #[test]
354    fn lifetime_never_is_none() {
355        assert!(TokenLifetime::Never.expires_at().is_none());
356    }
357
358    #[test]
359    fn lifetime_serde_roundtrip() {
360        let json = serde_json::to_string(&TokenLifetime::Days30).unwrap();
361        assert_eq!(json, "\"days30\"");
362        let back: TokenLifetime = serde_json::from_str(&json).unwrap();
363        assert_eq!(back, TokenLifetime::Days30);
364    }
365}