Skip to main content

steam_user/services/
twofactor.rs

1//! Two-factor authentication services.
2
3use scraper::{Html, Selector};
4use steam_totp::{generate_auth_code, generate_device_id, Secret};
5
6use crate::{
7    client::SteamUser,
8    endpoint::steam_endpoint,
9    error::SteamUserError,
10    types::{SteamGuardStatus, TwoFactorResponse},
11};
12
13impl SteamUser {
14    /// Initiates the process of enabling two-factor authentication (Steam Guard
15    /// Mobile Authenticator).
16    ///
17    /// This method starts the registration. You will receive an SMS code that
18    /// must later be passed to [`Self::finalize_authenticator`].
19    #[steam_endpoint(POST, host = Api, path = "/ITwoFactorService/AddAuthenticator/v1/", kind = Auth)]
20    pub async fn enable_two_factor(&self) -> Result<TwoFactorResponse, SteamUserError> {
21        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
22        let device_id = generate_device_id(steam_id, None);
23
24        // We need the mobile access token
25        let access_token = self.session.mobile_access_token.as_ref().ok_or(SteamUserError::MissingCredential { field: "mobile_access_token" })?.clone();
26
27        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?;
28
29        let response = response.get("response").ok_or_else(|| SteamUserError::MalformedResponse("Missing response object".into()))?;
30
31        let resp: TwoFactorResponse = serde_json::from_value(response.clone())?;
32
33        if let Some(ref shared_secret) = resp.shared_secret {
34            *self.session.shared_secret.lock() = Some(shared_secret.clone());
35        }
36
37        if resp.status != 1 {
38            return Err(SteamUserError::from_eresult(resp.status));
39        }
40
41        Ok(resp)
42    }
43
44    /// Alias for [`Self::enable_two_factor`].
45    // delegates to `enable_two_factor` — no #[steam_endpoint]
46    #[tracing::instrument(skip(self))]
47    pub async fn add_authenticator(&self) -> Result<TwoFactorResponse, SteamUserError> {
48        self.enable_two_factor().await
49    }
50
51    /// Finalizes the process of adding a mobile authenticator by providing the
52    /// shared secret.
53    ///
54    /// This is a convenience wrapper that sets the `shared_secret` in the
55    /// session before calling [`Self::finalize_authenticator`].
56    // delegates to `finalize_authenticator` — no #[steam_endpoint]
57    #[tracing::instrument(skip(self, shared_secret, activation_code))]
58    pub async fn finalize_two_factor(&self, shared_secret: &str, activation_code: &str) -> Result<(), SteamUserError> {
59        *self.session.shared_secret.lock() = Some(shared_secret.to_string());
60        self.finalize_authenticator(activation_code).await
61    }
62
63    /// Finalizes the mobile authenticator registration with the numeric code
64    /// received via SMS.
65    ///
66    /// # Arguments
67    ///
68    /// * `activation_code` - The numeric code received via SMS.
69    #[steam_endpoint(POST, host = Api, path = "/ITwoFactorService/FinalizeAddAuthenticator/v1/", kind = Auth)]
70    pub async fn finalize_authenticator(&self, activation_code: &str) -> Result<(), SteamUserError> {
71        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
72        let access_token = self.session.mobile_access_token.as_ref().ok_or(SteamUserError::MissingCredential { field: "mobile_access_token" })?.clone();
73
74        let shared_secret = self.session.shared_secret.lock().as_ref().ok_or(SteamUserError::MissingCredential { field: "shared_secret" })?.clone();
75
76        let mut time_offset = self.time_offset.lock().unwrap_or(0);
77        let mut attempts_left = 30;
78
79        while attempts_left > 0 {
80            let current_time = i64::try_from(
81                std::time::SystemTime::now()
82                    .duration_since(std::time::UNIX_EPOCH)?
83                    .as_secs(),
84            )
85            .unwrap_or(0);
86
87            let authenticator_time = current_time + time_offset;
88            let secret = Secret::from_string(&shared_secret)?;
89            let authenticator_code = generate_auth_code(&secret, time_offset)?;
90
91            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?;
92
93            let response = response.get("response").ok_or_else(|| SteamUserError::MalformedResponse("Missing response object".into()))?;
94
95            if let Some(server_time) = response.get("server_time").and_then(|v| v.as_i64()) {
96                time_offset = server_time - current_time;
97                *self.time_offset.lock() = Some(time_offset);
98            }
99
100            let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
101
102            if success {
103                return Ok(());
104            }
105
106            let status = i32::try_from(response.get("status").and_then(|v| v.as_i64()).unwrap_or(0)).unwrap_or(0);
107            if status == 89 {
108                return Err(SteamUserError::TwoFactorError("Invalid activation code".into()));
109            }
110
111            attempts_left -= 1;
112            time_offset += 30;
113            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
114        }
115
116        Err(SteamUserError::TwoFactorError("Failed to finalize adding authenticator after 30 attempts".into()))
117    }
118
119    /// Disables two-factor authentication using a revocation code.
120    #[steam_endpoint(POST, host = Api, path = "/ITwoFactorService/RemoveAuthenticator/v1/", kind = Auth)]
121    pub async fn disable_two_factor(&self, revocation_code: &str) -> Result<(), SteamUserError> {
122        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
123        let access_token = self.session.mobile_access_token.as_ref().ok_or(SteamUserError::MissingCredential { field: "mobile_access_token" })?;
124
125        let steam_id_str = steam_id.steam_id64().to_string();
126
127        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?;
128
129        let response = response.get("response").ok_or_else(|| SteamUserError::MalformedResponse("Missing response object".into()))?;
130
131        let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
132
133        if !success {
134            if let Some(status) = response.get("status").and_then(|v| v.as_i64()) {
135                return Err(SteamUserError::from_eresult(i32::try_from(status).unwrap_or(0)));
136            }
137            return Err(SteamUserError::TwoFactorError("Failed to remove authenticator".into()));
138        }
139
140        Ok(())
141    }
142
143    /// Remove the mobile authenticator using a revocation code.
144    ///
145    /// This is an alias for `disable_two_factor`.
146    // delegates to `disable_two_factor` — no #[steam_endpoint]
147    #[tracing::instrument(skip(self, revocation_code))]
148    pub async fn remove_authenticator(&self, revocation_code: &str) -> Result<(), SteamUserError> {
149        self.disable_two_factor(revocation_code).await
150    }
151
152    /// Deauthorizes all other devices currently logged into the Steam account.
153    #[steam_endpoint(POST, host = Store, path = "/twofactor/manage_action", kind = Auth)]
154    pub async fn deauthorize_devices(&self) -> Result<(), SteamUserError> {
155        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?;
156
157        // Use a generic SteamUserError check if response is null/missing (similar to JS
158        // !result check)
159        if response.is_null() {
160            return Err(SteamUserError::MalformedResponse("Failed to deauthorize devices".into()));
161        }
162
163        Ok(())
164    }
165
166    /// Retrieves the current Steam Guard protection status (Mobile, Email, or
167    /// None).
168    #[steam_endpoint(GET, host = Store, path = "/twofactor/manage_action", kind = Read)]
169    pub async fn get_steam_guard_status(&self) -> Result<SteamGuardStatus, SteamUserError> {
170        let response = self.get_path("/twofactor/manage_action").send().await?.text().await?;
171
172        let document = Html::parse_document(&response);
173
174        let mobile_selector = Selector::parse("#steam_authenticator_form #steam_authenticator_check[checked]").expect("valid CSS selector");
175        let email_selector = Selector::parse("#email_authenticator_form #email_authenticator_check[checked]").expect("valid CSS selector");
176        let none_selector = Selector::parse("#none_authenticator_form #none_authenticator_check[checked]").expect("valid CSS selector");
177
178        if document.select(&mobile_selector).next().is_some() {
179            return Ok(SteamGuardStatus::Mobile);
180        }
181        if document.select(&email_selector).next().is_some() {
182            return Ok(SteamGuardStatus::Email);
183        }
184        if document.select(&none_selector).next().is_some() {
185            return Ok(SteamGuardStatus::None);
186        }
187
188        Err(SteamUserError::MalformedResponse("Could not determine Steam Guard status".into()))
189    }
190
191    /// Enable Email Steam Guard.
192    #[steam_endpoint(POST, host = Store, path = "/twofactor/manage_action", kind = Auth)]
193    pub async fn enable_steam_guard_email(&self) -> Result<bool, SteamUserError> {
194        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?;
195
196        let document = Html::parse_document(&response);
197        let title_selector = Selector::parse("title").expect("valid CSS selector");
198        let check_selector = Selector::parse("#email_authenticator_check[checked]").expect("valid CSS selector");
199
200        let title_ok = document.select(&title_selector).next().map(|t| t.text().collect::<String>() == "Steam Guard Mobile Authenticator").unwrap_or(false);
201
202        let checked = document.select(&check_selector).next().is_some();
203
204        Ok(title_ok && checked)
205    }
206
207    /// Disable Steam Guard (Set to None).
208    #[steam_endpoint(POST, host = Store, path = "/twofactor/manage_action", kind = Auth)]
209    pub async fn disable_steam_guard_email(&self) -> Result<bool, SteamUserError> {
210        let response = self.post_path("/twofactor/manage_action").header("referer", "https://store.steampowered.com/twofactor/manage_action").form(&[("action", "actuallynone")]).send().await?.text().await?;
211
212        let document = Html::parse_document(&response);
213        let title_selector = Selector::parse("title").expect("valid CSS selector");
214
215        let title_ok = document.select(&title_selector).next().map(|t| t.text().collect::<String>() == "Steam Guard Mobile Authenticator").unwrap_or(false);
216        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.");
217
218        Ok(title_ok && text_ok)
219    }
220}