Merge pull request #78 from dyc3/account-setup

Rough implementation for account setup
This commit is contained in:
Carson McManus 2021-08-10 20:57:49 -04:00 committed by GitHub
commit 5fdc12bfb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 887 additions and 105 deletions

21
Cargo.lock generated
View file

@ -1625,6 +1625,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"standback", "standback",
"thiserror",
"uuid", "uuid",
] ]
@ -1781,6 +1782,26 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" 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]] [[package]]
name = "thread_local" name = "thread_local"
version = "0.3.4" version = "0.3.4"

View file

@ -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::<Url>().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,
}

View file

@ -9,7 +9,8 @@ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
use steamguard::{ use steamguard::{
steamapi, Confirmation, ConfirmationType, LoginError, SteamGuardAccount, UserLogin, steamapi, AccountLinkError, AccountLinker, Confirmation, ConfirmationType, FinalizeLinkError,
LoginError, SteamGuardAccount, UserLogin,
}; };
use termion::{ use termion::{
event::{Event, Key}, event::{Event, Key},
@ -22,7 +23,6 @@ use termion::{
extern crate lazy_static; extern crate lazy_static;
#[macro_use] #[macro_use]
extern crate anyhow; extern crate anyhow;
mod accountlinker;
mod accountmanager; mod accountmanager;
lazy_static! { lazy_static! {
@ -40,6 +40,7 @@ fn main() {
Arg::with_name("username") Arg::with_name("username")
.long("username") .long("username")
.short("u") .short("u")
.takes_value(true)
.help("Select the account you want by steam username. By default, the first account in the manifest is selected.") .help("Select the account you want by steam username. By default, the first account in the manifest is selected.")
) )
.arg( .arg(
@ -98,6 +99,7 @@ fn main() {
stderrlog::new() stderrlog::new()
.verbosity(verbosity) .verbosity(verbosity)
.module(module_path!()) .module(module_path!())
.module("steamguard")
.init() .init()
.unwrap(); .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") { if matches.is_present("setup") {
info!("setup"); println!("Log in to the account that you want to link to steamguard-cli");
let mut linker = accountlinker::AccountLinker::new(); let session = do_login_raw().expect("Failed to log in. Account has not been linked.");
// do_login(&mut linker.account);
// linker.link(linker.account.session.expect("no login session")); 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; return;
} }
@ -427,6 +509,57 @@ fn do_login(account: &mut SteamGuardAccount) {
} }
} }
fn do_login_raw() -> anyhow::Result<steamapi::Session> {
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() { fn demo_confirmation_menu() {
info!("showing demo menu"); info!("showing demo menu");
let (accept, deny) = prompt_confirmation_menu(vec![ let (accept, deny) = prompt_confirmation_menu(vec![

View file

@ -23,3 +23,4 @@ uuid = { version = "0.8", features = ["v4"] }
log = "0.4.14" log = "0.4.14"
scraper = "0.12.0" scraper = "0.12.0"
maplit = "1.0.2" maplit = "1.0.2"
thiserror = "1.0.26"

View file

@ -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<SteamGuardAccount>,
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<SteamGuardAccount, AccountLinkError> {
// 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),
}

View file

@ -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}}

View file

@ -0,0 +1 @@
{"response":{"status":29}}

View file

@ -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\"}"}

View file

@ -120,7 +120,7 @@ if ( typeof JSON != 'object' || !JSON.stringify || !JSON.parse ) { document.writ
<div class="responsive_page_content"> <div class="responsive_page_content">
<script type="text/javascript"> <script type="text/javascript">
g_sessionID = "68f328319201a5ecb430cd1df"; g_sessionID = "1234";
g_steamID = "76561198054667933"; g_steamID = "76561198054667933";
g_strLanguage = "english"; g_strLanguage = "english";
g_SNR = '2_mobileconf_conf_'; g_SNR = '2_mobileconf_conf_';

View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html class=" responsive touch legacy_mobile" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#171a21">
<title>Steam Community :: Confirmations</title>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<link href="https://community.cloudflare.steamstatic.com/public/shared/css/motiva_sans.css?v=GfSjbGKcNYaQ&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/shared/css/buttons.css?v=uR_4hRD_HUln&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/shared/css/shared_global.css?v=Add2STkxYHuV&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/globalv2.css?v=1gdnPXjQX6UG&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/skin_1/modalContent.css?v=.TP5s6TzX6LLh&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/mobile/styles_mobileconf.css?v=7eOknd5U_Oiy&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/shared/css/motiva_sans.css?v=GfSjbGKcNYaQ&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/skin_1/html5.css?v=.MtSlvoLZL0Tb&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/skin_1/economy.css?v=wliPEsKn4dhI&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/skin_1/trade.css?v=lAf9Nl_Ur8XN&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/skin_1/profile_tradeoffers.css?v=EUgAAbLAW1fW&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/shared/css/shared_responsive.css?v=bhB6Pv-oiDhL&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<link href="https://community.cloudflare.steamstatic.com/public/css/skin_1/header.css?v=kSY7-qhkPHds&amp;l=english&amp;_cdn=cloudflare" rel="stylesheet" type="text/css" >
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-33779068-1', 'auto', {
'sampleRate': 0.4 });
ga('set', 'dimension1', true );
ga('set', 'dimension2', 'Steam Mobile App' );
ga('set', 'dimension3', 'mobileconf' );
ga('set', 'dimension4', "mobileconf\/conf" );
ga('send', 'pageview' );
</script>
<script type="text/javascript">
var __PrototypePreserve=[];
__PrototypePreserve[0] = Array.from;
__PrototypePreserve[1] = Function.prototype.bind;
</script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/prototype-1.7.js?v=.55t44gwuwgvw&amp;_cdn=cloudflare" ></script>
<script type="text/javascript">
Array.from = __PrototypePreserve[0] || Array.from;
Function.prototype.bind = __PrototypePreserve[1] || Function.prototype.bind;
</script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/scriptaculous/_combined.js?v=OeNIgrpEF8tL&amp;l=english&amp;_cdn=cloudflare&amp;load=effects,controls,slider,dragdrop" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/global.js?v=kaDmsiBYUxf_&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/jquery-1.11.1.min.js?v=.isFTSRckeNhC&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/shared/javascript/tooltip.js?v=.9Z1XDV02xrml&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/shared/javascript/shared_global.js?v=RVua47VPZG4D&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript">Object.seal && [ Object, Array, String, Number ].map( function( builtin ) { Object.seal( builtin.prototype ); } );</script><script type="text/javascript">$J = jQuery.noConflict();
if ( typeof JSON != 'object' || !JSON.stringify || !JSON.parse ) { document.write( "<scr" + "ipt type=\"text\/javascript\" src=\"https:\/\/community.cloudflare.steamstatic.com\/public\/javascript\/json2.js?v=pmScf4470EZP&amp;l=english&amp;_cdn=cloudflare\" ><\/script>\n" ); };
</script><script type="text/javascript">VALVE_PUBLIC_PATH = "https:\/\/community.cloudflare.steamstatic.com\/public\/";</script>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function(event) {
SetupTooltips( { tooltipCSSClass: 'community_tooltip'} );
});
</script><script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/jquery-ui-1.9.2.min.js?v=.ILEZTVPIP_6a&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/shared/javascript/mobileappapi.js?v=KX5d7WjziQ7F&amp;l=english&amp;_cdn=cloudflare&amp;mobileClientType=android" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/mobile/mobileconf.js?v=mzd_2xm8sUkb&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/economy_common.js?v=tsXdRVB0yEaR&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/economy.js?v=n3ZFab2IK68b&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/modalv2.js?v=dfMhuy-Lrpyo&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/javascript/modalContent.js?v=SPSdiqm70dR8&amp;l=english&amp;_cdn=cloudflare" ></script>
<script type="text/javascript" src="https://community.cloudflare.steamstatic.com/public/shared/javascript/shared_responsive_adapter.js?v=gcLGc1YQkPQi&amp;l=english&amp;_cdn=cloudflare" ></script>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@steam" />
<meta property="og:title" content="Steam Community :: Confirmations">
<meta property="twitter:title" content="Steam Community :: Confirmations">
<meta property="og:type" content="website">
<meta property="fb:app_id" content="105386699540688">
<link rel="image_src" href="https://community.cloudflare.steamstatic.com/public/shared/images/responsive/share_steam_logo.png">
<meta property="og:image" content="https://community.cloudflare.steamstatic.com/public/shared/images/responsive/share_steam_logo.png">
<meta name="twitter:image" content="https://community.cloudflare.steamstatic.com/public/shared/images/responsive/share_steam_logo.png" />
<meta property="og:image:secure" content="https://community.cloudflare.steamstatic.com/public/shared/images/responsive/share_steam_logo.png">
<script type="text/javascript">
$J(function() {
window.location="steammobile:\/\/settitle?title=Confirmations"; });
</script>
</head>
<body class=" responsive_page">
<div class="responsive_page_frame no_header">
<div class="responsive_local_menu_tab">
</div>
<div class="responsive_page_menu_ctn localmenu">
<div class="responsive_page_menu" id="responsive_page_local_menu">
<div class="localmenu_content">
</div>
</div>
</div>
<div class="responsive_page_content_overlay">
</div>
<div class="responsive_fixonscroll_ctn nonresponsive_hidden no_menu">
</div>
<div class="responsive_page_content">
<script type="text/javascript">
g_sessionID = "1234";
g_steamID = "76561199155706892";
g_strLanguage = "english";
g_SNR = '2_mobileconf_conf_';
g_bAllowAppImpressions = true
g_CommunityPreferences = {"hide_adult_content_violence":1,"hide_adult_content_sex":1,"parenthesize_nicknames":0,"text_filter_setting":1,"text_filter_ignore_friends":1,"text_filter_words_revision":0,"timestamp_updated":0};
// We always want to have the timezone cookie set for PHP to use
setTimezoneCookies();
$J( function() {
InitMiniprofileHovers();
InitEmoticonHovers();
ApplyAdultContentPreferences();
});
$J( function() { InitEconomyHovers( "https:\/\/community.cloudflare.steamstatic.com\/public\/css\/skin_1\/economy.css?v=wliPEsKn4dhI&l=english&_cdn=cloudflare", "https:\/\/community.cloudflare.steamstatic.com\/public\/javascript\/economy_common.js?v=tsXdRVB0yEaR&l=english&_cdn=cloudflare", "https:\/\/community.cloudflare.steamstatic.com\/public\/javascript\/economy.js?v=n3ZFab2IK68b&l=english&_cdn=cloudflare" );});</script>
<div class="responsive_page_template_content" data-panel="{&quot;autoFocus&quot;:true}" >
<div id="mobileconf_list">
<div class="mobileconf_list_entry" id="conf9931444017" data-confid="9931444017" data-key="9746021299562127894" data-type="6" data-creator="2861625242839108895" data-cancel="Cancel" data-accept="Confirm" >
<div class="mobileconf_list_entry_content">
<div class="mobileconf_list_entry_icon">
<img src="https://community.cloudflare.steamstatic.com/public/shared/images/login/key.png" style="width: 32px; height: 32px; margin-top: 6px"> </div> <div class="mobileconf_list_entry_description">
<div>Account recovery</div>
<div></div>
<div>Just now</div>
</div>
</div>
<div class="mobileconf_list_entry_sep"></div>
</div>
</div>
<div id="mobileconf_done" class="mobileconf_done mobileconf_header" style="display: none">
<div>All done</div>
<div>You're all done, there's nothing left to confirm.</div>
</div>
<div id="mobileconf_details" style="display: none">
</div>
<div id="mobileconf_buttons" style="display: none">
<div>
<div class="mobileconf_button mobileconf_button_cancel">
</div><div class="mobileconf_button mobileconf_button_accept">
</div>
</div>
</div>
<div id="mobileconf_throbber" style="display: none">
<div style="text-align:center; margin: auto;">
<img src="https://community.cloudflare.steamstatic.com/public/images/login/throbber.gif" alt="Loading">
</div>
</div>
</div> <!-- responsive_page_legacy_content -->
<div id="footer_spacer" class=""></div>
<div id="footer_responsive_optin_spacer"></div>
<div id="footer">
<div class="footer_content">
<span id="footerLogo"><img src="https://community.cloudflare.steamstatic.com/public/images/skin_1/footerLogo_valve.png?v=1" width="96" height="26" border="0" alt="Valve Logo" /></span>
<span id="footerText">
&copy; Valve Corporation. All rights reserved. All trademarks are property of their respective owners in the US and other countries.<br/>Some geospatial data on this website is provided by <a href="https://steamcommunity.com/linkfilter/?url=http://www.geonames.org" target="_blank" rel="noreferrer">geonames.org</a>. <br>
<span class="valve_links">
<a href="http://store.steampowered.com/privacy_agreement/" target="_blank">Privacy Policy</a>
&nbsp; | &nbsp;<a href="https://store.steampowered.com/legal/" target="_blank">Legal</a>
&nbsp;| &nbsp;<a href="http://store.steampowered.com/subscriber_agreement/" target="_blank">Steam Subscriber Agreement</a>
&nbsp;| &nbsp;<a href="http://store.steampowered.com/account/cookiepreferences/" target="_blank">Cookies</a>
</span>
</span>
</div>
<div class="responsive_optin_link">
<div class="btn_medium btnv6_grey_black" onclick="Responsive_RequestMobileView()">
<span>View mobile website</span>
</div>
</div>
</div>
</div> <!-- responsive_page_content -->
</div> <!-- responsive_page_frame -->
</body>
</html>

View file

@ -1,3 +1,4 @@
pub use accountlinker::{AccountLinkError, AccountLinker, FinalizeLinkError};
use anyhow::Result; use anyhow::Result;
pub use confirmation::{Confirmation, ConfirmationType}; pub use confirmation::{Confirmation, ConfirmationType};
use hmacsha1::hmac_sha1; use hmacsha1::hmac_sha1;
@ -9,7 +10,7 @@ use reqwest::{
}; };
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, convert::TryInto, thread, time}; use std::{collections::HashMap, convert::TryInto};
pub use userlogin::{LoginError, UserLogin}; pub use userlogin::{LoginError, UserLogin};
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
@ -18,6 +19,7 @@ extern crate anyhow;
#[macro_use] #[macro_use]
extern crate maplit; extern crate maplit;
mod accountlinker;
mod confirmation; mod confirmation;
pub mod steamapi; pub mod steamapi;
mod userlogin; mod userlogin;
@ -45,6 +47,7 @@ pub struct SteamGuardAccount {
pub uri: String, pub uri: String,
pub fully_enrolled: bool, pub fully_enrolled: bool,
pub device_id: String, pub device_id: String,
pub secret_1: String,
#[serde(rename = "Session")] #[serde(rename = "Session")]
pub session: Option<steamapi::Session>, pub session: Option<steamapi::Session>,
} }
@ -82,6 +85,7 @@ impl SteamGuardAccount {
uri: String::from(""), uri: String::from(""),
fully_enrolled: false, fully_enrolled: false,
device_id: String::from(""), device_id: String::from(""),
secret_1: "".into(),
session: Option::None, session: Option::None,
}; };
} }

View file

@ -7,12 +7,16 @@ use reqwest::{
Url, Url,
}; };
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::iter::FromIterator; use std::iter::FromIterator;
use std::str::FromStr; use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::SteamGuardAccount;
lazy_static! { lazy_static! {
static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::<Url>().unwrap(); static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::<Url>().unwrap();
static ref STEAM_API_BASE: String = "https://api.steampowered.com".into();
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -82,6 +86,7 @@ pub struct OAuthData {
steamid: String, steamid: String,
wgtoken: String, wgtoken: String,
wgtoken_secure: String, wgtoken_secure: String,
#[serde(default)]
webcookie: String, webcookie: String,
} }
@ -93,7 +98,7 @@ pub struct Session {
pub steam_login: String, pub steam_login: String,
#[serde(rename = "SteamLoginSecure")] #[serde(rename = "SteamLoginSecure")]
pub steam_login_secure: String, pub steam_login_secure: String,
#[serde(rename = "WebCookie")] #[serde(default, rename = "WebCookie")]
pub web_cookie: String, pub web_cookie: String,
#[serde(rename = "OAuthToken")] #[serde(rename = "OAuthToken")]
pub token: String, pub token: String,
@ -114,7 +119,7 @@ pub fn get_server_time() -> i64 {
.unwrap(); .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)] #[derive(Debug)]
pub struct SteamApiClient { pub struct SteamApiClient {
cookies: reqwest::cookie::Jar, cookies: reqwest::cookie::Jar,
@ -123,7 +128,7 @@ pub struct SteamApiClient {
} }
impl SteamApiClient { impl SteamApiClient {
pub fn new() -> SteamApiClient { pub fn new(session: Option<Session>) -> SteamApiClient {
SteamApiClient { SteamApiClient {
cookies: reqwest::cookie::Jar::default(), cookies: reqwest::cookie::Jar::default(),
client: reqwest::blocking::ClientBuilder::new() client: reqwest::blocking::ClientBuilder::new()
@ -134,17 +139,20 @@ impl SteamApiClient {
}.into_iter())) }.into_iter()))
.build() .build()
.unwrap(), .unwrap(),
session: None, session: session,
} }
} }
fn build_session(&self, data: &OAuthData) -> Session { fn build_session(&self, data: &OAuthData) -> Session {
trace!("SteamApiClient::build_session");
return Session { return Session {
token: data.oauth_token.clone(), token: data.oauth_token.clone(),
steam_id: data.steamid.parse().unwrap(), steam_id: data.steamid.parse().unwrap(),
steam_login: format!("{}%7C%7C{}", data.steamid, data.wgtoken), steam_login: format!("{}%7C%7C{}", data.steamid, data.wgtoken),
steam_login_secure: format!("{}%7C%7C{}", data.steamid, data.wgtoken_secure), 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(), web_cookie: data.webcookie.clone(),
}; };
} }
@ -173,24 +181,35 @@ impl SteamApiClient {
} }
} }
pub fn request<U: reqwest::IntoUrl>(&self, method: reqwest::Method, url: U) -> RequestBuilder { pub fn request<U: reqwest::IntoUrl + std::fmt::Display>(
&self,
method: reqwest::Method,
url: U,
) -> RequestBuilder {
trace!("making request: {} {}", method, url);
self.cookies self.cookies
.add_cookie_str("mobileClientVersion=0 (2.1.3)", &STEAM_COOKIE_URL); .add_cookie_str("mobileClientVersion=0 (2.1.3)", &STEAM_COOKIE_URL);
self.cookies self.cookies
.add_cookie_str("mobileClient=android", &STEAM_COOKIE_URL); .add_cookie_str("mobileClient=android", &STEAM_COOKIE_URL);
self.cookies self.cookies
.add_cookie_str("Steam_Language=english", &STEAM_COOKIE_URL); .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 self.client
.request(method, url) .request(method, url)
.header(COOKIE, self.cookies.cookies(&STEAM_COOKIE_URL).unwrap()) .header(COOKIE, self.cookies.cookies(&STEAM_COOKIE_URL).unwrap())
} }
pub fn get<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder { pub fn get<U: reqwest::IntoUrl + std::fmt::Display>(&self, url: U) -> RequestBuilder {
self.request(reqwest::Method::GET, url) self.request(reqwest::Method::GET, url)
} }
pub fn post<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder { pub fn post<U: reqwest::IntoUrl + std::fmt::Display>(&self, url: U) -> RequestBuilder {
self.request(reqwest::Method::POST, url) self.request(reqwest::Method::POST, url)
} }
@ -244,6 +263,7 @@ impl SteamApiClient {
.post("https://steamcommunity.com/login/dologin") .post("https://steamcommunity.com/login/dologin")
.form(&params) .form(&params)
.send()?; .send()?;
self.save_cookies_from_response(&resp);
let text = resp.text()?; let text = resp.text()?;
trace!("raw login response: {}", 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<bool> {
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(&params)
.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(&params)
.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<bool> {
return self.phoneajax("has_phone", "null");
}
pub fn check_sms_code(&self, sms_code: String) -> anyhow::Result<bool> {
return self.phoneajax("check_sms_code", sms_code.as_str());
}
pub fn check_email_confirmation(&self) -> anyhow::Result<bool> {
return self.phoneajax("email_confirmation", "");
}
pub fn add_phone_number(&self, phone_number: String) -> anyhow::Result<bool> {
// 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<bool> {
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<AddAuthenticatorResponse> {
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(&params)
.send()?;
self.save_cookies_from_response(&resp);
let text = resp.text()?;
trace!("raw add authenticator response: {}", text);
let resp: SteamApiResponse<AddAuthenticatorResponse> = 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<FinalizeAddAuthenticatorResponse> {
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(&params)
.send()?;
let text = resp.text()?;
trace!("raw finalize authenticator response: {}", text);
let resp: SteamApiResponse<FinalizeAddAuthenticatorResponse> =
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<RemoveAuthenticatorResponse> {
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(&params)
.send()?;
let text = resp.text()?;
trace!("raw remove authenticator response: {}", text);
let resp: SteamApiResponse<RemoveAuthenticatorResponse> =
serde_json::from_str(text.as_str())?;
return Ok(resp.response);
}
} }
#[test] #[test]
@ -329,3 +548,138 @@ fn test_login_response_parse() {
); );
assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5"); assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5");
} }
#[test]
fn test_login_response_parse_missing_webcookie() {
let result = serde_json::from_str::<LoginResponse>(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<T> {
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<u64, D::Error>
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::<SteamApiResponse<AddAuthenticatorResponse>>(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::<SteamApiResponse<AddAuthenticatorResponse>>(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);
}

View file

@ -61,7 +61,7 @@ impl UserLogin {
twofactor_code: String::from(""), twofactor_code: String::from(""),
email_code: String::from(""), email_code: String::from(""),
steam_id: 0, steam_id: 0,
client: SteamApiClient::new(), client: SteamApiClient::new(None),
}; };
} }