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/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:real` |
16
17use std::sync::Arc;
18
19use axum::body::Body;
20use axum::extract::State;
21use axum::http::{Request, StatusCode};
22use axum::middleware::Next;
23use axum::response::{IntoResponse, Response};
24use axum::Json;
25use chrono::Utc;
26use futu_auth::{CheckCtx, KeyStore, LimitOutcome, RuntimeCounters, Scope};
27
28/// REST auth middleware 的组合 state:KeyStore(谁能进)+ RuntimeCounters(限额)
29///
30/// 从 v1.0 起 middleware 除了 scope 检查还会在 `trade:real` 请求上跑一次
31/// `check_and_commit` —— CheckCtx 里 market/symbol/side/value 全空,只挂
32/// rate limit + 时段窗口两个全局闸门。精细化检查(daily / per_order /
33/// side / 具体 market)留给下游 handler。
34#[derive(Clone)]
35pub struct AuthState {
36    pub key_store: Arc<KeyStore>,
37    pub counters: Arc<RuntimeCounters>,
38}
39
40impl AuthState {
41    pub fn new(key_store: Arc<KeyStore>, counters: Arc<RuntimeCounters>) -> Self {
42        Self {
43            key_store,
44            counters,
45        }
46    }
47}
48
49/// 根据 URI 路径推断所需 scope
50///
51/// **Fail-closed**:未知的 `/api/*` 路径返回 None,middleware 会拒绝请求。
52/// 这是故意的 —— 避免"新加了个路由但忘了映射 scope"导致它默默落到 `QotRead`
53/// 兜底,万一那是个写接口就等于对任意 `qot:read` key 开放。
54///
55/// 新增路由时必须同步更新下面的常量之一,否则会被 middleware 挡掉。
56fn scope_for_path(path: &str) -> Option<Scope> {
57    // 系统 + 行情只读
58    const QOT: &[&str] = &[
59        "/api/global-state",
60        "/api/user-info",
61        "/api/delay-statistics",
62        "/api/subscribe",
63        "/api/sub-info",
64        "/api/quote",
65        "/api/kline",
66        "/api/orderbook",
67        "/api/broker",
68        "/api/ticker",
69        "/api/rt",
70        "/api/snapshot",
71        "/api/static-info",
72        "/api/plate-set",
73        "/api/plate-security",
74        "/api/reference",
75        "/api/owner-plate",
76        "/api/option-chain",
77        "/api/warrant",
78        "/api/capital-flow",
79        "/api/capital-distribution",
80        "/api/user-security",
81        "/api/stock-filter",
82        "/api/ipo-list",
83        "/api/future-info",
84        "/api/market-state",
85        "/api/history-kline",
86    ];
87    // 账户只读
88    const ACC: &[&str] = &[
89        "/api/accounts",
90        "/api/funds",
91        "/api/positions",
92        "/api/orders",
93        "/api/order-fills",
94        "/api/history-orders",
95        "/api/history-order-fills",
96        "/api/max-trd-qtys",
97        "/api/margin-ratio",
98        "/api/order-fee",
99        "/api/sub-acc-push",
100    ];
101    // 交易写
102    //
103    // /api/order 是 POST 下单;/api/orders 是账户查询。精确匹配避免误伤。
104    const TRADE: &[&str] = &["/api/order", "/api/modify-order", "/api/unlock-trade"];
105
106    if TRADE.contains(&path) {
107        return Some(Scope::TradeReal);
108    }
109    if ACC.contains(&path) {
110        return Some(Scope::AccRead);
111    }
112    if QOT.contains(&path) {
113        return Some(Scope::QotRead);
114    }
115    // 未知 /api/* → 返回 None;middleware 决定如何处置
116    None
117}
118
119/// axum middleware:Bearer Token + scope 校验
120pub async fn bearer_auth(
121    State(auth): State<AuthState>,
122    mut req: Request<Body>,
123    next: Next,
124) -> Response {
125    // KeyStore 未配置 → legacy 模式,全放行
126    if !auth.key_store.is_configured() {
127        return next.run(req).await;
128    }
129
130    let path = req.uri().path();
131    // WebSocket 握手(/ws)和非 /api 路由不强制鉴权;/ws 的鉴权在握手阶段另行处理
132    if !path.starts_with("/api/") {
133        return next.run(req).await;
134    }
135
136    // 提取 Bearer token
137    let token = req
138        .headers()
139        .get("authorization")
140        .and_then(|v| v.to_str().ok())
141        .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string()));
142
143    let Some(token) = token else {
144        audit(path, None, "reject", "missing Authorization: Bearer");
145        return (
146            StatusCode::UNAUTHORIZED,
147            [("www-authenticate", "Bearer realm=\"futu-rest\"")],
148            Json(serde_json::json!({ "error": "missing Authorization: Bearer <api-key>" })),
149        )
150            .into_response();
151    };
152
153    let Some(rec) = auth.key_store.verify(&token) else {
154        audit(path, None, "reject", "invalid api key");
155        return (
156            StatusCode::UNAUTHORIZED,
157            Json(serde_json::json!({ "error": "invalid API key" })),
158        )
159            .into_response();
160    };
161
162    if rec.is_expired(Utc::now()) {
163        audit(path, Some(&rec.id), "reject", "key expired");
164        return (
165            StatusCode::UNAUTHORIZED,
166            Json(serde_json::json!({ "error": format!("API key {:?} expired", rec.id) })),
167        )
168            .into_response();
169    }
170
171    let Some(needed) = scope_for_path(path) else {
172        // 路径以 `/api/` 开头但不在映射表里 —— fail-closed:返回 404 + 审计
173        // 注意不是 401/403:key 是有效的,只是接口未知;避免泄漏"接口是否存在"
174        // 信息,也避免误导客户端以为换把 key 就能过。
175        audit(path, Some(&rec.id), "reject", "unknown /api route");
176        return (
177            StatusCode::NOT_FOUND,
178            Json(serde_json::json!({
179                "error": format!("unknown API route {path:?}")
180            })),
181        )
182            .into_response();
183    };
184    if !rec.scopes.contains(&needed) {
185        audit(
186            path,
187            Some(&rec.id),
188            "reject",
189            &format!("missing scope {}", needed),
190        );
191        return (
192            StatusCode::FORBIDDEN,
193            Json(serde_json::json!({
194                "error": format!("API key {:?} missing scope {:?}", rec.id, needed.as_str())
195            })),
196        )
197            .into_response();
198    }
199
200    // trade:real 的请求跑一次通用的 rate + hours 闸门(market/symbol/value/side
201    // 此时还没解析 body,全留空给 check_and_commit 跳过)
202    if needed == Scope::TradeReal {
203        let ctx = CheckCtx {
204            market: String::new(),
205            symbol: String::new(),
206            order_value: None,
207            trd_side: None,
208        };
209        if let LimitOutcome::Reject(reason) =
210            auth.counters
211                .check_and_commit(&rec.id, &rec.limits(), &ctx, Utc::now())
212        {
213            audit(path, Some(&rec.id), "reject", &format!("limit: {reason}"));
214            return (
215                StatusCode::TOO_MANY_REQUESTS,
216                Json(serde_json::json!({
217                    "error": format!("limit check failed: {reason}")
218                })),
219            )
220                .into_response();
221        }
222    }
223
224    audit(path, Some(&rec.id), "allow", needed.as_str());
225
226    // v1.2:把 verified KeyRecord 塞 request extensions,下游 handler 可以
227    // 用 axum `Extension<Arc<KeyRecord>>` 取出来跑 handler 层的 full CheckCtx
228    req.extensions_mut().insert(rec);
229
230    next.run(req).await
231}
232
233fn audit(path: &str, key_id: Option<&str>, result: &str, reason: &str) {
234    let key_id = key_id.unwrap_or("<none>");
235    if result == "reject" {
236        futu_auth::audit::reject("rest", path, key_id, reason);
237    } else {
238        futu_auth::audit::allow("rest", path, key_id, Some(reason));
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn scope_mapping() {
248        assert_eq!(scope_for_path("/api/quote"), Some(Scope::QotRead));
249        assert_eq!(scope_for_path("/api/accounts"), Some(Scope::AccRead));
250        assert_eq!(scope_for_path("/api/orders"), Some(Scope::AccRead));
251        assert_eq!(scope_for_path("/api/order"), Some(Scope::TradeReal));
252        assert_eq!(scope_for_path("/api/modify-order"), Some(Scope::TradeReal));
253        assert_eq!(scope_for_path("/api/unlock-trade"), Some(Scope::TradeReal));
254        assert_eq!(scope_for_path("/health"), None);
255    }
256
257    #[test]
258    fn unknown_api_path_fails_closed() {
259        // 关键安全属性:未来有人加新路由但忘了写进 scope_for_path 时,
260        // 不要默默当成 QotRead 放行。必须显式返回 None 让 middleware 拒绝。
261        assert_eq!(scope_for_path("/api/future-write-endpoint"), None);
262        assert_eq!(scope_for_path("/api/transfer-money"), None);
263        assert_eq!(scope_for_path("/api/"), None);
264    }
265
266    #[test]
267    fn all_known_routes_have_scopes() {
268        // 快照式校验:确保 server.rs 里 Router 注册的每条 /api 路由都能解析出 scope。
269        // 这条测试是一个"备忘锁",加路由时如果不更 auth.rs 这里会挂掉。
270        let known = [
271            // QOT
272            "/api/global-state",
273            "/api/user-info",
274            "/api/delay-statistics",
275            "/api/subscribe",
276            "/api/sub-info",
277            "/api/quote",
278            "/api/kline",
279            "/api/orderbook",
280            "/api/broker",
281            "/api/ticker",
282            "/api/rt",
283            "/api/snapshot",
284            "/api/static-info",
285            "/api/plate-set",
286            "/api/plate-security",
287            "/api/reference",
288            "/api/owner-plate",
289            "/api/option-chain",
290            "/api/warrant",
291            "/api/capital-flow",
292            "/api/capital-distribution",
293            "/api/user-security",
294            "/api/stock-filter",
295            "/api/ipo-list",
296            "/api/future-info",
297            "/api/market-state",
298            "/api/history-kline",
299            // ACC
300            "/api/accounts",
301            "/api/funds",
302            "/api/positions",
303            "/api/orders",
304            "/api/order-fills",
305            "/api/history-orders",
306            "/api/history-order-fills",
307            "/api/max-trd-qtys",
308            "/api/margin-ratio",
309            "/api/order-fee",
310            "/api/sub-acc-push",
311            // TRADE
312            "/api/order",
313            "/api/modify-order",
314            "/api/unlock-trade",
315        ];
316        for p in &known {
317            assert!(
318                scope_for_path(p).is_some(),
319                "route {p:?} is registered but not mapped to a Scope"
320            );
321        }
322    }
323}