1use anyhow::Result;
18use std::sync::Arc;
19
20use futu_domain_auth::{
21 AuthChallengeCommand, AuthChallengeKind, AuthChallengeRequestState, VerificationSecret,
22};
23use futu_gateway_core::bridge::{GatewayBridge, GatewayConfig, PushEvent};
24
25use crate::cli::Platform;
26use crate::config::{RuntimeConfig, app_lang_from_runtime_lang};
27use crate::credentials::resolve_login_password;
28use crate::hints;
29
30pub(super) struct Phase2Out {
35 pub(super) bridge: Arc<GatewayBridge>,
36 pub(super) auth_plan: Option<AuthPlan>,
37 pub(super) setup_only_done: bool,
38}
39
40pub(super) struct AuthPlan {
41 gateway_config: GatewayConfig,
42 verify_code: Option<String>,
43 verification_tty: bool,
44 interactive_retry: Option<InteractiveLoginPrompts>,
45 remember_login: bool,
46 allow_external_verification: bool,
47}
48
49impl AuthPlan {
50 pub(super) fn set_external_verification_available(&mut self, available: bool) {
51 self.allow_external_verification = available;
52 }
53}
54
55#[cfg(test)]
56mod tests;
57
58type VerifyCodePrompt = Arc<dyn Fn() -> Option<String> + Send + Sync>;
59
60struct AbortTaskOnDrop<T>(tokio::task::JoinHandle<T>);
61
62impl<T> Drop for AbortTaskOnDrop<T> {
63 fn drop(&mut self) {
64 self.0.abort();
65 }
66}
67
68fn build_verify_code_callback(
69 explicit_code: Option<String>,
70 stdin_is_terminal: bool,
71 interactive_prompt: VerifyCodePrompt,
72) -> Option<futu_backend::auth::VerifyCodeCallback> {
73 if let Some(code) = explicit_code {
74 tracing::info!("v1.4.57 UX-04: using --verify-code for SMS input (no stdin prompt)");
75 let code = Arc::new(code);
76 let callback = move || -> Option<String> { Some((*code).clone()) };
77 return Some(Box::new(callback));
78 }
79 if !stdin_is_terminal {
80 return None;
81 }
82
83 tracing::info!("interactive terminal available for pending SMS verification input");
84 Some(Box::new(move || {
85 interactive_prompt().and_then(|code| {
86 let code = code.trim();
87 (!code.is_empty()).then(|| code.to_string())
88 })
89 }))
90}
91
92fn prompt_sms_verification_code() -> Option<String> {
93 match rpassword::prompt_password("SMS verification code: ") {
94 Ok(code) => Some(code),
95 Err(error) => {
96 tracing::debug!(
97 error = %error,
98 "failed to read echo-disabled SMS verification code"
99 );
100 None
101 }
102 }
103}
104
105fn prompt_picture_verification_code() -> Option<String> {
106 match rpassword::prompt_password("Picture verification code (see PicVerifyCode.png): ") {
107 Ok(code) => Some(code),
108 Err(error) => {
109 tracing::debug!(
110 error = %error,
111 "failed to read echo-disabled picture verification code"
112 );
113 None
114 }
115 }
116}
117
118type AccountPrompt = Arc<dyn Fn() -> Option<String> + Send + Sync>;
119type PasswordPrompt = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
120type RememberPrompt = Arc<dyn Fn() -> Option<bool> + Send + Sync>;
121
122#[derive(Clone)]
123struct InteractiveLoginPrompts {
124 account: AccountPrompt,
125 password: PasswordPrompt,
126 remember: RememberPrompt,
127}
128
129struct InteractiveCredentials {
130 account: String,
131 password: String,
132 remember: bool,
133}
134
135impl InteractiveLoginPrompts {
136 fn production() -> Self {
137 Self {
138 account: Arc::new(prompt_login_account),
139 password: Arc::new(prompt_login_password),
140 remember: Arc::new(prompt_remember_login),
141 }
142 }
143
144 fn prompt_account(&self) -> Option<String> {
145 (self.account)()
146 .map(|account| account.trim().to_string())
147 .filter(|account| !account.is_empty())
148 }
149
150 fn prompt_credentials(&self) -> Option<InteractiveCredentials> {
151 let account = self.prompt_account()?;
152 let password = (self.password)(&account)?;
153 if password.is_empty() {
154 return None;
155 }
156 let remember = (self.remember)()?;
157 Some(InteractiveCredentials {
158 account,
159 password,
160 remember,
161 })
162 }
163}
164
165fn prompt_login_account() -> Option<String> {
166 eprint!("Login account: ");
167 if let Err(error) = std::io::Write::flush(&mut std::io::stderr()) {
168 tracing::debug!(error = %error, "failed to flush login-account prompt");
169 return None;
170 }
171 let mut account = String::new();
172 match std::io::stdin().read_line(&mut account) {
173 Ok(_) => Some(account),
174 Err(error) => {
175 tracing::debug!(error = %error, "failed to read login account");
176 None
177 }
178 }
179}
180
181fn prompt_login_password(account: &str) -> Option<String> {
182 match rpassword::prompt_password(format!("Login password for account {account}: ")) {
183 Ok(password) if !password.is_empty() => Some(password),
184 Ok(_) => None,
185 Err(error) => {
186 tracing::debug!(error = %error, "failed to read echo-disabled login password");
187 None
188 }
189 }
190}
191
192fn prompt_remember_login() -> Option<bool> {
193 eprint!("Remember login credentials? (y/N) ");
194 if let Err(error) = std::io::Write::flush(&mut std::io::stderr()) {
195 tracing::debug!(error = %error, "failed to flush remember-login prompt");
196 return None;
197 }
198 let mut answer = String::new();
199 match std::io::stdin().read_line(&mut answer) {
200 Ok(_) => Some(parse_remember_answer(&answer)),
201 Err(error) => {
202 tracing::debug!(error = %error, "failed to read remember-login answer");
203 None
204 }
205 }
206}
207
208fn parse_remember_answer(answer: &str) -> bool {
209 matches!(
213 answer.trim().to_ascii_lowercase().as_str(),
214 "y" | "yes" | "1"
215 )
216}
217
218fn apply_interactive_credentials(
219 gateway_config: &mut GatewayConfig,
220 credentials: InteractiveCredentials,
221) -> futu_core::error::Result<bool> {
222 let device_id = futu_backend::auth::read_or_generate_device_id(&credentials.account, None)
223 .map_err(|error| {
224 futu_core::error::FutuError::Codec(format!(
225 "auth device store unavailable for interactive retry: {error}"
226 ))
227 })?;
228 gateway_config.account = credentials.account;
229 gateway_config.password = credentials.password;
230 gateway_config.password_is_md5 = false;
231 gateway_config.device_id = device_id;
232 Ok(credentials.remember)
233}
234
235fn enforce_remember_choice(
236 bridge: &GatewayBridge,
237 plan: &AuthPlan,
238) -> futu_core::error::Result<()> {
239 if plan.remember_login {
240 return Ok(());
241 }
242 if let Err(error) = futu_backend::auth::forget_cached_credentials(&plan.gateway_config.account)
243 {
244 let startup = bridge.startup_readiness().snapshot();
245 if startup.state == futu_server::identity::StartupState::Ready {
246 let _ = bridge.startup_readiness().transition(
247 startup.generation,
248 futu_server::identity::StartupEvent::RetryRequired,
249 );
250 }
251 return Err(futu_core::error::FutuError::Codec(format!(
252 "remember-login opt-out could not remove persisted credentials: {error}"
253 )));
254 }
255 Ok(())
256}
257
258async fn run_startup_challenge_client(
259 runtime: futu_gateway_core::bridge::auth_challenge::AuthChallengeRuntime,
260 mut snapshots: tokio::sync::watch::Receiver<Option<futu_domain_auth::AuthChallengeSnapshot>>,
261 verify_cb: Option<futu_backend::auth::VerifyCodeCallback>,
262 picture_prompt: Option<VerifyCodePrompt>,
263 allow_external_verification: bool,
264) -> futu_core::error::Result<()> {
265 let mut first_snapshot = true;
266 loop {
267 if first_snapshot {
268 first_snapshot = false;
269 } else {
270 snapshots.changed().await.map_err(|_| {
271 futu_core::error::FutuError::Codec(
272 "auth challenge snapshot channel closed".to_string(),
273 )
274 })?;
275 }
276 let Some(snapshot) = snapshots.borrow_and_update().clone() else {
277 continue;
278 };
279 match snapshot.request_state {
280 AuthChallengeRequestState::Requested => {
281 let (generation, code) = match snapshot.kind {
282 AuthChallengeKind::Picture => {
283 let Some(prompt) = picture_prompt.as_deref() else {
284 if allow_external_verification {
287 continue;
288 }
289 let _ = runtime
290 .submit(snapshot.generation, AuthChallengeCommand::Cancel)
291 .await;
292 return Err(futu_core::error::FutuError::ServerError {
293 ret_type: 11,
294 msg: "picture verification requires an interactive or public Verification owner"
295 .to_string(),
296 });
297 };
298 let requested = runtime
299 .submit(snapshot.generation, AuthChallengeCommand::RequestCode)
300 .await?;
301 (requested.generation, prompt())
302 }
303 AuthChallengeKind::DeviceSms
304 | AuthChallengeKind::DeviceVoice
305 | AuthChallengeKind::DeviceEmail => {
306 let Some(callback) = verify_cb.as_deref() else {
307 if allow_external_verification {
310 continue;
311 }
312 let _ = runtime
313 .submit(snapshot.generation, AuthChallengeCommand::Cancel)
314 .await;
315 return Err(futu_core::error::FutuError::SmsVerificationCodeRequired);
316 };
317 (snapshot.generation, callback())
318 }
319 };
320 let Some(code) = code else {
321 let _ = runtime
322 .submit(generation, AuthChallengeCommand::Cancel)
323 .await;
324 return Err(futu_core::error::FutuError::ServerError {
325 ret_type: -1,
326 msg: "verification cancelled by user".to_string(),
327 });
328 };
329 match runtime
330 .submit(
331 generation,
332 AuthChallengeCommand::SubmitCode(VerificationSecret::new(code)),
333 )
334 .await
335 {
336 Ok(authenticated)
337 if authenticated.request_state
338 == AuthChallengeRequestState::Authenticated =>
339 {
340 return Ok(());
341 }
342 Ok(_) => {}
343 Err(error) => {
344 let _ = runtime
345 .submit(generation, AuthChallengeCommand::Cancel)
346 .await;
347 return Err(error);
348 }
349 }
350 }
351 AuthChallengeRequestState::Authenticated => return Ok(()),
352 AuthChallengeRequestState::Cancelled | AuthChallengeRequestState::Failed => {
353 return Err(futu_core::error::FutuError::Codec(
354 "auth challenge terminated".to_string(),
355 ));
356 }
357 AuthChallengeRequestState::Pending
358 | AuthChallengeRequestState::Submitted
359 | AuthChallengeRequestState::Replaced => {}
360 }
361 }
362}
363
364pub(super) async fn execute_auth_plan(
365 bridge: Arc<GatewayBridge>,
366 mut plan: AuthPlan,
367) -> futu_core::error::Result<tokio::sync::mpsc::Receiver<PushEvent>> {
368 loop {
369 let challenge_runtime = bridge.auth_challenge_runtime().clone();
370 let challenge_attempt = challenge_runtime.open_attempt()?;
371 let challenge_attempt_id = challenge_attempt.attempt_id();
372 let challenge_port: Arc<dyn futu_backend::auth::AuthChallengePort> =
373 Arc::new(challenge_attempt);
374 let verify_cb = build_verify_code_callback(
375 plan.verify_code.clone(),
376 plan.verification_tty,
377 Arc::new(prompt_sms_verification_code),
378 );
379 let challenge_snapshots = challenge_runtime.subscribe();
380 let mut challenge_client = AbortTaskOnDrop(tokio::spawn(run_startup_challenge_client(
381 challenge_runtime.clone(),
382 challenge_snapshots,
383 verify_cb,
384 plan.verification_tty
385 .then(|| Arc::new(prompt_picture_verification_code) as VerifyCodePrompt),
386 plan.allow_external_verification,
387 )));
388 let prepare_result = futu_backend::auth::with_auth_challenge_port(
389 challenge_port,
390 bridge.prepare_http_auth(&plan.gateway_config, None),
391 )
392 .await;
393 challenge_runtime.close_attempt(challenge_attempt_id).await;
397 enforce_remember_choice(bridge.as_ref(), &plan)?;
401 match prepare_result {
402 Ok(prepared) => {
403 challenge_client.0.abort();
404 let push_rx = match prepared {
405 futu_gateway_core::bridge::Phase1Outcome::SetupOnlyDone(push_rx) => push_rx,
406 futu_gateway_core::bridge::Phase1Outcome::Continue(prepared) => {
407 bridge
412 .initialize_after_http_auth(&plan.gateway_config, *prepared)
413 .await?
414 }
415 };
416 return Ok(push_rx);
417 }
418 Err(mut error) => {
419 if let Ok(joined) = tokio::time::timeout(
420 std::time::Duration::from_millis(100),
421 &mut challenge_client.0,
422 )
423 .await
424 && let Ok(Err(client_error)) = joined
425 {
426 error = client_error;
427 } else {
428 challenge_client.0.abort();
429 }
430
431 let Some(prompts) = plan.interactive_retry.as_ref() else {
432 return Err(error);
433 };
434 let startup = bridge.startup_readiness().snapshot();
435 if startup.state == futu_server::identity::StartupState::Authenticating {
436 bridge
437 .startup_readiness()
438 .transition(
439 startup.generation,
440 futu_server::identity::StartupEvent::AuthFailed,
441 )
442 .map_err(|transition| {
443 futu_core::error::FutuError::Codec(format!(
444 "interactive auth retry transition failed: {transition:?}"
445 ))
446 })?;
447 }
448 eprintln!(
449 "Authentication failed. Re-enter the login account, or press Enter to stop."
450 );
451 let Some(credentials) = prompts.prompt_credentials() else {
452 return Err(error);
453 };
454 plan.remember_login =
455 apply_interactive_credentials(&mut plan.gateway_config, credentials)?;
456 let retry = bridge.startup_readiness().snapshot();
457 bridge
458 .startup_readiness()
459 .transition(
460 retry.generation,
461 futu_server::identity::StartupEvent::BeginAuthentication,
462 )
463 .map_err(|transition| {
464 futu_core::error::FutuError::Codec(format!(
465 "interactive auth retry begin failed: {transition:?}"
466 ))
467 })?;
468 }
469 }
470 }
471}
472
473pub(super) async fn run_phase2(
474 config: &RuntimeConfig,
475 listen_addr: &str,
476 _inject_auth_failure_every: Option<u64>,
477) -> Result<Phase2Out> {
478 let bridge = Arc::new(GatewayBridge::new_pending_auth());
480 let mut auth_plan = None;
481
482 let stdin_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
497 let interactive_login = stdin_is_terminal.then(InteractiveLoginPrompts::production);
498 let prompted_account = config.login_account.is_none();
499 let login_account = config.login_account.clone().or_else(|| {
500 interactive_login
501 .as_ref()
502 .and_then(InteractiveLoginPrompts::prompt_account)
503 });
504 let (password, password_is_md5) =
505 resolve_login_password(login_account.as_deref(), config)?.unwrap_or((None, false));
506 let remember_login = if prompted_account && password.is_some() {
507 interactive_login
508 .as_ref()
509 .and_then(|prompts| (prompts.remember)())
510 .unwrap_or(false)
511 } else {
512 true
513 };
514
515 let cached_credentials_available = login_account
516 .as_deref()
517 .is_some_and(|account| futu_backend::auth::credential_ticket_status(account).is_some());
518 let should_attempt_auth = should_attempt_startup_auth(
519 login_account.is_some(),
520 password.is_some(),
521 cached_credentials_available,
522 config.setup_only,
523 config.reset_device,
524 );
525
526 if should_attempt_auth && let Some(account) = &login_account {
527 let password = password.clone().unwrap_or_default();
528 if config.reset_device {
534 let confirm = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
541 eprintln!();
542 eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
543 eprintln!("⚠️ --reset-device: 即将**删除**以下文件:");
544 eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
545 eprintln!(" ~/.futu-opend-rs/device-{{hash}}.dat (device_id 持久化)");
546 eprintln!(" ~/.futu-opend-rs/credentials-{{hash}}.json (remember-login 凭证)");
547 eprintln!();
548 eprintln!(" 执行后下次启动必须重新走 SMS 验证流程。");
549 eprintln!(" 适用场景:device_id 被服务端锁定(ret_type=15/21 无法恢复)。");
550 eprintln!();
551 eprint!(" 继续?(y/N) ");
552 if let Err(err) = std::io::Write::flush(&mut std::io::stderr()) {
553 tracing::debug!(
554 error = %err,
555 "failed to flush reset-device confirmation prompt"
556 );
557 }
558 let mut answer = String::new();
559 if std::io::stdin().read_line(&mut answer).is_err() {
560 false
561 } else {
562 matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
563 }
564 } else {
565 tracing::warn!(
567 "⚠️ --reset-device running in non-interactive mode; skipping \
568 confirmation prompt (proceeding with destructive reset)"
569 );
570 true
571 };
572
573 if confirm {
574 match futu_backend::auth::reset_device_state(account) {
575 Ok(()) => tracing::info!(
576 "⚠️ --reset-device: deleted device_id + credentials files, \
577 will start fresh (SMS verification required)"
578 ),
579 Err(e) => {
580 tracing::warn!(error = %e, "reset_device failed (non-fatal)")
581 }
582 }
583 } else {
584 eprintln!();
585 eprintln!("已取消 --reset-device(未做任何修改)。");
586 eprintln!("若只想验证 device_id 当前值而不重置,请用:`ls ~/.futu-opend-rs/`");
587 tracing::info!("--reset-device aborted by user via interactive prompt");
588 return Ok(Phase2Out {
589 bridge: Arc::clone(&bridge),
590 auth_plan: None,
591 setup_only_done: true,
592 });
593 }
594 }
595 let device_id =
600 futu_backend::auth::read_or_generate_device_id(account, config.device_id.as_deref())
601 .map_err(|err| {
602 anyhow::anyhow!(
603 "auth device store unavailable: {err}. \
604 daemon needs a writable 0700 credentials directory before login"
605 )
606 })?;
607 tracing::info!(
608 account_fp = %futu_backend::auth::redact::account_log_fingerprint(account),
609 device_id_fp = %futu_backend::auth::redact::device_id_log_fingerprint(&device_id),
610 platform = config.platform.name(),
611 auth_server = %config.auth_server,
612 "login credentials"
613 );
614 let app_lang = app_lang_from_runtime_lang(&config.lang);
615 let gw_config = GatewayConfig {
616 auth_server: config.auth_server.clone(),
617 account: account.clone(),
618 password,
619 password_is_md5,
620 region: config.login_region.clone(),
621 listen_addr: listen_addr.to_string(),
622 device_id,
623 app_lang,
624 client_type: match config.platform {
628 Platform::Futunn => 40,
629 Platform::Moomoo => 60,
630 },
631 setup_only: config.setup_only,
632 client_sig_proactive_refresh: config.client_sig_proactive_refresh,
633 client_sig_reactive_refresh: config.client_sig_reactive_refresh,
634 history_kline_cloud_sync: config.history_kline_cloud_sync,
635 };
636
637 let plan = AuthPlan {
654 gateway_config: gw_config,
655 verify_code: config.verify_code.clone(),
656 verification_tty: stdin_is_terminal,
657 interactive_retry: interactive_login,
658 remember_login,
659 allow_external_verification: false,
663 };
664
665 if config.setup_only {
666 let remember_login = plan.remember_login;
667 match execute_auth_plan(Arc::clone(&bridge), plan).await {
668 Ok(_) => {
669 if remember_login {
670 tracing::info!(
671 "✅ --setup-only: authentication succeeded and credentials cached. \
672 Exiting. You can now start futu-opend in production."
673 );
674 } else {
675 tracing::info!(
676 "✅ --setup-only: authentication succeeded for this session; \
677 remember-login was declined, so no credentials remain cached."
678 );
679 }
680 return Ok(Phase2Out {
681 bridge: Arc::clone(&bridge),
682 auth_plan: None,
683 setup_only_done: true,
684 });
685 }
686 Err(error) => {
687 hints::print_auth_error_hint(&error, config);
688 return Err(startup_auth_failure_error(true, error));
689 }
690 }
691 }
692 auth_plan = Some(plan);
693 } else {
694 if config.setup_only {
695 return Err(anyhow::anyhow!(
696 "--setup-only requires --login-account and --login-pwd"
697 ));
698 }
699 tracing::warn!("no login credentials provided, starting in offline mode");
700 tracing::warn!("use --login-account and --login-pwd to connect to backend");
701 let pending = bridge.startup_readiness().snapshot();
702 let authenticating = bridge
703 .startup_readiness()
704 .transition(
705 pending.generation,
706 futu_server::identity::StartupEvent::BeginAuthentication,
707 )
708 .map_err(|error| anyhow::anyhow!("offline startup transition: {error:?}"))?;
709 bridge
710 .startup_readiness()
711 .transition(
712 authenticating.generation,
713 futu_server::identity::StartupEvent::Authenticated {
714 user_id: 0,
715 attribution: None,
716 },
717 )
718 .map_err(|error| anyhow::anyhow!("offline ready transition: {error:?}"))?;
719 }
720
721 #[cfg(feature = "dev-flags")]
728 if let Some(inject_secs) = _inject_auth_failure_every {
729 if inject_secs == 0 {
730 tracing::warn!("v1.4.97 P1-D-C: --inject-auth-failure-every=0 ignored (must be > 0)");
731 } else {
732 tracing::warn!(
733 inject_secs,
734 "v1.4.97 P1-D-C: DEV-ONLY auth-failure injection ENABLED — \
735 will clear login_cache every {}s to trigger P1-D self-heal \
736 ladder. DO NOT USE IN PRODUCTION.",
737 inject_secs
738 );
739 let bridge_for_inject = std::sync::Arc::clone(&bridge);
740 tokio::spawn(async move {
741 let mut ticker = tokio::time::interval(std::time::Duration::from_secs(inject_secs));
742 ticker.tick().await; loop {
744 ticker.tick().await;
745 tracing::warn!("v1.4.97 P1-D-C: injecting qot_logined=false (DEV-ONLY)");
746 bridge_for_inject.caches().login_cache.clear();
747 }
748 });
749 }
750 }
751
752 Ok(Phase2Out {
753 bridge,
754 auth_plan,
755 setup_only_done: false,
756 })
757}
758
759fn startup_auth_failure_error(
760 setup_only: bool,
761 error: futu_core::error::FutuError,
762) -> anyhow::Error {
763 let error = anyhow::Error::new(error);
764 if setup_only {
765 error.context("--setup-only: auth failed")
766 } else {
767 error.context("startup authentication failed; public surfaces were not started")
768 }
769}
770
771fn should_attempt_startup_auth(
772 has_login_account: bool,
773 has_password: bool,
774 has_cached_credentials: bool,
775 setup_only: bool,
776 reset_device: bool,
777) -> bool {
778 has_login_account && (has_password || (!setup_only && !reset_device && has_cached_credentials))
779}