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