Skip to main content

futu_rest/
auth.rs

1//! REST API 的 Bearer Token 鉴权
2//!
3//! 两种模式:
4//! - **未配置 KeyStore**:只读 `/api/*` 保持 legacy 无鉴权;写交易/admin 路径
5//!   仍返回 401(v1.4.86 SEC-003 Q4)
6//! - **配置了 KeyStore**:所有 `/api/*` 请求必须带 `Authorization: Bearer <plaintext>`,
7//!   且对应 key 必须满足 route 对应的 scope
8//!
9//! 路由 → scope 映射由 `futu-surface-spec` 的 `EndpointSpec` 派生,未知
10//! `/api/*` path fail-closed。新增 REST route 必须先登记 EndpointSpec,
11//! 这样 CLI / REST / MCP / Gateway / gRPC 的可见性和权限契约才能共用同一
12//! source of truth。
13//!
14//! 写类交易 endpoint 在 middleware 层使用 `Scope::Trade` super-scope,
15//! 允许 `trade:real` / `trade:simulate` / `trade:unlock` 进入对应 handler;
16//! handler 层再依据解码后的请求做真实 env / unlock 二次校验。
17
18use std::sync::Arc;
19
20use axum::Json;
21use axum::body::Body;
22use axum::extract::State;
23use axum::extract::connect_info::ConnectInfo;
24use axum::http::{Request, StatusCode};
25use axum::middleware::Next;
26use axum::response::{IntoResponse, Response};
27use futu_auth::{KeyStore, RuntimeCounters, Scope};
28
29/// REST auth middleware 的组合 state:KeyStore(谁能进)+ RuntimeCounters(限额)
30///
31/// 从 v1.0 起 middleware 除了 scope 检查还会在 `trade:real` 请求上跑一次
32/// `check_and_commit` —— CheckCtx 里 market/symbol/side/value 全空,只挂
33/// rate limit + 时段窗口两个全局闸门。精细化检查(daily / per_order /
34/// side / 具体 market)留给下游 handler。
35#[derive(Clone)]
36pub struct AuthState {
37    /// keys.json 热可替换 key store(共享同一 [`KeyStore`] 确保 /reload 生效)
38    pub key_store: Arc<KeyStore>,
39    /// 日累计 / 速率窗口 / rate-limit 的全局计数器;REST / gRPC / MCP 应共用
40    /// 同一实例才能保证限额跨接口一致
41    pub counters: Arc<RuntimeCounters>,
42}
43
44impl AuthState {
45    /// 构造 AuthState。`key_store` 和 `counters` 都是 [`Arc`] 共享,调用方
46    /// 负责在多个接口(REST / gRPC / MCP)之间保持同一实例。
47    pub fn new(key_store: Arc<KeyStore>, counters: Arc<RuntimeCounters>) -> Self {
48        Self {
49            key_store,
50            counters,
51        }
52    }
53}
54
55/// 根据 URI 路径推断所需 scope
56///
57/// **Fail-closed**:未知的 `/api/*` 路径返回 None,middleware 会拒绝请求。
58/// REST path 只从 `futu-surface-spec` 的 EndpointSpec 派生;新增 REST route
59/// 必须先声明 spec,否则 cross-surface invariant 会失败。
60fn scope_for_path(path: &str) -> Option<Scope> {
61    futu_surface_spec::lookup_endpoint_by_rest_path(path).map(rest_scope_for_spec)
62}
63
64fn rest_scope_for_spec(spec: &'static futu_surface_spec::EndpointSpec) -> Scope {
65    match spec.runtime.scope {
66        // REST keeps a trade super-scope at middleware level so trade:real,
67        // trade:simulate, and trade:unlock keys can reach the handler, where
68        // env-specific checks still happen against the decoded request.
69        Scope::TradeReal | Scope::TradeSimulate => {
70            if spec.runtime.side_effects == futu_surface_spec::SideEffectKind::Write {
71                Scope::Trade
72            } else {
73                spec.runtime.scope
74            }
75        }
76        other => other,
77    }
78}
79
80/// v1.4.90 P1-A: scope satisfaction check.
81///
82/// - `needed = Scope::Trade` (super-scope) → held 含 `trade_super_members`
83///   任一即过 (TradeReal / TradeSimulate / TradeUnlock).
84/// - 其他 needed → 严格 `held.contains(&needed)`.
85///
86/// **不要**把 `Scope::Trade` 写进 keys.json 真实持有 set —— super-scope
87/// 仅作 needed 侧占位语义。如果一把 key 持有 `Scope::Trade`(理论上不
88/// 应该),它也只能"匹配 needed=Scope::Trade"路径,不会绕过严格 scope。
89///
90/// v1.4.104 阶段 5: 真实 middleware 路径已委托给 `futu_auth_pipeline` 的
91/// 内部 `scope_satisfied`. 本地 fn 仅供 unit test (`v1_4_90_scope_satisfied_*`)
92/// 验证语义不变.
93#[cfg(test)]
94fn scope_satisfied(held: &std::collections::HashSet<Scope>, needed: Scope) -> bool {
95    if needed == Scope::Trade {
96        return Scope::trade_super_members()
97            .iter()
98            .any(|s| held.contains(s));
99    }
100    held.contains(&needed)
101}
102
103/// v1.4.86 SEC-003 Q4: path 是否属于 "mutating write" 类 (legacy 模式下必须
104/// 拦截). 返 true = 强制要求 auth, 不走 legacy fall-through.
105///
106/// 当前包含:
107/// - trade:real (下单 / 改单 / 撤单 / 解锁 / reconfirm)
108/// - admin (shutdown / reload / status — status 虽然 read-only 但含 daemon
109///   内部状态, legacy 下也不应暴露给任意 local process)
110fn is_mutating_write_path(path: &str) -> bool {
111    // v1.4.90 P1-A: TRADE 列现返 super-scope `Scope::Trade`,原 TradeReal/
112    // TradeSimulate 直接经路径表已不会出现,但保留兼容判断防未来漂移。
113    // v1.4.104 codex F1 P1: /api/unlock-trade 现单独 TradeUnlock, 仍属 mutating.
114    matches!(
115        scope_for_path(path),
116        Some(Scope::Trade)
117            | Some(Scope::TradeReal)
118            | Some(Scope::TradeSimulate)
119            | Some(Scope::TradeUnlock)
120            | Some(Scope::Admin)
121    )
122}
123
124/// axum middleware:Bearer Token + scope 校验
125///
126/// **v1.4.86 SEC-003 Q4 真 fix**: legacy 模式 (未配 keys.json) 下, **仍然
127/// 拦截** mutating endpoint (place-order / modify-order / cancel-all-order /
128/// unlock-trade / reconfirm-order / admin/*) 未经 auth 的访问. 只读 endpoint
129/// (行情 / 账户 read-only) 继续 legacy 允许 (backward compat 大部分用户).
130///
131/// 理由: 本机任何 skill / agent / 脚本可以无 auth `curl POST /api/order` 下单,
132/// 这是安全风险. v1.4.84 stderr warn 不够, v1.4.86 作硬门禁.
133///
134/// ## v1.4.104 阶段 5: pipeline 委托
135///
136/// transport-only 逻辑 (legacy mutating-block / `/api/*` 路由 / Bearer 头解析 /
137/// 404 unknown route / KeyRecord 注入 extensions) 仍在本地. **scope 检查 +
138/// expiry + super-scope semantics + rate gate + audit emit** 全 委托给
139/// [`futu_auth_pipeline::authenticate_request`] (跨 surface 共享同一份).
140/// LoC 减 ~80 行. 行为 byte-identical:
141/// - 401 Unauthenticated (含 `WWW-Authenticate` header) on missing Bearer
142/// - 401 on invalid/expired key (pipeline reason)
143/// - 404 on unknown `/api/*` route (REST-specific fail-closed)
144/// - 403 generic "forbidden" body on scope miss / acc_id whitelist (BUG-011 不泄 key_id/scope)
145/// - 429 with limit reason on rate fail
146pub async fn bearer_auth(
147    State(auth): State<AuthState>,
148    mut req: Request<Body>,
149    next: Next,
150) -> Response {
151    use futu_auth_pipeline::{
152        AuthDecision, AuthEnvelope, Credential, Endpoint, SurfaceId, authenticate_request,
153    };
154
155    let path = req.uri().path().to_string();
156    let legacy_mode = !auth.key_store.is_configured();
157    let audit_ctx = audit_context_from_request(&req);
158
159    // ── Step 1: Legacy mode + mutating-write block (REST 专属, v1.4.86 SEC-003 Q4) ─
160    if legacy_mode {
161        if is_mutating_write_path(&path) {
162            audit(
163                &audit_ctx,
164                &path,
165                None,
166                "reject",
167                "legacy mode (no keys.json) blocks mutating endpoint",
168            );
169            return (
170                StatusCode::UNAUTHORIZED,
171                [("www-authenticate", "Bearer realm=\"futu-rest\"")],
172                Json(serde_json::json!({
173                    "error": format!(
174                        "mutating endpoint {path:?} requires API key. \
175                         Run `futucli gen-key --id my-key --scopes trade:real` to \
176                         create one, then `--rest-keys-file /path/to/keys.json` \
177                         on daemon restart."
178                    ),
179                    "hint": "legacy no-auth mode only allows read-only endpoints. \
180                            See https://www.futuapi.com/guide/auth/"
181                })),
182            )
183                .into_response();
184        }
185        // legacy + read-only → 放行 (backward compat)
186        return next.run(req).await;
187    }
188
189    // ── Step 2: 非 /api 路由 (含 /ws / /health / /metrics) 不走 auth middleware
190    if !path.starts_with("/api/") {
191        return next.run(req).await;
192    }
193
194    // ── Step 3: 提取 Bearer token (REST-specific 401 + WWW-Authenticate) ─────────
195    //
196    // v1.4.90 P2-G: scheme 大小写不敏感 (RFC 7235 §2.1).
197    // v1.4.104 阶段 7-3: 走 `futu_auth_pipeline::parse_bearer_scheme` 共享 helper
198    // (4 surface 同源, gRPC / WS / REST / MCP 一致解析).
199    let token = req
200        .headers()
201        .get("authorization")
202        .and_then(|v| v.to_str().ok())
203        .and_then(|v| futu_auth_pipeline::parse_bearer_scheme(v).map(|t| t.to_string()));
204
205    let Some(token) = token else {
206        audit(
207            &audit_ctx,
208            &path,
209            None,
210            "reject",
211            "missing Authorization: Bearer",
212        );
213        return (
214            StatusCode::UNAUTHORIZED,
215            [("www-authenticate", "Bearer realm=\"futu-rest\"")],
216            Json(serde_json::json!({ "error": "missing Authorization: Bearer <api-key>" })),
217        )
218            .into_response();
219    };
220
221    // ── Step 4: Unknown /api route → 404 fail-closed (REST-specific UX) ─────────
222    //
223    // 在 pipeline 之前做这个检查, 防止 pipeline 用 needed_scope=None 误放行 unknown
224    // route (pipeline 的 None scope 视作 "公开 endpoint", 但 REST 把它当 unknown).
225    let Some(needed) = scope_for_path(&path) else {
226        // 注意 401/403 都不返: key 是有效的, 只是接口未知; 避免泄漏 "接口是否
227        // 存在" 信息. 这里需要先 verify key 才能记 audit, 但不需要 scope check.
228        let key_id = auth
229            .key_store
230            .verify(&token)
231            .map(|r| r.id.clone())
232            .unwrap_or_else(|| "<invalid>".to_string());
233        audit(
234            &audit_ctx,
235            &path,
236            Some(&key_id),
237            "reject",
238            "unknown /api route",
239        );
240        return (
241            StatusCode::NOT_FOUND,
242            Json(serde_json::json!({
243                "error": format!("unknown API route {path:?}")
244            })),
245        )
246            .into_response();
247    };
248
249    // ── Step 5: Pipeline auth (scope + expiry + super-scope + rate + audit) ─────
250    let env = AuthEnvelope {
251        surface: SurfaceId::Rest,
252        endpoint: Endpoint::HttpPath(&path),
253        needed_scope: Some(needed),
254        credential: Credential::Bearer(&token),
255        proto_id: None, // REST middleware 层尚未解析 body, 不做 body-aware
256        body: &[],
257        explicit_acc_id: None,
258        explicit_ctx: None,
259        commit_rate: true, // REST middleware 层是 trade rate 闸门唯一一处
260        audit_emit: true,
261    };
262
263    let rec = match futu_auth::audit::with_context(audit_ctx.clone(), || {
264        authenticate_request(&auth.key_store, &auth.counters, env)
265    }) {
266        AuthDecision::Allow { rec, .. } => rec, // pipeline 已 audit allow
267        AuthDecision::Reject { kind, reason, .. } => {
268            // pipeline 已 audit reject; v1.4.106 D1 5a: 走 SurfaceAdapter trait
269            // (RestAdapter::translate_reject), 跨 surface 一致.
270            use futu_auth_pipeline::SurfaceAdapter;
271            return RestAdapter::translate_reject(kind, reason);
272        }
273    };
274
275    // v1.2: KeyRecord 塞 request extensions, 下游 handler 用 `Extension<Arc<KeyRecord>>`
276    // 取出来跑 handler 层 full CheckCtx (acc_id / market / value 等细粒度).
277    if let Some(rec) = rec {
278        req.extensions_mut().insert(rec);
279    }
280
281    next.run(req).await
282}
283
284fn audit_context_from_request(req: &Request<Body>) -> futu_auth::audit::AuditContext {
285    let remote_addr = req
286        .extensions()
287        .get::<ConnectInfo<std::net::SocketAddr>>()
288        .map(|ConnectInfo(addr)| addr.to_string());
289    let session_id = header_str(req, "x-request-id")
290        .or_else(|| header_str(req, "x-futu-session-id"))
291        .map(str::to_string);
292    futu_auth::audit::AuditContext::new(remote_addr.as_deref(), session_id.as_deref())
293}
294
295fn header_str<'a>(req: &'a Request<Body>, name: &str) -> Option<&'a str> {
296    let value = req.headers().get(name)?.to_str().ok()?.trim();
297    if value.is_empty() { None } else { Some(value) }
298}
299
300/// v1.4.106 D1 5a: REST surface adapter — 把 pipeline `AuthDecision::Reject`
301/// 翻成 axum `Response`.
302///
303/// **历史**: v1.4.104 阶段 5 把"翻 reject 为 HTTP response"作 free fn
304/// `reject_to_http_response` 写在本文件; v1.4.106 D1 把 4 surface 的同类
305/// translate fn 收敛到 [`futu_auth_pipeline::SurfaceAdapter`] trait, 让 4
306/// surface 一致, 防 sibling-route 不一致 regression (codex round 3 F1 教训).
307///
308/// **HTTP body 泛化策略** (v1.4.102 BUG-011 fix):
309/// - 401: 保留 reason (让 client 知道 missing token / invalid bearer 类提示)
310///   + 加 `WWW-Authenticate: Bearer` header (RFC 7235 §3.1)
311/// - 403 / 500: body 泛化 (不泄 key_id / scope / 内部 bug 信息), audit log 已含细节
312/// - 429: 保留 reason (client 需 backoff 决策)
313/// - 404: 保留 reason (REST 专属, unknown route 用)
314pub struct RestAdapter;
315
316impl futu_auth_pipeline::SurfaceAdapter for RestAdapter {
317    type WireResponse = Response;
318
319    fn surface_id() -> futu_auth_pipeline::SurfaceId {
320        futu_auth_pipeline::SurfaceId::Rest
321    }
322
323    fn translate_reject(
324        kind: futu_auth_pipeline::RejectKind,
325        reason: String,
326    ) -> Self::WireResponse {
327        use futu_auth_pipeline::RejectKind;
328        match kind {
329            RejectKind::Unauthenticated => (
330                StatusCode::UNAUTHORIZED,
331                [("www-authenticate", "Bearer realm=\"futu-rest\"")],
332                Json(serde_json::json!({ "error": reason })),
333            )
334                .into_response(),
335            RejectKind::Forbidden => {
336                // BUG-011: body 泛化 "forbidden" 不泄 scope/key_id; audit log 已含细节
337                drop(reason);
338                (
339                    StatusCode::FORBIDDEN,
340                    Json(serde_json::json!({ "error": "forbidden" })),
341                )
342                    .into_response()
343            }
344            RejectKind::RateLimited => (
345                StatusCode::TOO_MANY_REQUESTS,
346                Json(serde_json::json!({ "error": format!("limit check failed: {reason}") })),
347            )
348                .into_response(),
349            RejectKind::NotFound => (
350                StatusCode::NOT_FOUND,
351                Json(serde_json::json!({ "error": reason })),
352            )
353                .into_response(),
354            RejectKind::InternalError => {
355                drop(reason);
356                (
357                    StatusCode::INTERNAL_SERVER_ERROR,
358                    Json(serde_json::json!({ "error": "internal error" })),
359                )
360                    .into_response()
361            }
362        }
363    }
364}
365
366fn audit(
367    ctx: &futu_auth::audit::AuditContext,
368    path: &str,
369    key_id: Option<&str>,
370    result: &str,
371    reason: &str,
372) {
373    let key_id = key_id.unwrap_or("<none>");
374    futu_auth::audit::with_context(ctx.clone(), || {
375        if result == "reject" {
376            futu_auth::audit::reject("rest", path, key_id, reason);
377        } else {
378            futu_auth::audit::allow("rest", path, key_id, Some(reason));
379        }
380    });
381}
382
383#[cfg(test)]
384mod tests;