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;