Skip to main content

futu_backend/auth/
repull.rs

1//! v1.4.93 G2 (CLAUDE.md C4 audit): 实装 POST `/authority/repull_auth_code`,
2//! 对齐 C++ FTLogin `auth_impl.cpp:715-754` `RepullAuthCode` +
3//! `auth_impl.cpp:3308-3376` `ParseRepullAuthCodeResponse`。
4//!
5//! ## 触发场景
6//!
7//! - **broker auth_code 过期**:[`super::BrokerAuthCode::invalid_time`] 在认证
8//!   响应里给的 expiry。daemon 长跑(典型 30 天)触发,原本 broker channel
9//!   失效不 self-heal、必须重启 daemon —— 本 fn 让 `bridge` 拉新 auth_code
10//!   重做 `broker_auth` HTTP + CMD 1001 重登 broker, **避免重启**。
11//! - **broker `kAuthNoValidCid` (error_code=20029)**:C++ 在
12//!   `ParseRepullAuthCodeResponse` 见此码会 `ClearBrokerAccountInfo` 然后重新
13//!   走整 RepullAuthCode 流程 —— 本 fn 仅做"拉新 auth_code"那一步,
14//!   `ClearBrokerAccountInfo` 等价物(清 cipher / customer_id)由 caller 决定
15//!   要不要做(v1.4.93 不主动清,v1.4.94+ 视真机行为再决定)。
16//!
17//! ## 协议层 (对齐 C++)
18//!
19//! ```text
20//! POST https://{auth_domain}/authority/repull_auth_code
21//! body = {
22//!   "uid":       <u64>,            // 当前账户 uid
23//!   "device_id": "<16-hex>",       // 设备 ID (持久化)
24//!   "web_sig":   "<str>",          // /authority/ 响应里 web_sig_new (持久化)
25//!   "broker_id": <i32>             // 单 broker
26//! }
27//! ```
28//!
29//! Response result 单 broker:
30//! ```text
31//! { "result": { "uid":<u64>, "broker_id":<i32>, "auth_code":"<str>",
32//!   "invalid_time":<u64> } }
33//! ```
34//!
35//! 错误响应 result 缺失,error.error_code 给具体错码。
36//! `error_code=20029` (`kAuthNoValidCid`) 是特定可识别状态。
37//!
38//! ## Failure fallback
39//!
40//! - `web_sig` 空(v1.4.92 凭据 / device-verify shell 没此字段)→ caller 跳过
41//!   repull,fallback 走 platform refresh(重 POST /authority/)然后再 retry
42//! - HTTP 失败 / web_sig 过期 → caller log + 不重试本轮 → 等下次 broker
43//!   reconnect 触发或 platform refresh
44//!
45//! ## CLAUDE.md pitfalls 关联
46//!
47//! - **#34** Agent 调研结论 ≠ 真机正确性: 本实装基于 C++ 源码 `auth_impl.cpp`
48//!   完整对照, 但 backend 实际 wire (是否 reject 'web_sig over-frequent
49//!   refresh', error_code 准确含义) 仍需真机 verify
50//! - **#42** Backend-semantic 风险: error_code=20029 是否真触发 + repull 后
51//!   的 broker channel 重建是否 work, 需真机
52//! - **#45** Silent-success: 函数返 `Ok(BrokerAuthCode)` 必须基于响应
53//!   `result.auth_code` + `invalid_time` 都非空, 否则返 Err
54
55use futu_core::error::{FutuError, Result};
56use futu_domain_broker_reconnect::{
57    BrokerRepullAuthCodeRequestFacts, BrokerRepullAuthCodeRequestReject,
58    BrokerRepullAuthCodeSuccessFacts, BrokerRepullAuthCodeSuccessReject,
59    validate_broker_repull_auth_code_request, validate_broker_repull_auth_code_success,
60};
61
62use super::redact::uid_log_fingerprint;
63use super::{BrokerAuthCode, UserAttribution};
64
65/// `RepullAuthCode` URL 路径常量, 对齐 C++ `auth_impl.cpp:28`
66/// `AUTH_REPULL_AUTHCODE = "/authority/repull_auth_code"`.
67const REPULL_AUTH_CODE_PATH: &str = "/authority/repull_auth_code";
68
69/// C++ `kAuthNoValidCid` 错码(broker cid 失效)。当 backend 返此码时,
70/// 调用方应当清 broker cipher cache + 触发 broker channel 重建(C++ 行为
71/// `ClearBrokerAccountInfo`)。本 fn 不做副作用,只透传给 caller 决定。
72pub const ERROR_CODE_NO_VALID_CID: i64 = 20029;
73
74/// 请求新的 broker auth_code,对齐 C++ `RepullAuthCode`.
75///
76/// # 参数
77///
78/// - `http`: 复用 bridge 创建的 reqwest::Client(含 webpki-roots TLS 配置)
79/// - `attribution`: 当前账户 user_attribution (决定 auth_domain)
80/// - `uid`: 当前账户 uid (== AuthResult.user_id)
81/// - `web_sig`: 持久化的 web_sig (来自 SavedCredentials.web_sig 或
82///   AuthResult.web_sig)。**空字符串 → 直接 Err**(向后兼容旧凭据无此字段
83///   的场景,调用方应跳过 repull、fallback 走 platform refresh)。
84/// - `device_id`: 设备 ID (16-hex)
85/// - `broker_id`: 目标 broker (1001 / 1007 / 1008 / 1009 / 1012 / 1017 / 1019)
86///
87/// # 返回
88///
89/// 成功: `BrokerAuthCode { broker_id, auth_code, invalid_time }` —— 与
90/// `parse_auth_code_list` 解出的元素同结构, caller 可直接走 `broker_auth`
91/// HTTP + `broker_tcp_login` 流程。
92///
93/// 失败: `Err(FutuError::*)`. 见模块文档 fallback 策略.
94pub async fn repull_auth_code(
95    http: &reqwest::Client,
96    attribution: UserAttribution,
97    uid: u64,
98    web_sig: &str,
99    device_id: &str,
100    broker_id: u32,
101) -> Result<BrokerAuthCode> {
102    // Backward-compatible public wrapper: callers created before v1.4.112 did
103    // not pass OpenD's app client type. Internal bridge code should call the
104    // precise helper below because it already owns `AuthState.client_type`.
105    let client_type = match attribution {
106        UserAttribution::Cn | UserAttribution::Hk => 40,
107        _ => 60,
108    };
109    repull_auth_code_with_client_type(
110        http,
111        client_type,
112        attribution,
113        uid,
114        web_sig,
115        device_id,
116        broker_id,
117    )
118    .await
119}
120
121/// 请求新的 broker auth_code, 显式使用 OpenD client_type 构造 C++ 形态
122/// FTAuthImpl business headers。
123pub async fn repull_auth_code_with_client_type(
124    http: &reqwest::Client,
125    client_type: u8,
126    attribution: UserAttribution,
127    uid: u64,
128    web_sig: &str,
129    device_id: &str,
130    broker_id: u32,
131) -> Result<BrokerAuthCode> {
132    let request = validate_broker_repull_auth_code_request(BrokerRepullAuthCodeRequestFacts {
133        uid,
134        web_sig,
135        broker_id,
136    })
137    .map_err(repull_request_reject_error)?;
138    let uid = request.uid;
139    let web_sig = request.web_sig;
140    let broker_id = request.broker_id;
141
142    let url = repull_auth_code_url(attribution);
143
144    let body = serde_json::json!({
145        "uid": uid,
146        "device_id": device_id,
147        "web_sig": web_sig,
148        "broker_id": broker_id,
149    });
150
151    let uid_fp = uid_log_fingerprint(uid);
152    tracing::info!(
153        broker_id,
154        uid_fp = %uid_fp,
155        url = %url,
156        attribution = ?attribution,
157        "v1.4.93 G2: POST /authority/repull_auth_code (broker auth_code refresh)"
158    );
159
160    // 注意: 不打印 body (含 web_sig) — 走 redact_auth_body 才能 log,
161    // 这里只 info url + broker_id; 失败场景下走 error/warn 仍只透出 ret_type
162    let headers = super::http_client::auth_business_headers(client_type, device_id)?;
163    let resp: serde_json::Value = http
164        .post(&url)
165        .headers(headers)
166        .json(&body)
167        .send()
168        .await
169        .map_err(|e| FutuError::Network(std::io::Error::other(e.to_string())))?
170        .json()
171        .await
172        .map_err(|e| FutuError::Codec(format!("repull_auth_code: response not JSON: {e}")))?;
173
174    // 错误分支 (对齐 C++ ParseRepullAuthCodeResponse:3340-3358)
175    if let Some(err) = resp.get("error").and_then(|e| e.as_object()) {
176        let code = err.get("error_code").and_then(|v| v.as_i64()).unwrap_or(-1);
177        let msg = err
178            .get("error_msg")
179            .and_then(|v| v.as_str())
180            .unwrap_or("unknown");
181        if code != 0 {
182            tracing::warn!(
183                broker_id,
184                uid_fp = %uid_fp,
185                error_code = code,
186                error_msg = %msg,
187                no_valid_cid = code == ERROR_CODE_NO_VALID_CID,
188                "v1.4.93 G2: RepullAuthCode failed"
189            );
190            return Err(FutuError::ServerError {
191                ret_type: code as i32,
192                msg: format!("repull_auth_code broker_id={broker_id}: {msg}"),
193            });
194        }
195    }
196
197    let result = resp
198        .get("result")
199        .and_then(|r| r.as_object())
200        .ok_or_else(|| {
201            FutuError::Codec("repull_auth_code: missing result + missing error".into())
202        })?;
203
204    parse_repull_success_response(result, uid, broker_id)
205}
206
207fn repull_request_reject_error(reject: BrokerRepullAuthCodeRequestReject) -> FutuError {
208    match reject {
209        BrokerRepullAuthCodeRequestReject::EmptyWebSig => FutuError::Codec(
210            "repull_auth_code: web_sig empty (legacy credentials before v1.4.93 G3 \
211             or device-verify shell path) — caller should fallback to platform refresh"
212                .into(),
213        ),
214        BrokerRepullAuthCodeRequestReject::ZeroUid => {
215            FutuError::Codec("repull_auth_code: uid is 0 (invalid)".into())
216        }
217        BrokerRepullAuthCodeRequestReject::UnsupportedBrokerId { broker_id } => {
218            FutuError::Codec(format!("repull_auth_code: unknown broker_id {broker_id}"))
219        }
220    }
221}
222
223fn repull_auth_code_url(attribution: UserAttribution) -> String {
224    // `auth_domain()` already returns a complete base URL with scheme. All
225    // other primary-auth call sites use it as-is; do not prepend another
226    // `https://`.
227    format!("{}{}", attribution.auth_domain(), REPULL_AUTH_CODE_PATH)
228}
229
230fn parse_repull_success_response(
231    result: &serde_json::Map<String, serde_json::Value>,
232    uid: u64,
233    broker_id: u32,
234) -> Result<BrokerAuthCode> {
235    // 对齐 C++ ParseRepullAuthCodeResponse:3325-3338 字段抽取 + 校验
236    let validated = validate_broker_repull_auth_code_success(BrokerRepullAuthCodeSuccessFacts {
237        expected_uid: uid,
238        expected_broker_id: broker_id,
239        response_uid: result.get("uid").and_then(|v| v.as_u64()),
240        response_broker_id: result.get("broker_id").and_then(|v| v.as_u64()),
241        auth_code: result.get("auth_code").and_then(|v| v.as_str()),
242        invalid_time: result.get("invalid_time").and_then(|v| v.as_u64()),
243    })
244    .map_err(repull_success_reject_error)?;
245
246    let auth_code = validated.auth_code.to_string();
247    let invalid_time = validated.invalid_time;
248
249    tracing::info!(
250        broker_id,
251        uid_fp = %uid_log_fingerprint(uid),
252        invalid_time,
253        auth_code_len = auth_code.len(),
254        "v1.4.93 G2: RepullAuthCode success — broker auth_code refreshed"
255    );
256
257    Ok(BrokerAuthCode {
258        broker_id: validated.broker_id,
259        auth_code,
260        invalid_time,
261    })
262}
263
264fn repull_success_reject_error(reject: BrokerRepullAuthCodeSuccessReject) -> FutuError {
265    match reject {
266        BrokerRepullAuthCodeSuccessReject::UidMismatch {
267            expected_uid,
268            response_uid,
269        } => {
270            let expected_uid_fp = uid_log_fingerprint(expected_uid);
271            let got_uid_fp = uid_log_fingerprint(response_uid);
272            FutuError::Codec(format!(
273                "repull_auth_code: response uid mismatch (expected {expected_uid_fp}, \
274                 got {got_uid_fp})"
275            ))
276        }
277        BrokerRepullAuthCodeSuccessReject::MissingBrokerId => {
278            FutuError::Codec("repull_auth_code: response broker_id invalid".into())
279        }
280        BrokerRepullAuthCodeSuccessReject::InvalidBrokerId { response_broker_id } => {
281            FutuError::Codec(format!(
282                "repull_auth_code: response broker_id invalid (got {response_broker_id})"
283            ))
284        }
285        BrokerRepullAuthCodeSuccessReject::BrokerIdMismatch {
286            expected_broker_id,
287            response_broker_id,
288        } => FutuError::Codec(format!(
289            "repull_auth_code: response broker_id mismatch (expected {expected_broker_id}, \
290             got {response_broker_id})"
291        )),
292        BrokerRepullAuthCodeSuccessReject::EmptyAuthCodeOrInvalidTime {
293            auth_code_len,
294            invalid_time,
295        } => FutuError::Codec(format!(
296            "repull_auth_code: empty auth_code or invalid_time (auth_code_len={auth_code_len}, \
297             invalid_time={invalid_time})"
298        )),
299    }
300}
301
302#[cfg(test)]
303mod tests;