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