1use std::fmt::Debug;
2use std::marker::PhantomData;
3use std::ops::Deref;
4use std::sync::Arc;
5use std::time::Duration;
6
7use backoff::future::retry;
8use base64::Engine;
9use cookie::Cookie;
10use cookie::CookieJar;
11use futures::TryFutureExt;
12use futures_timer::Delay;
13use parking_lot::RwLock;
14use reqwest::header::HeaderMap;
15use reqwest::header::HeaderValue;
16use reqwest::header::CONTENT_TYPE;
17use reqwest::redirect::Policy;
18use reqwest::Client;
19use reqwest::IntoUrl;
20use reqwest::Method;
21use reqwest::Response;
22use reqwest::Url;
23use scraper::Html;
24use serde::de::DeserializeOwned;
25use serde::Serialize;
26use steam_protobuf::ProtobufDeserialize;
27use steam_protobuf::ProtobufSerialize;
28use tracing::debug;
29use tracing::error;
30use tracing::info;
31use tracing::trace;
32use tracing::warn;
33
34use crate::adapter::SteamCookie;
35use crate::errors::AuthError;
36use crate::errors::InternalError;
37use crate::errors::LinkerError;
38use crate::retry::login_retry_strategy;
39use crate::user::IsUser;
40use crate::user::PresentMaFile;
41use crate::user::SteamUser;
42use crate::utils::dump_cookies_by_domain;
43use crate::utils::dump_cookies_by_domain_and_name;
44use crate::utils::retrieve_header_location;
45use crate::web_handler::cache_api_key;
46use crate::web_handler::confirmation::Confirmation;
47use crate::web_handler::confirmation::Confirmations;
48use crate::web_handler::get_confirmations;
49use crate::web_handler::login::login_and_store_cookies;
50use crate::web_handler::send_confirmations;
51use crate::web_handler::steam_guard_linker::account_has_phone;
52use crate::web_handler::steam_guard_linker::add_authenticator_to_account;
53use crate::web_handler::steam_guard_linker::add_phone_to_account;
54use crate::web_handler::steam_guard_linker::check_email_confirmation;
55use crate::web_handler::steam_guard_linker::check_sms;
56use crate::web_handler::steam_guard_linker::finalize;
57use crate::web_handler::steam_guard_linker::remove_authenticator;
58use crate::web_handler::steam_guard_linker::twofactor_status;
59use crate::web_handler::steam_guard_linker::validate_phone_number;
60use crate::web_handler::steam_guard_linker::AddAuthenticatorStep;
61use crate::web_handler::steam_guard_linker::QueryStatusResponse;
62use crate::web_handler::steam_guard_linker::RemoveAuthenticatorScheme;
63use crate::web_handler::steam_guard_linker::STEAM_ADD_PHONE_CATCHUP_SECS;
64use crate::CacheGuard;
65use crate::ConfirmationAction;
66use crate::MobileAuthFile;
67use crate::STEAM_COMMUNITY_HOST;
68
69#[derive(Debug)]
80pub struct SteamAuthenticator<AuthState, MaFileState> {
81 inner: InnerAuthenticator<MaFileState>,
82 auth_level: PhantomData<AuthState>,
83}
84
85#[derive(Debug)]
86struct InnerAuthenticator<MaFileState> {
87 pub(crate) client: MobileClient,
88 pub(crate) user: SteamUser<MaFileState>,
89 pub(crate) cache: Option<CacheGuard>,
90}
91
92#[derive(Clone, Copy, Debug)]
94pub struct Authenticated;
95
96#[derive(Clone, Copy, Debug)]
98pub struct Unauthenticated;
99
100impl<AuthState, M> SteamAuthenticator<AuthState, M> {
101 const fn client(&self) -> &MobileClient {
102 &self.inner.client
103 }
104 const fn user(&self) -> &SteamUser<M> {
105 &self.inner.user
106 }
107}
108
109impl<MaFileState> SteamAuthenticator<Unauthenticated, MaFileState>
110where
111 MaFileState: 'static + Send + Sync + Clone,
112{
113 #[must_use]
117 pub fn new(user: SteamUser<MaFileState>) -> Self {
118 Self {
119 inner: InnerAuthenticator {
120 client: MobileClient::default(),
121 user,
122 cache: None,
123 },
124 auth_level: PhantomData::<Unauthenticated>,
125 }
126 }
127 pub async fn login(self) -> Result<SteamAuthenticator<Authenticated, MaFileState>, AuthError> {
141 let user = self.inner.user;
142 let client = self.inner.client;
143 let user_arc: Arc<dyn IsUser> = Arc::new(user.clone());
144
145 let mut cache = retry(login_retry_strategy(), || async {
147 login_and_store_cookies(&client, user_arc.clone())
148 .await
149 .map_err(|error| match error {
150 e => {
151 warn!("Permanent error happened.");
152 warn!("{e}");
153 backoff::Error::permanent(e)
154 }
155 })
156 })
157 .await?;
158 info!("Login to Steam successfully.");
159
160 let api_key = cache_api_key(&client, user_arc.clone(), cache.steamid.to_steam64()).await;
167 if let Some(api_key) = api_key {
168 cache.set_api_key(Some(api_key));
169 info!("Cached API Key successfully.");
170 }
171
172 Ok(SteamAuthenticator {
173 inner: InnerAuthenticator {
174 client,
175 user,
176 cache: Some(Arc::new(RwLock::new(cache))),
177 },
178 auth_level: PhantomData,
179 })
180 }
181}
182
183impl<M> SteamAuthenticator<Authenticated, M>
184where
185 M: Send + Sync,
186{
187 fn cache(&self) -> CacheGuard {
188 self.inner.cache.as_ref().expect("Safe to unwrap.").clone()
189 }
190
191 pub fn api_key(&self) -> Option<String> {
193 self.inner
194 .cache
195 .as_ref()
196 .expect("Safe to unwrap")
197 .read()
198 .api_key()
199 .map(ToString::to_string)
200 }
201
202 pub async fn steam_guard_status(&self) -> Result<QueryStatusResponse, AuthError> {
204 twofactor_status(self.client(), self.cache()).await.map_err(Into::into)
205 }
206
207 pub async fn add_authenticator(
223 &self,
224 current_step: AddAuthenticatorStep,
225 phone_number: &str,
226 ) -> Result<AddAuthenticatorStep, AuthError> {
227 let user_has_phone_registered = account_has_phone(self.client()).await?;
228 debug!("Has phone registered? {:?}", user_has_phone_registered);
229
230 if !user_has_phone_registered && current_step == AddAuthenticatorStep::InitialStep {
231 let phone_registration_result = self.add_phone_number(phone_number).await?;
232 debug!("User add phone result: {:?}", phone_registration_result);
233
234 return Ok(AddAuthenticatorStep::EmailConfirmation);
235 }
236
237 if !user_has_phone_registered {
240 check_email_confirmation(self.client()).await?;
241 debug!("Email confirmation signal sent.");
242 }
243
244 add_authenticator_to_account(self.client(), self.cache().read())
245 .await
246 .map(AddAuthenticatorStep::MobileAuth)
247 .map_err(Into::into)
248 }
249
250 pub async fn finalize_authenticator(&self, mafile: &MobileAuthFile, sms_code: &str) -> Result<(), AuthError> {
258 let account_has_phone_now: bool = check_sms(self.client(), sms_code)
260 .map_ok(|_| Delay::new(Duration::from_secs(STEAM_ADD_PHONE_CATCHUP_SECS)))
261 .and_then(|_| account_has_phone(self.client()))
262 .await?;
263
264 if !account_has_phone_now {
265 return Err(LinkerError::GeneralFailure("This should not happen.".to_string()).into());
266 }
267
268 info!("Successfully confirmed SMS code.");
269
270 finalize(self.client(), self.cache().read(), mafile, sms_code)
271 .await
272 .map_err(Into::into)
273 }
274
275 pub async fn remove_authenticator(
279 &self,
280 revocation_code: &str,
281 remove_authenticator_scheme: RemoveAuthenticatorScheme,
282 ) -> Result<(), AuthError> {
283 remove_authenticator(
284 self.client(),
285 self.cache().read(),
286 revocation_code,
287 remove_authenticator_scheme,
288 )
289 .await
290 }
291
292 async fn add_phone_number(&self, phone_number: &str) -> Result<bool, AuthError> {
295 if !validate_phone_number(phone_number) {
296 return Err(LinkerError::GeneralFailure(
297 "Invalid phone number. Should be in format of: +(CountryCode)(AreaCode)(PhoneNumber). E.g \
298 +5511976914922"
299 .to_string(),
300 )
301 .into());
302 }
303
304 let response = add_phone_to_account(self.client(), phone_number).await?;
307 Delay::new(Duration::from_secs(STEAM_ADD_PHONE_CATCHUP_SECS)).await;
308
309 Ok(response)
310 }
311
312 pub async fn request_custom_endpoint<T>(
317 &self,
318 url: String,
319 method: Method,
320 custom_headers: Option<HeaderMap>,
321 data: Option<T>,
322 ) -> Result<Response, InternalError>
323 where
324 T: Serialize + Send + Sync,
325 {
326 self.client()
327 .request_with_session_guard(url, method, custom_headers, data, None::<&str>)
328 .await
329 }
330
331 #[allow(missing_docs)]
332 pub fn dump_cookie(&self, steam_domain_host: &str, steam_cookie_name: &str) -> Option<String> {
333 dump_cookies_by_domain_and_name(&self.client().cookie_store.read(), steam_domain_host, steam_cookie_name)
334 }
335}
336
337impl SteamAuthenticator<Authenticated, PresentMaFile> {
338 pub async fn fetch_confirmations(&self) -> Result<Confirmations, AuthError> {
340 let steamid = self.cache().read().steam_id();
341 let secret = (&self.inner.user).identity_secret();
342 let device_id = (&self.inner.user).device_id();
343
344 get_confirmations(self.client(), secret, device_id, steamid)
345 .err_into()
346 .await
347 }
348
349 pub async fn handle_confirmations<'a, 'b, F>(&self, operation: ConfirmationAction, f: F) -> Result<(), AuthError>
353 where
354 F: Fn(Confirmations) -> Box<dyn Iterator<Item = Confirmation> + Send> + Send,
355 {
356 let confirmations = self.fetch_confirmations().await?;
357 if !confirmations.is_empty() {
358 self.process_confirmations(operation, f(confirmations)).await
359 } else {
360 Ok(())
361 }
362 }
363
364 pub async fn process_confirmations<I>(
369 &self,
370 operation: ConfirmationAction,
371 confirmations: I,
372 ) -> Result<(), AuthError>
373 where
374 I: IntoIterator<Item = Confirmation> + Send,
375 {
376 let steamid = self.cache().read().steam_id();
377
378 send_confirmations(
379 self.client(),
380 self.user().identity_secret(),
381 self.user().device_id(),
382 steamid,
383 operation,
384 confirmations,
385 )
386 .await
387 .map_err(Into::into)
388 }
389}
390
391#[derive(Debug)]
392pub struct MobileClient {
393 pub inner_http_client: Client,
395 pub cookie_store: Arc<RwLock<CookieJar>>,
397}
398
399impl MobileClient {
400 pub(crate) fn get_cookie_value(&self, domain: &str, name: &str) -> Option<String> {
401 dump_cookies_by_domain_and_name(&self.cookie_store.read(), domain, name)
402 }
403 pub(crate) fn set_cookie_value(&self, cookie: Cookie<'static>) {
404 self.cookie_store.write().add_original(cookie);
405 }
406
407 pub(crate) async fn request_proto<INPUT, OUTPUT>(
408 &self,
409 url: impl IntoUrl + Send,
410 method: Method,
411 proto_message: INPUT,
412 _token: Option<&str>,
413 ) -> Result<OUTPUT, InternalError>
414 where
415 INPUT: ProtobufSerialize,
416 OUTPUT: ProtobufDeserialize<Output = OUTPUT> + Debug,
417 {
418 let url = url.into_url().unwrap();
419 debug!("Request url: {}", url);
420 let request_builder = self.inner_http_client.request(method.clone(), url);
421
422 let req = if method == Method::GET {
423 let encoded = base64::engine::general_purpose::URL_SAFE.encode(proto_message.to_bytes().unwrap());
424 let parameters = &[("input_protobuf_encoded", encoded)];
425 request_builder.query(parameters)
426 } else if method == Method::POST {
427 let encoded = base64::engine::general_purpose::STANDARD.encode(proto_message.to_bytes().unwrap());
428 debug!("Request proto body: {:?}", encoded);
429 let form = reqwest::multipart::Form::new().text("input_protobuf_encoded", encoded);
430 request_builder.multipart(form)
431 } else {
432 return Err(InternalError::GeneralFailure("Unsupported Method".to_string()));
433 };
434
435 let response = req.send().await?;
436 debug!("Response {:?}", response);
437
438 let res_bytes = response.bytes().await?;
439 OUTPUT::from_bytes(res_bytes).map_or_else(
440 |_| {
441 error!("Failed deserializing {}", std::any::type_name::<OUTPUT>());
442 Err(InternalError::GeneralFailure("asdfd".to_string()))
443 },
444 |res| {
445 debug!("Response body {:?}", res);
446 Ok(res)
447 },
448 )
449 }
450
451 pub(crate) async fn request_with_session_guard<T, QP, U>(
453 &self,
454 url: U,
455 method: Method,
456 custom_headers: Option<HeaderMap>,
457 data: Option<T>,
458 query_params: Option<QP>,
459 ) -> Result<Response, InternalError>
460 where
461 T: Serialize + Send,
462 QP: Serialize + Send,
463 U: IntoUrl + Send,
464 {
465 if self.session_is_expired().await? {
467 warn!("Session was lost. Trying to reconnect.");
468 unimplemented!()
469 };
470
471 self.request(url, method, custom_headers, data, query_params)
472 .err_into()
473 .await
474 }
475 pub(crate) async fn request_with_session_guard_and_decode<T, QP, OUTPUT>(
476 &self,
477 url: String,
478 method: Method,
479 custom_headers: Option<HeaderMap>,
480 data: Option<T>,
481 query_params: Option<QP>,
482 ) -> Result<OUTPUT, InternalError>
483 where
484 T: Serialize + Send + Sync,
485 QP: Serialize + Send + Sync,
486 OUTPUT: DeserializeOwned,
487 {
488 let req = self
489 .request_with_session_guard(url, method, custom_headers, data.as_ref(), query_params)
490 .await?;
491
492 let response_body = req
493 .text()
494 .inspect_ok(|s| {
495 debug!("{} text: {}", std::any::type_name::<OUTPUT>(), s);
496 })
497 .await?;
498
499 serde_json::from_str::<OUTPUT>(&response_body).map_err(InternalError::DeserializationError)
500 }
501
502 pub(crate) async fn request<T, QS, U>(
504 &self,
505 url: U,
506 method: Method,
507 headers: Option<HeaderMap>,
508 form_data: Option<T>,
509 query_params: QS,
510 ) -> Result<Response, InternalError>
511 where
512 QS: Serialize + Send,
513 T: Serialize + Send,
514 U: IntoUrl + Send,
515 {
516 let parsed_url = url
517 .into_url()
518 .map_err(|_| InternalError::GeneralFailure("Couldn't parse passed URL. Insert a valid one.".to_string()))?;
519 let mut header_map = headers.unwrap_or_default();
520
521 let domain_cookies = dump_cookies_by_domain(&self.cookie_store.read(), parsed_url.host_str().unwrap());
522 header_map.insert(
523 reqwest::header::COOKIE,
524 domain_cookies.unwrap_or_default().parse().unwrap(),
525 );
526
527 let req_builder = self
528 .inner_http_client
529 .request(method, parsed_url)
530 .headers(header_map)
531 .query(&query_params);
532
533 let request = match form_data {
534 None => req_builder.build().unwrap(),
535 Some(data) => match serde_urlencoded::to_string(data) {
536 Ok(body) => {
537 debug!("Request body: {}", &body);
538 req_builder
539 .header(
540 CONTENT_TYPE,
541 HeaderValue::from_static("application/x-www-form-urlencoded; charset=UTF-8"),
542 )
543 .body(body)
544 .build()
545 .expect("Safe to unwrap.")
546 }
547 Err(err) => {
548 return Err(InternalError::GeneralFailure(format!(
549 "Failed to serialize body: {err}"
550 )))
551 }
552 },
553 };
554 debug!("{:?}", &request);
555
556 let res = self.inner_http_client.execute(request).err_into().await;
557 if let Ok(ref response) = res {
558 debug!("Response status: {:?}", response.status());
559 debug!("Response headers: {:?}", response.headers());
560
561 let mut cookie_jar = self.cookie_store.write();
562 for cookie in response.cookies() {
563 let mut our_cookie = SteamCookie::from(cookie);
564 let host = response.url().host().expect("Safe.").to_string();
565 our_cookie.set_domain(host);
566
567 trace!(
568 "New cookie from: {:?}, name: {}, value: {} ",
569 our_cookie.domain(),
570 our_cookie.name(),
571 our_cookie.value()
572 );
573 cookie_jar.add_original(our_cookie.deref().clone());
574 }
575 }
576 res
577 }
578
579 pub(crate) async fn request_and_decode<T, OUTPUT, QS, U>(
580 &self,
581 url: U,
582 method: Method,
583 headers: Option<HeaderMap>,
584 form_data: Option<T>,
585 query_params: QS,
586 ) -> Result<OUTPUT, InternalError>
587 where
588 OUTPUT: DeserializeOwned,
589 QS: Serialize + Send + Sync,
590 T: Serialize + Send + Sync,
591 U: IntoUrl + Send,
592 {
593 let resp = self.request(url, method, headers, form_data, query_params).await?;
594 let response_body = resp
595 .text()
596 .inspect_ok(|s| {
597 debug!("{} text: {}", std::any::type_name::<OUTPUT>(), s);
598 })
599 .await?;
600
601 serde_json::from_str::<OUTPUT>(&response_body).map_err(InternalError::DeserializationError)
602 }
603
604 async fn session_is_expired(&self) -> Result<bool, InternalError> {
610 let account_url = format!("{}/account", crate::STEAM_STORE_BASE);
611
612 let response = self
614 .request(account_url, Method::HEAD, None, None::<u8>, None::<u8>)
615 .await?;
616
617 if let Some(location) = retrieve_header_location(&response) {
618 return Ok(Url::parse(location).map(Self::url_expired_check).unwrap());
619 }
620 Ok(false)
621 }
622
623 fn url_expired_check(redirect_url: Url) -> bool {
625 redirect_url.host_str().unwrap() == "lostauth" || redirect_url.path().starts_with("/login")
626 }
627
628 pub(crate) async fn get_html<T, QS>(
630 &self,
631 url: T,
632 headers: Option<HeaderMap>,
633 query: Option<QS>,
634 ) -> Result<Html, InternalError>
635 where
636 T: IntoUrl + Send,
637 QS: Serialize + Send,
638 {
639 self.request_with_session_guard(url, Method::GET, headers, None::<&str>, query)
640 .and_then(|r| r.text().err_into())
641 .await
642 .map(|s| Html::parse_document(&s))
643 }
644
645 fn reset_jar(&mut self) {
647 self.cookie_store = Arc::new(RwLock::new(CookieJar::new()));
648 }
649
650 fn standard_mobile_cookies() -> Vec<Cookie<'static>> {
652 vec![
653 Cookie::build("Steam_Language", "english")
654 .domain(STEAM_COMMUNITY_HOST)
655 .finish(),
656 Cookie::build("mobileClient", "android")
657 .domain(STEAM_COMMUNITY_HOST)
658 .finish(),
659 Cookie::build("mobileClientVersion", "0 (2.1.3)")
660 .domain(STEAM_COMMUNITY_HOST)
661 .finish(),
662 ]
663 }
664
665 fn init_cookie_jar() -> CookieJar {
667 let mut mobile_cookies = CookieJar::new();
668 Self::standard_mobile_cookies()
669 .into_iter()
670 .for_each(|cookie| mobile_cookies.add(cookie));
671 mobile_cookies
672 }
673
674 fn init_mobile_client() -> Client {
676 let user_agent = "Dalvik/2.1.0 (Linux; U; Android 9; Valve Steam App Version/3)";
677 let mut default_headers = HeaderMap::new();
678 default_headers.insert(
679 reqwest::header::ACCEPT,
680 "text/javascript, text/html, application/xml, text/xml, */*"
681 .parse()
682 .unwrap(),
683 );
684 default_headers.insert(reqwest::header::REFERER, crate::MOBILE_REFERER.parse().unwrap());
685 default_headers.insert(
686 "X-Requested-With",
687 "com.valvesoftware.android.steam.community".parse().unwrap(),
688 );
689
690 reqwest::Client::builder()
691 .user_agent(user_agent)
692 .cookie_store(true)
693 .redirect(Policy::limited(5))
694 .default_headers(default_headers)
695 .referer(false)
696 .build()
697 .unwrap()
698 }
699}
700
701impl Default for MobileClient {
702 fn default() -> Self {
703 Self {
704 inner_http_client: Self::init_mobile_client(),
705 cookie_store: Arc::new(RwLock::new(Self::init_cookie_jar())),
706 }
707 }
708}