steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Two-factor authentication services.

use scraper::{Html, Selector};
use steam_totp::{generate_auth_code, generate_device_id, Secret};

use crate::{
    client::SteamUser,
    endpoint::steam_endpoint,
    error::SteamUserError,
    types::{SteamGuardStatus, TwoFactorResponse},
};

impl SteamUser {
    /// Initiates the process of enabling two-factor authentication (Steam Guard
    /// Mobile Authenticator).
    ///
    /// This method starts the registration. You will receive an SMS code that
    /// must later be passed to [`Self::finalize_authenticator`].
    #[steam_endpoint(POST, host = Api, path = "/ITwoFactorService/AddAuthenticator/v1/", kind = Auth)]
    pub async fn enable_two_factor(&self) -> Result<TwoFactorResponse, SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        let device_id = generate_device_id(steam_id, None);

        // We need the mobile access token
        let access_token = self.session.mobile_access_token.as_ref().ok_or(SteamUserError::MissingCredential { field: "mobile_access_token" })?.clone();

        let response: serde_json::Value = self.post_path("/ITwoFactorService/AddAuthenticator/v1/").header(reqwest::header::AUTHORIZATION, format!("Bearer {}", access_token)).form(&[("steamid", steam_id.steam_id64().to_string()), ("authenticator_type", "1".to_string()), ("device_identifier", device_id), ("sms_phone_id", "1".to_string()), ("version", "2".to_string())]).send().await?.json().await?;

        let response = response.get("response").ok_or_else(|| SteamUserError::MalformedResponse("Missing response object".into()))?;

        let resp: TwoFactorResponse = serde_json::from_value(response.clone())?;

        if let Some(ref shared_secret) = resp.shared_secret {
            *self.session.shared_secret.lock() = Some(shared_secret.clone());
        }

        if resp.status != 1 {
            return Err(SteamUserError::from_eresult(resp.status));
        }

        Ok(resp)
    }

    /// Alias for [`Self::enable_two_factor`].
    // delegates to `enable_two_factor` — no #[steam_endpoint]
    #[tracing::instrument(skip(self))]
    pub async fn add_authenticator(&self) -> Result<TwoFactorResponse, SteamUserError> {
        self.enable_two_factor().await
    }

    /// Finalizes the process of adding a mobile authenticator by providing the
    /// shared secret.
    ///
    /// This is a convenience wrapper that sets the `shared_secret` in the
    /// session before calling [`Self::finalize_authenticator`].
    // delegates to `finalize_authenticator` — no #[steam_endpoint]
    #[tracing::instrument(skip(self, shared_secret, activation_code))]
    pub async fn finalize_two_factor(&self, shared_secret: &str, activation_code: &str) -> Result<(), SteamUserError> {
        *self.session.shared_secret.lock() = Some(shared_secret.to_string());
        self.finalize_authenticator(activation_code).await
    }

    /// Finalizes the mobile authenticator registration with the numeric code
    /// received via SMS.
    ///
    /// # Arguments
    ///
    /// * `activation_code` - The numeric code received via SMS.
    #[steam_endpoint(POST, host = Api, path = "/ITwoFactorService/FinalizeAddAuthenticator/v1/", kind = Auth)]
    pub async fn finalize_authenticator(&self, activation_code: &str) -> Result<(), SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        let access_token = self.session.mobile_access_token.as_ref().ok_or(SteamUserError::MissingCredential { field: "mobile_access_token" })?.clone();

        let shared_secret = self.session.shared_secret.lock().as_ref().ok_or(SteamUserError::MissingCredential { field: "shared_secret" })?.clone();

        let mut time_offset = self.time_offset.lock().unwrap_or(0);
        let mut attempts_left = 30;

        while attempts_left > 0 {
            let current_time = i64::try_from(
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)?
                    .as_secs(),
            )
            .unwrap_or(0);

            let authenticator_time = current_time + time_offset;
            let secret = Secret::from_string(&shared_secret)?;
            let authenticator_code = generate_auth_code(&secret, time_offset)?;

            let response: serde_json::Value = self.post_path("/ITwoFactorService/FinalizeAddAuthenticator/v1/").header(reqwest::header::AUTHORIZATION, format!("Bearer {}", access_token)).form(&[("steamid", steam_id.steam_id64().to_string()), ("authenticator_code", authenticator_code), ("authenticator_time", authenticator_time.to_string()), ("activation_code", activation_code.to_string())]).send().await?.json().await?;

            let response = response.get("response").ok_or_else(|| SteamUserError::MalformedResponse("Missing response object".into()))?;

            if let Some(server_time) = response.get("server_time").and_then(|v| v.as_i64()) {
                time_offset = server_time - current_time;
                *self.time_offset.lock() = Some(time_offset);
            }

            let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);

            if success {
                return Ok(());
            }

            let status = i32::try_from(response.get("status").and_then(|v| v.as_i64()).unwrap_or(0)).unwrap_or(0);
            if status == 89 {
                return Err(SteamUserError::TwoFactorError("Invalid activation code".into()));
            }

            attempts_left -= 1;
            time_offset += 30;
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
        }

        Err(SteamUserError::TwoFactorError("Failed to finalize adding authenticator after 30 attempts".into()))
    }

    /// Disables two-factor authentication using a revocation code.
    #[steam_endpoint(POST, host = Api, path = "/ITwoFactorService/RemoveAuthenticator/v1/", kind = Auth)]
    pub async fn disable_two_factor(&self, revocation_code: &str) -> Result<(), SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        let access_token = self.session.mobile_access_token.as_ref().ok_or(SteamUserError::MissingCredential { field: "mobile_access_token" })?;

        let steam_id_str = steam_id.steam_id64().to_string();

        let response: serde_json::Value = self.post_path("/ITwoFactorService/RemoveAuthenticator/v1/").header(reqwest::header::AUTHORIZATION, format!("Bearer {}", access_token)).form(&[("steamid", steam_id_str.as_str()), ("revocation_code", revocation_code), ("steamguard_scheme", "1")]).send().await?.json().await?;

        let response = response.get("response").ok_or_else(|| SteamUserError::MalformedResponse("Missing response object".into()))?;

        let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);

        if !success {
            if let Some(status) = response.get("status").and_then(|v| v.as_i64()) {
                return Err(SteamUserError::from_eresult(i32::try_from(status).unwrap_or(0)));
            }
            return Err(SteamUserError::TwoFactorError("Failed to remove authenticator".into()));
        }

        Ok(())
    }

    /// Remove the mobile authenticator using a revocation code.
    ///
    /// This is an alias for `disable_two_factor`.
    // delegates to `disable_two_factor` — no #[steam_endpoint]
    #[tracing::instrument(skip(self, revocation_code))]
    pub async fn remove_authenticator(&self, revocation_code: &str) -> Result<(), SteamUserError> {
        self.disable_two_factor(revocation_code).await
    }

    /// Deauthorizes all other devices currently logged into the Steam account.
    #[steam_endpoint(POST, host = Store, path = "/twofactor/manage_action", kind = Auth)]
    pub async fn deauthorize_devices(&self) -> Result<(), SteamUserError> {
        let response: serde_json::Value = self.post_path("/twofactor/manage_action").header("origin", "https://store.steampowered.com").header("referer", "https://store.steampowered.com/twofactor/manage").form(&[("action", "deauthorize")]).send().await?.json().await?;

        // Use a generic SteamUserError check if response is null/missing (similar to JS
        // !result check)
        if response.is_null() {
            return Err(SteamUserError::MalformedResponse("Failed to deauthorize devices".into()));
        }

        Ok(())
    }

    /// Retrieves the current Steam Guard protection status (Mobile, Email, or
    /// None).
    #[steam_endpoint(GET, host = Store, path = "/twofactor/manage_action", kind = Read)]
    pub async fn get_steam_guard_status(&self) -> Result<SteamGuardStatus, SteamUserError> {
        let response = self.get_path("/twofactor/manage_action").send().await?.text().await?;

        let document = Html::parse_document(&response);

        let mobile_selector = Selector::parse("#steam_authenticator_form #steam_authenticator_check[checked]").expect("valid CSS selector");
        let email_selector = Selector::parse("#email_authenticator_form #email_authenticator_check[checked]").expect("valid CSS selector");
        let none_selector = Selector::parse("#none_authenticator_form #none_authenticator_check[checked]").expect("valid CSS selector");

        if document.select(&mobile_selector).next().is_some() {
            return Ok(SteamGuardStatus::Mobile);
        }
        if document.select(&email_selector).next().is_some() {
            return Ok(SteamGuardStatus::Email);
        }
        if document.select(&none_selector).next().is_some() {
            return Ok(SteamGuardStatus::None);
        }

        Err(SteamUserError::MalformedResponse("Could not determine Steam Guard status".into()))
    }

    /// Enable Email Steam Guard.
    #[steam_endpoint(POST, host = Store, path = "/twofactor/manage_action", kind = Auth)]
    pub async fn enable_steam_guard_email(&self) -> Result<bool, SteamUserError> {
        let response = self.post_path("/twofactor/manage_action").header("referer", "https://store.steampowered.com/twofactor/manage").form(&[("action", "email"), ("email_authenticator_check", "on")]).send().await?.text().await?;

        let document = Html::parse_document(&response);
        let title_selector = Selector::parse("title").expect("valid CSS selector");
        let check_selector = Selector::parse("#email_authenticator_check[checked]").expect("valid CSS selector");

        let title_ok = document.select(&title_selector).next().map(|t| t.text().collect::<String>() == "Steam Guard Mobile Authenticator").unwrap_or(false);

        let checked = document.select(&check_selector).next().is_some();

        Ok(title_ok && checked)
    }

    /// Disable Steam Guard (Set to None).
    #[steam_endpoint(POST, host = Store, path = "/twofactor/manage_action", kind = Auth)]
    pub async fn disable_steam_guard_email(&self) -> Result<bool, SteamUserError> {
        let response = self.post_path("/twofactor/manage_action").header("referer", "https://store.steampowered.com/twofactor/manage_action").form(&[("action", "actuallynone")]).send().await?.text().await?;

        let document = Html::parse_document(&response);
        let title_selector = Selector::parse("title").expect("valid CSS selector");

        let title_ok = document.select(&title_selector).next().map(|t| t.text().collect::<String>() == "Steam Guard Mobile Authenticator").unwrap_or(false);
        let text_ok = response.contains("Turning Steam Guard off requires confirmation. We've sent you an email with a link to confirm disabling Steam Guard.");

        Ok(title_ok && text_ok)
    }
}