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;