Skip to main content

futu_backend/auth/
login.rs

1use std::sync::Arc;
2
3use futu_core::error::{FutuError, Result};
4
5use super::endpoints;
6use super::http_client::build_primary_auth_http_client;
7use super::password_auth::password_auth;
8use super::phone::normalize_phone_account;
9use super::redact::{self, account_log_fingerprint};
10use super::{AuthChallengePort, AuthConfig, AuthResult, AuthSession};
11
12tokio::task_local! {
13    static AUTH_CHALLENGE_PORT: Arc<dyn AuthChallengePort>;
14}
15
16pub(super) fn current_auth_challenge_port() -> Option<Arc<dyn AuthChallengePort>> {
17    AUTH_CHALLENGE_PORT.try_with(Arc::clone).ok()
18}
19
20mod cached_credentials;
21#[cfg(test)]
22pub(in crate::auth) use cached_credentials::install_test_cached_verify_origin_override;
23use cached_credentials::try_cached_credentials_login;
24
25#[cfg(test)]
26pub(in crate::auth) async fn try_cached_credentials_login_for_test(
27    http: &reqwest::Client,
28    effective_config: &AuthConfig,
29    region_code: Option<&str>,
30    verify_cb: Option<&(dyn Fn() -> Option<String> + Send + Sync)>,
31    primary_webtcp: Option<&endpoints::PrimaryAuthWebTcpContext>,
32) -> Result<Option<AuthResult>> {
33    try_cached_credentials_login(
34        http,
35        effective_config,
36        region_code,
37        verify_cb,
38        primary_webtcp,
39    )
40    .await
41}
42
43fn attach_primary_auth_site_config(
44    auth_result: AuthResult,
45    context: Option<&endpoints::PrimaryAuthWebTcpContext>,
46) -> AuthSession {
47    let bootstrap_site_config = context
48        .map_or_else(crate::auth::site_config::empty_shared, |context| {
49            Arc::clone(&context.site_config)
50        });
51    AuthSession {
52        auth_result,
53        bootstrap_site_config,
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(in crate::auth) enum CachedSmsPreflight {
59    DirectVerify,
60    AwaitInput,
61    Continue,
62}
63
64/// Select the cached-SMS admission route from the immutable credential
65/// snapshot facts. A complete challenge is either verified through an available
66/// input callback or preserved without network I/O while awaiting input.
67pub(in crate::auth) fn plan_cached_sms_preflight(
68    dvs_fresh: bool,
69    dcs_fresh: bool,
70    has_verify_cb: bool,
71) -> CachedSmsPreflight {
72    if dvs_fresh && dcs_fresh {
73        if has_verify_cb {
74            CachedSmsPreflight::DirectVerify
75        } else {
76            CachedSmsPreflight::AwaitInput
77        }
78    } else {
79        CachedSmsPreflight::Continue
80    }
81}
82
83pub(in crate::auth) fn should_fallback_to_password_auth_after_remember_error(
84    err: &FutuError,
85) -> bool {
86    !matches!(
87        err,
88        FutuError::ServerError { ret_type: 20, msg }
89            if msg.starts_with("remember-login device verification did not complete:")
90    )
91}
92
93/// 验证码获取回调类型
94///
95/// 当需要短信验证码时调用此回调。返回 Some(code) 表示用户输入了验证码,
96/// 返回 None 表示用户取消。
97pub type VerifyCodeCallback = Box<dyn Fn() -> Option<String> + Send + Sync>;
98
99/// 完整密码鉴权(优先用保存的凭据跳过验证码)
100///
101/// CLI 模式使用 `authenticate()` 从 stdin 读取验证码;
102/// GUI 模式使用 `authenticate_with_callback()` 通过回调获取。
103pub async fn authenticate(config: &AuthConfig) -> Result<AuthSession> {
104    authenticate_with_callback(config, None).await
105}
106
107/// 完整密码鉴权(带自定义验证码回调)
108pub async fn authenticate_with_callback(
109    config: &AuthConfig,
110    verify_cb: Option<VerifyCodeCallback>,
111) -> Result<AuthSession> {
112    // v1.4.84 SEC-001: 首次 auth 启动时 stderr warn debug log 安全风险.
113    // OnceLock dedup 避免 retry 或 daemon-reload 重复打.
114    redact::emit_debug_log_security_warn_once();
115
116    let http = build_primary_auth_http_client(config.protocol_identity.client_type())?;
117    let primary_webtcp = endpoints::primary_auth_webtcp_context_for_auth_server(
118        config.protocol_identity,
119        &config.auth_server,
120    );
121    let _primary_webtcp_prefetch = primary_webtcp.as_ref().map(|context| {
122        endpoints::spawn_primary_auth_site_config_prefetch(context, &config.device_id)
123    });
124
125    // v1.4.13:把 `+86-13900000000` 这种带区号的输入拆成 account 本体 + region_code。
126    // 不拆的话 moomoo 服务端按 `13900000000` 查存的 pwd_md5 对不上我们 tgtgt 里
127    // 发的整串 `+86-13900000000`,报 `error_code=2 账号密码不匹配`。对齐 C++
128    // `BasicAccountAuthInfo` 的字段约定(`auth_impl.cpp:267`)。
129    let (normalized_account, region_code) = normalize_phone_account(&config.account);
130    if region_code.is_some() {
131        // v1.4.106 codex 0558 F2: log fingerprint, 不写 raw account / phone
132        tracing::info!(
133            original_fp = %account_log_fingerprint(&config.account),
134            account_fp = %account_log_fingerprint(&normalized_account),
135            region_no = %region_code.as_deref().unwrap_or(""),
136            "parsed phone account with region code"
137        );
138    }
139    // 构造本地 effective config —— `account` 字段已归一化,后续所有流程都用它
140    let mut effective_config = config.clone();
141    effective_config.account = normalized_account;
142
143    if let Some(auth_result) = try_cached_credentials_login(
144        &http,
145        &effective_config,
146        region_code.as_deref(),
147        verify_cb.as_deref(),
148        primary_webtcp.as_ref(),
149    )
150    .await?
151    {
152        return Ok(attach_primary_auth_site_config(
153            auth_result,
154            primary_webtcp.as_ref(),
155        ));
156    }
157
158    // 全新密码认证
159    //
160    // v1.4.17:SMS 验证码错(`error_code=21`)时自动轮换 device_id 重试,
161    // 最多 MAX_SMS_RETRIES 次。
162    //
163    // **v1.4.57 修正(外部报告 #5 第 3 层根因)**:自动轮换 device_id 反而会触发
164    // 服务端限流("5 次不同设备 30 秒内" 硬 threshold)。同事实锤:连续 2 次
165    // SMS 输错自动轮换后,正确码也被 code=1 "系统繁忙" 拒。v1.4.57 起**不再
166    // 自动轮换**(MAX_SMS_RETRIES=0),让用户手动决定:
167    //   - 真是验证码输错 → 重新运行 `futu-opend --setup-only` 重试一次
168    //   - 需要强制换 device_id → 显式 `--reset-device --setup-only`
169    //
170    // **tty 检测**:prompt_input 已在非 tty 时 fail fast(见 auth/util.rs:38-51),
171    // 避免空验证码毒化 device_id。v1.4.57 外部 #5 A/C 两层根因至此闭环。
172    // v1.4.57 外部 #5:直接调一次 password_auth,不再自动轮换 device_id。
173    // 如 SMS 输错 (ret_type=21),把错误返给 caller,用户再手动 retry。
174    //
175    // v1.4.17-56 的 `MAX_SMS_RETRIES=2` 自动轮换反而触发服务端限流(同一 uid
176    // "5 次不同设备 30 秒内"硬阈值),导致正确码也被 code=1 系统繁忙拒(实锤:
177    // 2026-04-22 外部用户 Telegram SMS 中继场景)。
178    //
179    // 未来若想恢复重试(e.g., CLI flag gated),restore 原 loop + 用
180    // `reset_device_state` + `read_or_generate_device_id` 轮换 device_id。
181    let auth_result = password_auth(
182        &effective_config,
183        region_code.as_deref(),
184        &http,
185        verify_cb.as_deref(),
186        primary_webtcp.as_ref(),
187    )
188    .await?;
189    Ok(attach_primary_auth_site_config(
190        auth_result,
191        primary_webtcp.as_ref(),
192    ))
193}
194
195pub async fn authenticate_with_challenge_port(
196    config: &AuthConfig,
197    port: Arc<dyn AuthChallengePort>,
198) -> Result<AuthSession> {
199    with_auth_challenge_port(port, authenticate_with_callback(config, None)).await
200}
201
202pub async fn with_auth_challenge_port<F>(port: Arc<dyn AuthChallengePort>, future: F) -> F::Output
203where
204    F: std::future::Future,
205{
206    AUTH_CHALLENGE_PORT.scope(port, future).await
207}