diff --git a/Cargo.lock b/Cargo.lock index ab1ad08..f109ac8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1625,6 +1625,7 @@ dependencies = [ "serde", "serde_json", "standback", + "thiserror", "uuid", ] @@ -1781,6 +1782,26 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" +[[package]] +name = "thiserror" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "0.3.4" diff --git a/src/accountlinker.rs b/src/accountlinker.rs deleted file mode 100644 index 5daf521..0000000 --- a/src/accountlinker.rs +++ /dev/null @@ -1,87 +0,0 @@ -use log::*; -use reqwest::{cookie::CookieStore, header::COOKIE, Url}; -use serde::Deserialize; -use serde_json::Value; -use std::collections::HashMap; -use steamguard::{steamapi::Session, SteamGuardAccount}; - -#[derive(Debug, Clone)] -pub struct AccountLinker { - device_id: String, - phone_number: String, - pub account: SteamGuardAccount, - client: reqwest::blocking::Client, -} - -impl AccountLinker { - pub fn new() -> AccountLinker { - return AccountLinker { - device_id: generate_device_id(), - phone_number: String::from(""), - account: SteamGuardAccount::new(), - client: reqwest::blocking::ClientBuilder::new() - .cookie_store(true) - .build() - .unwrap(), - }; - } - - pub fn link(&self, session: &mut Session) { - let mut params = HashMap::new(); - params.insert("access_token", session.token.clone()); - params.insert("steamid", session.steam_id.to_string()); - params.insert("device_identifier", self.device_id.clone()); - params.insert("authenticator_type", String::from("1")); - params.insert("sms_phone_id", String::from("1")); - } - - fn has_phone(&self, session: &Session) -> bool { - return self._phoneajax(session, "has_phone", "null"); - } - - fn _phoneajax(&self, session: &Session, op: &str, arg: &str) -> bool { - trace!("_phoneajax: op={}", op); - let url = "https://steamcommunity.com".parse::().unwrap(); - let cookies = reqwest::cookie::Jar::default(); - cookies.add_cookie_str("mobileClientVersion=0 (2.1.3)", &url); - cookies.add_cookie_str("mobileClient=android", &url); - cookies.add_cookie_str("Steam_Language=english", &url); - - let mut params = HashMap::new(); - params.insert("op", op); - params.insert("arg", arg); - params.insert("sessionid", session.session_id.as_str()); - if op == "check_sms_code" { - params.insert("checkfortos", "0"); - params.insert("skipvoip", "1"); - } - - let resp = self - .client - .post("https://steamcommunity.com/steamguard/phoneajax") - .header(COOKIE, cookies.cookies(&url).unwrap()) - .send() - .unwrap(); - - let result: Value = resp.json().unwrap(); - if result["has_phone"] != Value::Null { - trace!("found has_phone field"); - return result["has_phone"].as_bool().unwrap(); - } else if result["success"] != Value::Null { - trace!("found success field"); - return result["success"].as_bool().unwrap(); - } else { - trace!("did not find any expected field"); - return false; - } - } -} - -fn generate_device_id() -> String { - return format!("android:{}", uuid::Uuid::new_v4().to_string()); -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AddAuthenticatorResponse { - pub response: SteamGuardAccount, -} diff --git a/src/main.rs b/src/main.rs index 2bb1860..dbc5f59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,8 @@ use std::{ sync::{Arc, Mutex}, }; use steamguard::{ - steamapi, Confirmation, ConfirmationType, LoginError, SteamGuardAccount, UserLogin, + steamapi, AccountLinkError, AccountLinker, Confirmation, ConfirmationType, FinalizeLinkError, + LoginError, SteamGuardAccount, UserLogin, }; use termion::{ event::{Event, Key}, @@ -22,7 +23,6 @@ use termion::{ extern crate lazy_static; #[macro_use] extern crate anyhow; -mod accountlinker; mod accountmanager; lazy_static! { @@ -40,6 +40,7 @@ fn main() { Arg::with_name("username") .long("username") .short("u") + .takes_value(true) .help("Select the account you want by steam username. By default, the first account in the manifest is selected.") ) .arg( @@ -98,6 +99,7 @@ fn main() { stderrlog::new() .verbosity(verbosity) .module(module_path!()) + .module("steamguard") .init() .unwrap(); @@ -120,13 +122,93 @@ fn main() { } } - manifest.load_accounts(); + manifest + .load_accounts() + .expect("Failed to load accounts in manifest"); if matches.is_present("setup") { - info!("setup"); - let mut linker = accountlinker::AccountLinker::new(); - // do_login(&mut linker.account); - // linker.link(linker.account.session.expect("no login session")); + println!("Log in to the account that you want to link to steamguard-cli"); + let session = do_login_raw().expect("Failed to log in. Account has not been linked."); + + let mut linker = AccountLinker::new(session); + let account: SteamGuardAccount; + loop { + match linker.link() { + Ok(a) => { + account = a; + break; + } + Err(AccountLinkError::MustRemovePhoneNumber) => { + println!("There is already a phone number on this account, please remove it and try again."); + return; + } + Err(AccountLinkError::MustProvidePhoneNumber) => { + println!("Enter your phone number in the following format: +1 123-456-7890"); + print!("Phone number: "); + linker.phone_number = prompt().replace(&['(', ')', '-'][..], ""); + } + Err(AccountLinkError::AuthenticatorPresent) => { + println!("An authenticator is already present on this account."); + return; + } + Err(AccountLinkError::MustConfirmEmail) => { + println!("Check your email and click the link."); + pause(); + } + Err(err) => { + error!( + "Failed to link authenticator. Account has not been linked. {}", + err + ); + return; + } + } + } + manifest.add_account(account); + match manifest.save() { + Ok(_) => {} + Err(err) => { + error!("Aborting the account linking process because we failed to save the manifest. This is really bad. Here is the error: {}", err); + println!( + "Just in case, here is the account info. Save it somewhere just in case!\n{:?}", + manifest.accounts.last().unwrap().lock().unwrap() + ); + return; + } + } + + let mut account = manifest + .accounts + .last() + .as_ref() + .unwrap() + .clone() + .lock() + .unwrap(); + + debug!("attempting link finalization"); + print!("Enter SMS code: "); + let sms_code = prompt(); + let mut tries = 0; + loop { + match linker.finalize(&mut account, sms_code.clone()) { + Ok(_) => break, + Err(FinalizeLinkError::WantMore) => { + debug!("steam wants more 2fa codes (tries: {})", tries); + tries += 1; + if tries >= 30 { + error!("Failed to finalize: unable to generate valid 2fa codes"); + break; + } + continue; + } + Err(err) => { + error!("Failed to finalize: {}", err); + break; + } + } + } + return; } @@ -427,6 +509,57 @@ fn do_login(account: &mut SteamGuardAccount) { } } +fn do_login_raw() -> anyhow::Result { + print!("Username: "); + let username = prompt(); + let _ = std::io::stdout().flush(); + let password = rpassword::prompt_password_stdout("Password: ").unwrap(); + if password.len() > 0 { + debug!("password is present"); + } else { + debug!("password is empty"); + } + // TODO: reprompt if password is empty + let mut login = UserLogin::new(username, password); + let mut loops = 0; + loop { + match login.login() { + Ok(s) => { + return Ok(s); + } + Err(LoginError::Need2FA) => { + print!("Enter 2fa code: "); + login.twofactor_code = prompt(); + } + Err(LoginError::NeedCaptcha { captcha_gid }) => { + debug!("need captcha to log in"); + login.captcha_text = prompt_captcha_text(&captcha_gid); + } + Err(LoginError::NeedEmail) => { + println!("You should have received an email with a code."); + print!("Enter code: "); + login.email_code = prompt(); + } + Err(r) => { + error!("Fatal login result: {:?}", r); + bail!(r); + } + } + loops += 1; + if loops > 2 { + error!("Too many loops. Aborting login process, to avoid getting rate limited."); + bail!("Too many loops. Login process aborted to avoid getting rate limited."); + } + } +} + +fn pause() { + println!("Press any key to continue..."); + let mut stdout = stdout().into_raw_mode().unwrap(); + stdout.flush().unwrap(); + stdin().events().next(); +} + fn demo_confirmation_menu() { info!("showing demo menu"); let (accept, deny) = prompt_confirmation_menu(vec![ diff --git a/steamguard/Cargo.toml b/steamguard/Cargo.toml index 466dbfe..f7e91d6 100644 --- a/steamguard/Cargo.toml +++ b/steamguard/Cargo.toml @@ -23,3 +23,4 @@ uuid = { version = "0.8", features = ["v4"] } log = "0.4.14" scraper = "0.12.0" maplit = "1.0.2" +thiserror = "1.0.26" diff --git a/steamguard/src/accountlinker.rs b/steamguard/src/accountlinker.rs new file mode 100644 index 0000000..d45e569 --- /dev/null +++ b/steamguard/src/accountlinker.rs @@ -0,0 +1,144 @@ +use crate::{ + steamapi::{ + AddAuthenticatorResponse, FinalizeAddAuthenticatorResponse, Session, SteamApiClient, + }, + SteamGuardAccount, +}; +use log::*; +use thiserror::Error; + +#[derive(Debug)] +pub struct AccountLinker { + device_id: String, + pub phone_number: String, + pub account: Option, + pub finalized: bool, + sent_confirmation_email: bool, + session: Session, + client: SteamApiClient, +} + +impl AccountLinker { + pub fn new(session: Session) -> AccountLinker { + return AccountLinker { + device_id: generate_device_id(), + phone_number: "".into(), + account: None, + finalized: false, + sent_confirmation_email: false, + session: session.clone(), + client: SteamApiClient::new(Some(session)), + }; + } + + pub fn link(&mut self) -> anyhow::Result { + // let has_phone = self.client.has_phone()?; + + // if has_phone && !self.phone_number.is_empty() { + // return Err(AccountLinkError::MustRemovePhoneNumber); + // } + // if !has_phone && self.phone_number.is_empty() { + // return Err(AccountLinkError::MustProvidePhoneNumber); + // } + + // if !has_phone { + // if self.sent_confirmation_email { + // if !self.client.check_email_confirmation()? { + // return Err(anyhow!("Failed email confirmation check"))?; + // } + // } else if !self.client.add_phone_number(self.phone_number.clone())? { + // return Err(anyhow!("Failed to add phone number"))?; + // } else { + // self.sent_confirmation_email = true; + // return Err(AccountLinkError::MustConfirmEmail); + // } + // } + + let resp: AddAuthenticatorResponse = + self.client.add_authenticator(self.device_id.clone())?; + + match resp.status { + 29 => { + return Err(AccountLinkError::AuthenticatorPresent); + } + 1 => { + let mut account = resp.to_steam_guard_account(); + account.device_id = self.device_id.clone(); + account.session = self.client.session.clone(); + return Ok(account); + } + status => { + return Err(anyhow!("Unknown add authenticator status code: {}", status))?; + } + } + } + + /// You may have to call this multiple times. If you have to call it a bunch of times, then you can assume that you are unable to generate correct 2fa codes. + pub fn finalize( + &mut self, + account: &mut SteamGuardAccount, + sms_code: String, + ) -> anyhow::Result<(), FinalizeLinkError> { + let time = crate::steamapi::get_server_time(); + let code = account.generate_code(time); + let resp: FinalizeAddAuthenticatorResponse = + self.client + .finalize_authenticator(sms_code.clone(), code, time)?; + info!("finalize response status: {}", resp.status); + + match resp.status { + 89 => { + return Err(FinalizeLinkError::BadSmsCode); + } + _ => {} + } + + if !resp.success { + return Err(FinalizeLinkError::Failure { + status: resp.status, + })?; + } + + if resp.want_more { + return Err(FinalizeLinkError::WantMore); + } + + self.finalized = true; + account.fully_enrolled = true; + return Ok(()); + } +} + +fn generate_device_id() -> String { + return format!("android:{}", uuid::Uuid::new_v4().to_string()); +} + +#[derive(Error, Debug)] +pub enum AccountLinkError { + /// No phone number on the account + #[error("A phone number is needed, but not already present on the account.")] + MustProvidePhoneNumber, + /// A phone number is already on the account + #[error("A phone number was provided, but one is already present on the account.")] + MustRemovePhoneNumber, + /// User need to click link from confirmation email + #[error("An email has been sent to the user's email, click the link in that email.")] + MustConfirmEmail, + #[error("Authenticator is already present.")] + AuthenticatorPresent, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Error, Debug)] +pub enum FinalizeLinkError { + #[error("Provided SMS code was incorrect.")] + BadSmsCode, + /// Steam wants more 2fa codes to verify that we can generate valid codes. Call finalize again. + #[error("Steam wants more 2fa codes for verification.")] + WantMore, + #[error("Finalization was not successful. Status code {status:?}")] + Failure { status: i32 }, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/steamguard/src/fixtures/api-responses/add-authenticator-1.json b/steamguard/src/fixtures/api-responses/add-authenticator-1.json new file mode 100644 index 0000000..536f196 --- /dev/null +++ b/steamguard/src/fixtures/api-responses/add-authenticator-1.json @@ -0,0 +1 @@ +{"response":{"shared_secret":"wGwZx=sX5MmTxi6QgA3Gi","serial_number":"72016503753671","revocation_code":"R123456","uri":"otpauth://totp/Steam:hydrastar2?secret=JRX7DZIF4JNA3QE3UMS4BDACDISZTRWA&issuer=Steam","server_time":"1628559846","account_name":"hydrastar2","token_gid":"fe12390348285d7f4","identity_secret":"soo58ouTUV+5=KhRKDVK","secret_1":"Me7ngFQsY9R=x3EQyOU","status":1}} \ No newline at end of file diff --git a/steamguard/src/fixtures/api-responses/add-authenticator-2.json b/steamguard/src/fixtures/api-responses/add-authenticator-2.json new file mode 100644 index 0000000..55bac0a --- /dev/null +++ b/steamguard/src/fixtures/api-responses/add-authenticator-2.json @@ -0,0 +1 @@ +{"response":{"status":29}} \ No newline at end of file diff --git a/steamguard/src/fixtures/api-responses/login-response-missing-webcookie.json b/steamguard/src/fixtures/api-responses/login-response-missing-webcookie.json new file mode 100644 index 0000000..94e933d --- /dev/null +++ b/steamguard/src/fixtures/api-responses/login-response-missing-webcookie.json @@ -0,0 +1 @@ +{"success":true,"requires_twofactor":false,"redirect_uri":"steammobile:\/\/mobileloginsucceeded","login_complete":true,"oauth":"{\"steamid\":\"92591609556178617\",\"account_name\":\"hydrastar2\",\"oauth_token\":\"1cc83205dab2979e558534dab29f6f3aa\",\"wgtoken\":\"3EDA9DEF07D7B39361D95203525D8AFE82A\",\"wgtoken_secure\":\"F31641B9AFC2F8B0EE7B6F44D7E73EA3FA48\"}"} \ No newline at end of file diff --git a/steamguard/src/fixtures/confirmations/multiple-confirmations.html b/steamguard/src/fixtures/confirmations/multiple-confirmations.html index 23bc936..f82c84e 100644 --- a/steamguard/src/fixtures/confirmations/multiple-confirmations.html +++ b/steamguard/src/fixtures/confirmations/multiple-confirmations.html @@ -120,7 +120,7 @@ if ( typeof JSON != 'object' || !JSON.stringify || !JSON.parse ) { document.writ
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+
+
+
+
+ + + + +
+ +
+ +
+
+ +
+ + + +
+ +
+
+
+
+
+
Account recovery
+
+
Just now
+
+
+ +
+
+
+ + + + + + + + +
+ + + + + +
+ +
+ + \ No newline at end of file diff --git a/steamguard/src/lib.rs b/steamguard/src/lib.rs index ebfbdf0..b240f3e 100644 --- a/steamguard/src/lib.rs +++ b/steamguard/src/lib.rs @@ -1,3 +1,4 @@ +pub use accountlinker::{AccountLinkError, AccountLinker, FinalizeLinkError}; use anyhow::Result; pub use confirmation::{Confirmation, ConfirmationType}; use hmacsha1::hmac_sha1; @@ -9,7 +10,7 @@ use reqwest::{ }; use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, convert::TryInto, thread, time}; +use std::{collections::HashMap, convert::TryInto}; pub use userlogin::{LoginError, UserLogin}; #[macro_use] extern crate lazy_static; @@ -18,6 +19,7 @@ extern crate anyhow; #[macro_use] extern crate maplit; +mod accountlinker; mod confirmation; pub mod steamapi; mod userlogin; @@ -45,6 +47,7 @@ pub struct SteamGuardAccount { pub uri: String, pub fully_enrolled: bool, pub device_id: String, + pub secret_1: String, #[serde(rename = "Session")] pub session: Option, } @@ -82,6 +85,7 @@ impl SteamGuardAccount { uri: String::from(""), fully_enrolled: false, device_id: String::from(""), + secret_1: "".into(), session: Option::None, }; } diff --git a/steamguard/src/steamapi.rs b/steamguard/src/steamapi.rs index 209cfeb..22cb78d 100644 --- a/steamguard/src/steamapi.rs +++ b/steamguard/src/steamapi.rs @@ -7,12 +7,16 @@ use reqwest::{ Url, }; use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; use std::iter::FromIterator; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::SteamGuardAccount; + lazy_static! { static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::().unwrap(); + static ref STEAM_API_BASE: String = "https://api.steampowered.com".into(); } #[derive(Debug, Clone, Deserialize)] @@ -82,6 +86,7 @@ pub struct OAuthData { steamid: String, wgtoken: String, wgtoken_secure: String, + #[serde(default)] webcookie: String, } @@ -93,7 +98,7 @@ pub struct Session { pub steam_login: String, #[serde(rename = "SteamLoginSecure")] pub steam_login_secure: String, - #[serde(rename = "WebCookie")] + #[serde(default, rename = "WebCookie")] pub web_cookie: String, #[serde(rename = "OAuthToken")] pub token: String, @@ -114,7 +119,7 @@ pub fn get_server_time() -> i64 { .unwrap(); } -/// Provides raw access to the Steam API. Handles cookies, some deserialization, etc. to make it easier. +/// Provides raw access to the Steam API. Handles cookies, some deserialization, etc. to make it easier. It covers `ITwoFactorService` from the Steam web API, and some mobile app specific api endpoints. #[derive(Debug)] pub struct SteamApiClient { cookies: reqwest::cookie::Jar, @@ -123,7 +128,7 @@ pub struct SteamApiClient { } impl SteamApiClient { - pub fn new() -> SteamApiClient { + pub fn new(session: Option) -> SteamApiClient { SteamApiClient { cookies: reqwest::cookie::Jar::default(), client: reqwest::blocking::ClientBuilder::new() @@ -134,17 +139,20 @@ impl SteamApiClient { }.into_iter())) .build() .unwrap(), - session: None, + session: session, } } fn build_session(&self, data: &OAuthData) -> Session { + trace!("SteamApiClient::build_session"); return Session { token: data.oauth_token.clone(), steam_id: data.steamid.parse().unwrap(), steam_login: format!("{}%7C%7C{}", data.steamid, data.wgtoken), steam_login_secure: format!("{}%7C%7C{}", data.steamid, data.wgtoken_secure), - session_id: self.extract_session_id().unwrap(), + session_id: self + .extract_session_id() + .expect("failed to extract session id from cookies"), web_cookie: data.webcookie.clone(), }; } @@ -173,24 +181,35 @@ impl SteamApiClient { } } - pub fn request(&self, method: reqwest::Method, url: U) -> RequestBuilder { + pub fn request( + &self, + method: reqwest::Method, + url: U, + ) -> RequestBuilder { + trace!("making request: {} {}", method, url); self.cookies .add_cookie_str("mobileClientVersion=0 (2.1.3)", &STEAM_COOKIE_URL); self.cookies .add_cookie_str("mobileClient=android", &STEAM_COOKIE_URL); self.cookies .add_cookie_str("Steam_Language=english", &STEAM_COOKIE_URL); + if let Some(session) = &self.session { + self.cookies.add_cookie_str( + format!("sessionid={}", session.session_id).as_str(), + &STEAM_COOKIE_URL, + ); + } self.client .request(method, url) .header(COOKIE, self.cookies.cookies(&STEAM_COOKIE_URL).unwrap()) } - pub fn get(&self, url: U) -> RequestBuilder { + pub fn get(&self, url: U) -> RequestBuilder { self.request(reqwest::Method::GET, url) } - pub fn post(&self, url: U) -> RequestBuilder { + pub fn post(&self, url: U) -> RequestBuilder { self.request(reqwest::Method::POST, url) } @@ -244,6 +263,7 @@ impl SteamApiClient { .post("https://steamcommunity.com/login/dologin") .form(¶ms) .send()?; + self.save_cookies_from_response(&resp); let text = resp.text()?; trace!("raw login response: {}", text); @@ -289,6 +309,205 @@ impl SteamApiClient { } } } + + /// One of the endpoints that handles phone number things. Can check to see if phone is present on account, and maybe do some other stuff. It's not really super clear. + /// + /// Host: steamcommunity.com + /// Endpoint: POST /steamguard/phoneajax + /// Requires `sessionid` cookie to be set. + fn phoneajax(&self, op: &str, arg: &str) -> anyhow::Result { + let mut params = hashmap! { + "op" => op, + "arg" => arg, + "sessionid" => self.session.as_ref().unwrap().session_id.as_str(), + }; + if op == "check_sms_code" { + params.insert("checkfortos", "0"); + params.insert("skipvoip", "1"); + } + + let resp = self + .post("https://steamcommunity.com/steamguard/phoneajax") + .form(¶ms) + .send()?; + + trace!("phoneajax: status={}", resp.status()); + let result: Value = resp.json()?; + trace!("phoneajax: {:?}", result); + if result["has_phone"] != Value::Null { + trace!("op: {} - found has_phone field", op); + return result["has_phone"] + .as_bool() + .ok_or(anyhow!("failed to parse has_phone field into boolean")); + } else if result["success"] != Value::Null { + trace!("op: {} - found success field", op); + return result["success"] + .as_bool() + .ok_or(anyhow!("failed to parse success field into boolean")); + } else { + trace!("op: {} - did not find any expected field", op); + return Ok(false); + } + } + + /// Works similar to phoneajax. Used in the process to add a phone number to a steam account. + /// Valid ops: + /// - get_phone_number => `input` is treated as a phone number to add to the account. Yes, this is somewhat counter intuitive. + /// - resend_sms + /// - get_sms_code => `input` is treated as a the SMS code that was texted to the phone number. Again, this is somewhat counter intuitive. After this succeeds, the phone number is added to the account. + /// - email_verification => If the account is protected with steam guard email, a verification link is sent. After the link in the email is clicked, send this op. After, an SMS code is sent to the phone number. + /// - retry_email_verification + /// + /// Host: store.steampowered.com + /// Endpoint: /phone/add_ajaxop + fn phone_add_ajaxop(&self, op: &str, input: &str) -> anyhow::Result<()> { + trace!("phone_add_ajaxop: op={} input={}", op, input); + let params = hashmap! { + "op" => op, + "input" => input, + "sessionid" => self.session.as_ref().unwrap().session_id.as_str(), + }; + + let resp = self + .post("https://store.steampowered.com/phone/add_ajaxop") + .form(¶ms) + .send()?; + trace!("phone_add_ajaxop: http status={}", resp.status()); + let text = resp.text()?; + trace!("phone_add_ajaxop response: {}", text); + + todo!(); + } + + pub fn has_phone(&self) -> anyhow::Result { + return self.phoneajax("has_phone", "null"); + } + + pub fn check_sms_code(&self, sms_code: String) -> anyhow::Result { + return self.phoneajax("check_sms_code", sms_code.as_str()); + } + + pub fn check_email_confirmation(&self) -> anyhow::Result { + return self.phoneajax("email_confirmation", ""); + } + + pub fn add_phone_number(&self, phone_number: String) -> anyhow::Result { + // return self.phoneajax("add_phone_number", phone_number.as_str()); + todo!(); + } + + /// Provides lots of juicy information, like if the number is a VOIP number. + /// Host: store.steampowered.com + /// Endpoint: POST /phone/validate + /// Found on page: https://store.steampowered.com/phone/add + pub fn phone_validate(&self, phone_number: String) -> anyhow::Result { + let params = hashmap! { + "sessionID" => "", + "phoneNumber" => "", + }; + + todo!(); + } + + /// Starts the authenticator linking process. + /// This doesn't check any prereqisites to ensure the request will pass validation on Steam's side (eg. sms/email confirmations). + /// A valid `Session` is required for this request. Cookies are not needed for this request, but they are set anyway. + /// + /// Host: api.steampowered.com + /// Endpoint: POST /ITwoFactorService/AddAuthenticator/v0001 + pub fn add_authenticator( + &mut self, + device_id: String, + ) -> anyhow::Result { + ensure!(matches!(self.session, Some(_))); + let params = hashmap! { + "access_token" => self.session.as_ref().unwrap().token.clone(), + "steamid" => self.session.as_ref().unwrap().steam_id.to_string(), + "authenticator_type" => "1".into(), + "device_identifier" => device_id, + "sms_phone_id" => "1".into(), + }; + + let resp = self + .post(format!( + "{}/ITwoFactorService/AddAuthenticator/v0001", + STEAM_API_BASE.to_string() + )) + .form(¶ms) + .send()?; + self.save_cookies_from_response(&resp); + let text = resp.text()?; + trace!("raw add authenticator response: {}", text); + + let resp: SteamApiResponse = serde_json::from_str(text.as_str())?; + + Ok(resp.response) + } + + /// + /// Host: api.steampowered.com + /// Endpoint: POST /ITwoFactorService/FinalizeAddAuthenticator/v0001 + pub fn finalize_authenticator( + &self, + sms_code: String, + code_2fa: String, + time_2fa: i64, + ) -> anyhow::Result { + ensure!(matches!(self.session, Some(_))); + let params = hashmap! { + "steamid" => self.session.as_ref().unwrap().steam_id.to_string(), + "access_token" => self.session.as_ref().unwrap().token.clone(), + "activation_code" => sms_code, + "authenticator_code" => code_2fa, + "authenticator_time" => time_2fa.to_string(), + }; + + let resp = self + .post(format!( + "{}/ITwoFactorService/FinalizeAddAuthenticator/v0001", + STEAM_API_BASE.to_string() + )) + .form(¶ms) + .send()?; + + let text = resp.text()?; + trace!("raw finalize authenticator response: {}", text); + + let resp: SteamApiResponse = + serde_json::from_str(text.as_str())?; + + return Ok(resp.response); + } + + /// Host: api.steampowered.com + /// Endpoint: POST /ITwoFactorService/RemoveAuthenticator/v0001 + pub fn remove_authenticator( + &self, + revocation_code: String, + ) -> anyhow::Result { + let params = hashmap! { + "steamid" => self.session.as_ref().unwrap().steam_id.to_string(), + "steamguard_scheme" => "2".into(), + "revocation_code" => revocation_code, + "access_token" => self.session.as_ref().unwrap().token.to_string(), + }; + + let resp = self + .post(format!( + "{}/ITwoFactorService/RemoveAuthenticator/v0001", + STEAM_API_BASE.to_string() + )) + .form(¶ms) + .send()?; + + let text = resp.text()?; + trace!("raw remove authenticator response: {}", text); + + let resp: SteamApiResponse = + serde_json::from_str(text.as_str())?; + + return Ok(resp.response); + } } #[test] @@ -329,3 +548,138 @@ fn test_login_response_parse() { ); assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5"); } + +#[test] +fn test_login_response_parse_missing_webcookie() { + let result = serde_json::from_str::(include_str!( + "fixtures/api-responses/login-response-missing-webcookie.json" + )); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap(); + + let oauth = resp.oauth.unwrap(); + assert_eq!(oauth.steamid, "92591609556178617"); + assert_eq!(oauth.oauth_token, "1cc83205dab2979e558534dab29f6f3aa"); + assert_eq!(oauth.wgtoken, "3EDA9DEF07D7B39361D95203525D8AFE82A"); + assert_eq!(oauth.wgtoken_secure, "F31641B9AFC2F8B0EE7B6F44D7E73EA3FA48"); + assert_eq!(oauth.webcookie, ""); +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SteamApiResponse { + pub response: T, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AddAuthenticatorResponse { + /// Shared secret between server and authenticator + #[serde(default)] + pub shared_secret: String, + /// Authenticator serial number (unique per token) + #[serde(default)] + pub serial_number: String, + /// code used to revoke authenticator + #[serde(default)] + pub revocation_code: String, + /// URI for QR code generation + #[serde(default)] + pub uri: String, + /// Current server time + #[serde(default, deserialize_with = "parse_json_string_as_number")] + pub server_time: u64, + /// Account name to display on token client + #[serde(default)] + pub account_name: String, + /// Token GID assigned by server + #[serde(default)] + pub token_gid: String, + /// Secret used for identity attestation (e.g., for eventing) + #[serde(default)] + pub identity_secret: String, + /// Spare shared secret + #[serde(default)] + pub secret_1: String, + /// Result code + pub status: i32, +} + +impl AddAuthenticatorResponse { + pub fn to_steam_guard_account(&self) -> SteamGuardAccount { + SteamGuardAccount { + shared_secret: self.shared_secret.clone(), + serial_number: self.serial_number.clone(), + revocation_code: self.revocation_code.clone(), + uri: self.uri.clone(), + server_time: self.server_time, + account_name: self.account_name.clone(), + token_gid: self.token_gid.clone(), + identity_secret: self.identity_secret.clone(), + secret_1: self.secret_1.clone(), + fully_enrolled: false, + device_id: "".into(), + session: None, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FinalizeAddAuthenticatorResponse { + pub status: i32, + #[serde(deserialize_with = "parse_json_string_as_number")] + pub server_time: u64, + pub want_more: bool, + pub success: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveAuthenticatorResponse { + pub success: bool, +} + +fn parse_json_string_as_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // for some reason, deserializing to &str doesn't work but this does. + let s: String = Deserialize::deserialize(deserializer)?; + Ok(s.parse().unwrap()) +} + +#[test] +fn test_parse_add_auth_response() { + let result = serde_json::from_str::>(include_str!( + "fixtures/api-responses/add-authenticator-1.json" + )); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap().response; + + assert_eq!(resp.server_time, 1628559846); + assert_eq!(resp.shared_secret, "wGwZx=sX5MmTxi6QgA3Gi"); + assert_eq!(resp.revocation_code, "R123456"); +} + +#[test] +fn test_parse_add_auth_response2() { + let result = serde_json::from_str::>(include_str!( + "fixtures/api-responses/add-authenticator-2.json" + )); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap().response; + + assert_eq!(resp.status, 29); +} diff --git a/steamguard/src/userlogin.rs b/steamguard/src/userlogin.rs index 351bfe3..6c60703 100644 --- a/steamguard/src/userlogin.rs +++ b/steamguard/src/userlogin.rs @@ -61,7 +61,7 @@ impl UserLogin { twofactor_code: String::from(""), email_code: String::from(""), steam_id: 0, - client: SteamApiClient::new(), + client: SteamApiClient::new(None), }; }