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