futu_grpc/
auth.rs

1//! gRPC 的 Bearer Token 鉴权
2//!
3//! 两种模式:
4//! - **未配置 KeyStore**:完全不鉴权(保持旧行为,启动日志 warn)
5//! - **配置了 KeyStore**:所有 RPC 必须带 `authorization: Bearer <plaintext>` metadata,
6//!   且按 proto_id 区间 / RPC 类型校验 scope
7//!
8//! 由于 gRPC 采用通用 `Request(proto_id, body)` RPC 模式,scope 检查根据
9//! **proto_id 区间** 进行;`SubscribePush` 流式 RPC 要求 `qot:read`
10//! (推送混合了行情与交易,以行情 scope 为最低门槛)。
11//!
12//! | proto_id 范围 | 所需 scope |
13//! |---|---|
14//! | 1xxx 系统(InitConnect / GetGlobalState / KeepAlive / …) | 无(放行) |
15//! | 3xxx 行情 | `qot:read` |
16//! | 2001 / 2008 / 2101 / 2102 / 2111 / 2201 / 2211 / 2221 / 2222 / 2223 / 2225 / 2226 账户只读 | `acc:read` |
17//! | 2005 UnlockTrade | `trade:real` |
18//! | 2202 / 2205 / 2237 下单 / 改单 / 确认 | `trade:real` |
19//! | 其他 | 拒绝(保守) |
20
21use std::sync::Arc;
22
23use chrono::Utc;
24use futu_auth::{KeyRecord, KeyStore, Scope};
25use tonic::{Request, Status};
26
27/// 按 proto_id 推断所需 scope
28///
29/// 返回 `None` 代表该 proto_id 属于系统 / 免鉴权区间(仍会校验 key 本身有效)。
30///
31/// v1.0 起映射统一到 `futu_auth::scope_for_proto_id`,gRPC 和核心 WS 共用。
32#[inline]
33pub fn scope_for_proto(proto_id: u32) -> Option<Scope> {
34    futu_auth::scope_for_proto_id(proto_id)
35}
36
37/// 提取 + 校验 Bearer token,返回命中的 KeyRecord;鉴权失败返回 Status
38///
39/// - store 未配置 → 返回 Ok(None)(legacy 全放行)
40/// - 有 store 但 metadata 缺失 / token 无效 / 过期 → Err
41#[allow(clippy::result_large_err)] // tonic::Status 固定大小,与 tonic 自身 RPC 签名一致
42pub fn authenticate<T>(
43    store: &Arc<KeyStore>,
44    req: &Request<T>,
45) -> Result<Option<Arc<KeyRecord>>, Status> {
46    if !store.is_configured() {
47        return Ok(None);
48    }
49
50    let token = req
51        .metadata()
52        .get("authorization")
53        .and_then(|v| v.to_str().ok())
54        .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string()));
55
56    let Some(token) = token else {
57        futu_auth::audit::reject(
58            "grpc",
59            "auth",
60            "<missing>",
61            "missing authorization: Bearer metadata",
62        );
63        return Err(Status::unauthenticated(
64            "missing authorization: Bearer <api-key> metadata",
65        ));
66    };
67
68    let Some(rec) = store.verify(&token) else {
69        futu_auth::audit::reject("grpc", "auth", "<invalid>", "invalid api key");
70        return Err(Status::unauthenticated("invalid API key"));
71    };
72
73    if rec.is_expired(Utc::now()) {
74        futu_auth::audit::reject("grpc", "auth", &rec.id, "key expired");
75        return Err(Status::unauthenticated(format!(
76            "API key {:?} expired",
77            rec.id
78        )));
79    }
80
81    Ok(Some(rec))
82}
83
84/// 检查 KeyRecord 是否持有 `needed` scope
85#[allow(clippy::result_large_err)] // tonic::Status 固定大小,与 tonic 自身 RPC 签名一致
86pub fn check_scope(
87    rec: &Option<Arc<KeyRecord>>,
88    proto_id: u32,
89    needed: Scope,
90) -> Result<(), Status> {
91    let Some(rec) = rec else {
92        // legacy 模式(store 未配置)
93        return Ok(());
94    };
95    let endpoint = format!("proto_id={}", proto_id);
96    if !rec.scopes.contains(&needed) {
97        futu_auth::audit::reject(
98            "grpc",
99            &endpoint,
100            &rec.id,
101            &format!("missing scope {}", needed),
102        );
103        return Err(Status::permission_denied(format!(
104            "API key {:?} missing scope {:?} for proto_id={}",
105            rec.id,
106            needed.as_str(),
107            proto_id
108        )));
109    }
110    futu_auth::audit::allow("grpc", &endpoint, &rec.id, Some(needed.as_str()));
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn proto_range_mapping() {
120        assert_eq!(scope_for_proto(1002), None); // GlobalState
121        assert_eq!(scope_for_proto(3004), Some(Scope::QotRead)); // GetBasicQot
122        assert_eq!(scope_for_proto(3223), Some(Scope::QotRead)); // MarketState
123        assert_eq!(scope_for_proto(2001), Some(Scope::AccRead)); // AccList
124        assert_eq!(scope_for_proto(2101), Some(Scope::AccRead)); // Funds
125        assert_eq!(scope_for_proto(2201), Some(Scope::AccRead)); // GetOrderList
126        assert_eq!(scope_for_proto(2005), Some(Scope::TradeReal)); // Unlock
127        assert_eq!(scope_for_proto(2202), Some(Scope::TradeReal)); // PlaceOrder
128        assert_eq!(scope_for_proto(2205), Some(Scope::TradeReal)); // ModifyOrder
129    }
130}