diff --git a/components/helpers.js b/components/helpers.js index b354da5..f077790 100644 --- a/components/helpers.js +++ b/components/helpers.js @@ -57,6 +57,18 @@ exports.eresultError = function(eresult) { return err; }; +exports.decodeJwt = function(jwt) { + let parts = jwt.split('.'); + if (parts.length != 3) { + throw new Error('Invalid JWT'); + } + + let standardBase64 = parts[1].replace(/-/g, '+') + .replace(/_/g, '/'); + + return JSON.parse(Buffer.from(standardBase64, 'base64').toString('utf8')); +}; + /** * Resolves a Steam profile URL to get steamID64 and vanityURL * @param {String} url - Full steamcommunity profile URL or only the vanity part. diff --git a/components/twofactor.js b/components/twofactor.js index 651c691..eb5d105 100644 --- a/components/twofactor.js +++ b/components/twofactor.js @@ -2,158 +2,151 @@ var SteamTotp = require('steam-totp'); var SteamCommunity = require('../index.js'); var ETwoFactorTokenType = { - "None": 0, // No token-based two-factor authentication - "ValveMobileApp": 1, // Tokens generated using Valve's special charset (5 digits, alphanumeric) - "ThirdParty": 2 // Tokens generated using literally everyone else's standard charset (6 digits, numeric). This is disabled. + None: 0, // No token-based two-factor authentication + ValveMobileApp: 1, // Tokens generated using Valve's special charset (5 digits, alphanumeric) + ThirdParty: 2 // Tokens generated using literally everyone else's standard charset (6 digits, numeric). This is disabled. }; SteamCommunity.prototype.enableTwoFactor = function(callback) { - var self = this; + this._verifyMobileAccessToken(); - this.getWebApiOauthToken(function(err, token) { - if(err) { + if (!this.mobileAccessToken) { + callback(new Error('No mobile access token available. Provide one by calling setMobileAppAccessToken()')); + return; + } + + this.httpRequestPost({ + uri: "https://api.steampowered.com/ITwoFactorService/AddAuthenticator/v1/?access_token=" + this.mobileAccessToken, + // TODO: Send this as protobuf to more closely mimic official app behavior + form: { + steamid: this.steamID.getSteamID64(), + authenticator_time: Math.floor(Date.now() / 1000), + authenticator_type: ETwoFactorTokenType.ValveMobileApp, + device_identifier: SteamTotp.getDeviceID(this.steamID), + sms_phone_id: '1' + }, + json: true + }, (err, response, body) => { + if (err) { callback(err); return; } - self.httpRequestPost({ - "uri": "https://api.steampowered.com/ITwoFactorService/AddAuthenticator/v1/", - "form": { - "steamid": self.steamID.getSteamID64(), - "access_token": token, - "authenticator_time": Math.floor(Date.now() / 1000), - "authenticator_type": ETwoFactorTokenType.ValveMobileApp, - "device_identifier": SteamTotp.getDeviceID(self.steamID), - "sms_phone_id": "1" - }, - "json": true - }, function(err, response, body) { - if (err) { - callback(err); - return; - } + if (!body.response) { + callback(new Error('Malformed response')); + return; + } - if(!body.response) { - callback(new Error("Malformed response")); - return; - } + if (body.response.status != 1) { + var error = new Error('Error ' + body.response.status); + error.eresult = body.response.status; + callback(error); + return; + } - if(body.response.status != 1) { - var error = new Error("Error " + body.response.status); - error.eresult = body.response.status; - callback(error); - return; - } - - callback(null, body.response); - }, "steamcommunity"); - }); + callback(null, body.response); + }, 'steamcommunity'); }; SteamCommunity.prototype.finalizeTwoFactor = function(secret, activationCode, callback) { - var attemptsLeft = 30; - var diff = 0; + this._verifyMobileAccessToken(); - var self = this; - this.getWebApiOauthToken(function(err, token) { - if(err) { - callback(err); - return; - } + if (!this.mobileAccessToken) { + callback(new Error('No mobile access token available. Provide one by calling setMobileAppAccessToken()')); + return; + } - SteamTotp.getTimeOffset(function(err, offset, latency) { - if (err) { - callback(err); - return; - } + let attemptsLeft = 30; + let diff = 0; - diff = offset; - finalize(token); - }); - }); + let finalize = () => { + let code = SteamTotp.generateAuthCode(secret, diff); - function finalize(token) { - var code = SteamTotp.generateAuthCode(secret, diff); - - self.httpRequestPost({ - "uri": "https://api.steampowered.com/ITwoFactorService/FinalizeAddAuthenticator/v1/", - "form": { - "steamid": self.steamID.getSteamID64(), - "access_token": token, - "authenticator_code": code, - "authenticator_time": Math.floor(Date.now() / 1000), - "activation_code": activationCode + this.httpRequestPost({ + uri: 'https://api.steampowered.com/ITwoFactorService/FinalizeAddAuthenticator/v1/?access_token=' + this.mobileAccessToken, + form: { + steamid: this.steamID.getSteamID64(), + authenticator_code: code, + authenticator_time: Math.floor(Date.now() / 1000), + activation_code: activationCode }, - "json": true + json: true }, function(err, response, body) { if (err) { callback(err); return; } - if(!body.response) { - callback(new Error("Malformed response")); + if (!body.response) { + callback(new Error('Malformed response')); return; } body = body.response; - if(body.server_time) { + if (body.server_time) { diff = body.server_time - Math.floor(Date.now() / 1000); } - if(body.status == 89) { - callback(new Error("Invalid activation code")); + if (body.status == 89) { + callback(new Error('Invalid activation code')); } else if(body.want_more) { attemptsLeft--; diff += 30; - finalize(token); + finalize(); } else if(!body.success) { - callback(new Error("Error " + body.status)); + callback(new Error('Error ' + body.status)); } else { callback(null); } - }, "steamcommunity"); + }, 'steamcommunity'); } -}; -SteamCommunity.prototype.disableTwoFactor = function(revocationCode, callback) { - var self = this; - - this.getWebApiOauthToken(function(err, token) { - if(err) { + SteamTotp.getTimeOffset(function(err, offset, latency) { + if (err) { callback(err); return; } - self.httpRequestPost({ - "uri": "https://api.steampowered.com/ITwoFactorService/RemoveAuthenticator/v1/", - "form": { - "steamid": self.steamID.getSteamID64(), - "access_token": token, - "revocation_code": revocationCode, - "steamguard_scheme": 1 - }, - "json": true - }, function(err, response, body) { - if (err) { - callback(err); - return; - } - - if(!body.response) { - callback(new Error("Malformed response")); - return; - } - - if(!body.response.success) { - callback(new Error("Request failed")); - return; - } - - // success = true means it worked - callback(null); - }, "steamcommunity"); + diff = offset; + finalize(); }); }; + +SteamCommunity.prototype.disableTwoFactor = function(revocationCode, callback) { + this._verifyMobileAccessToken(); + + if (!this.mobileAccessToken) { + callback(new Error('No mobile access token available. Provide one by calling setMobileAppAccessToken()')); + return; + } + + this.httpRequestPost({ + uri: 'https://api.steampowered.com/ITwoFactorService/RemoveAuthenticator/v1/?access_token=' + this.mobileAccessToken, + form: { + steamid: this.steamID.getSteamID64(), + revocation_code: revocationCode, + steamguard_scheme: 1 + }, + json: true + }, function(err, response, body) { + if (err) { + callback(err); + return; + } + + if (!body.response) { + callback(new Error('Malformed response')); + return; + } + + if (!body.response.success) { + callback(new Error('Request failed')); + return; + } + + // success = true means it worked + callback(null); + }, 'steamcommunity'); +}; diff --git a/components/webapi.js b/components/webapi.js index b1ea5c4..a138ee5 100644 --- a/components/webapi.js +++ b/components/webapi.js @@ -1,5 +1,7 @@ var SteamCommunity = require('../index.js'); +const Helpers = require('./helpers.js'); + SteamCommunity.prototype.getWebApiKey = function(domain, callback) { var self = this; this.httpRequest({ @@ -45,7 +47,7 @@ SteamCommunity.prototype.getWebApiKey = function(domain, callback) { }; /** - * @deprecated No longer works if not logged in via mobile login. Will be removed in a future release. + * @deprecated No longer works. Will be removed in a future release. * @param {function} callback */ SteamCommunity.prototype.getWebApiOauthToken = function(callback) { @@ -53,5 +55,64 @@ SteamCommunity.prototype.getWebApiOauthToken = function(callback) { return callback(null, this.oAuthToken); } - callback(new Error('This operation requires an OAuth token, which can only be obtained from node-steamcommunity\'s `login` method.')); + callback(new Error('This operation requires an OAuth token, which is no longer issued by Steam.')); +}; + +/** + * Sets an access_token generated by steam-session using EAuthTokenPlatformType.MobileApp. + * Required for some operations such as 2FA enabling and disabling. + * This will throw an Error if the provided token is not valid, was not generated for the MobileApp platform, is expired, + * or does not belong to the logged-in user account. + * + * @param {string} token + */ +SteamCommunity.prototype.setMobileAppAccessToken = function(token) { + if (!this.steamID) { + throw new Error('Log on to steamcommunity before setting a mobile app access token'); + } + + let decodedToken = Helpers.decodeJwt(token); + + if (!decodedToken.iss || !decodedToken.sub || !decodedToken.aud || !decodedToken.exp) { + throw new Error('Provided value is not a valid Steam access token'); + } + + if (decodedToken.iss == 'steam') { + throw new Error('Provided token is a refresh token, not an access token'); + } + + if (decodedToken.sub != this.steamID.getSteamID64()) { + throw new Error(`Provided token belongs to account ${decodedToken.sub}, but we are logged into ${this.steamID.getSteamID64()}`); + } + + if (decodedToken.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Provided token is expired'); + } + + if ((decodedToken.aud || []).indexOf('mobile') == -1) { + throw new Error('Provided token is not valid for MobileApp platform type'); + } + + this.mobileAccessToken = token; +}; + +/** + * Verifies that the mobile access token we already have set is still valid for current login. + * + * @private + */ +SteamCommunity.prototype._verifyMobileAccessToken = function() { + if (!this.mobileAccessToken) { + // No access token, so nothing to do here. + return; + } + + let decodedToken = Helpers.decodeJwt(this.mobileAccessToken); + + let isTokenInvalid = decodedToken.sub != this.steamID.getSteamID64() // SteamID doesn't match + || decodedToken.exp < Math.floor(Date.now() / 1000); // Token is expired + + if (isTokenInvalid) { + delete this.mobileAccessToken; + } }; diff --git a/examples/disable_twofactor.js b/examples/disable_twofactor.js index 1c2c617..389ca93 100644 --- a/examples/disable_twofactor.js +++ b/examples/disable_twofactor.js @@ -48,15 +48,34 @@ function doLogin(accountName, password, authCode, captcha, rCode) { } console.log('Logged on!'); - community.disableTwoFactor('R' + rCode, (err) => { - if (err) { - console.log(err); - process.exit(); - return; - } - console.log('Two-factor authentication disabled!'); - process.exit(); + if (community.mobileAccessToken) { + // If we already have a mobile access token, we don't need to prompt for one. + doRevoke(rCode); + return; + } + + console.log('You need to provide a mobile app access token to continue.'); + console.log('You can generate one using steam-session (https://www.npmjs.com/package/steam-session).'); + console.log('The access token needs to be generated using EAuthTokenPlatformType.MobileApp.'); + console.log('Make sure you provide an *ACCESS* token, not a refresh token.'); + + rl.question('Access Token: ', (accessToken) => { + community.setMobileAppAccessToken(accessToken); + doRevoke(rCode); }); }); } + +function doRevoke(rCode) { + community.disableTwoFactor('R' + rCode, (err) => { + if (err) { + console.log(err); + process.exit(); + return; + } + + console.log('Two-factor authentication disabled!'); + process.exit(); + }); +} diff --git a/examples/enable_twofactor.js b/examples/enable_twofactor.js index 60495a8..9f95942 100644 --- a/examples/enable_twofactor.js +++ b/examples/enable_twofactor.js @@ -56,41 +56,60 @@ function doLogin(accountName, password, authCode, captcha) { } console.log('Logged on!'); - community.enableTwoFactor((err, response) => { - if (err) { - if (err.eresult == EResult.Fail) { - console.log('Error: Failed to enable two-factor authentication. Do you have a phone number attached to your account?'); - process.exit(); - return; - } - if (err.eresult == EResult.RateLimitExceeded) { - console.log('Error: RateLimitExceeded. Try again later.'); - process.exit(); - return; - } + if (community.mobileAccessToken) { + // If we already have a mobile access token, we don't need to prompt for one. + doSetup(); + return; + } - console.log(err); - process.exit(); - return; - } + console.log('You need to provide a mobile app access token to continue.'); + console.log('You can generate one using steam-session (https://www.npmjs.com/package/steam-session).'); + console.log('The access token needs to be generated using EAuthTokenPlatformType.MobileApp.'); + console.log('Make sure you provide an *ACCESS* token, not a refresh token.'); - if (response.status != EResult.OK) { - console.log(`Error: Status ${response.status}`); - process.exit(); - return; - } - - let filename = `twofactor_${community.steamID.getSteamID64()}.json`; - console.log(`Writing secrets to ${filename}`); - console.log(`Revocation code: ${response.revocation_code}`); - FS.writeFileSync(filename, JSON.stringify(response, null, '\t')); - - promptActivationCode(response); + rl.question('Access Token: ', (accessToken) => { + community.setMobileAppAccessToken(accessToken); + doSetup(); }); }); } +function doSetup() { + community.enableTwoFactor((err, response) => { + if (err) { + if (err.eresult == EResult.Fail) { + console.log('Error: Failed to enable two-factor authentication. Do you have a phone number attached to your account?'); + process.exit(); + return; + } + + if (err.eresult == EResult.RateLimitExceeded) { + console.log('Error: RateLimitExceeded. Try again later.'); + process.exit(); + return; + } + + console.log(err); + process.exit(); + return; + } + + if (response.status != EResult.OK) { + console.log(`Error: Status ${response.status}`); + process.exit(); + return; + } + + let filename = `twofactor_${community.steamID.getSteamID64()}.json`; + console.log(`Writing secrets to ${filename}`); + console.log(`Revocation code: ${response.revocation_code}`); + FS.writeFileSync(filename, JSON.stringify(response, null, '\t')); + + promptActivationCode(response); + }); +} + function promptActivationCode(response) { rl.question('SMS Code: ', (smsCode) => { community.finalizeTwoFactor(response.shared_secret, smsCode, (err) => { diff --git a/index.js b/index.js index 64e3756..6cd2257 100644 --- a/index.js +++ b/index.js @@ -65,7 +65,7 @@ SteamCommunity.prototype.login = function(details, callback) { this._setCookie(Request.cookie('steamMachineAuth' + parts[0] + '=' + encodeURIComponent(parts[1])), true); } - var disableMobile = details.disableMobile; + var disableMobile = typeof details.disableMobile == 'undefined' ? true : details.disableMobile; var self = this; @@ -123,7 +123,7 @@ SteamCommunity.prototype.login = function(details, callback) { "donotcache": Date.now() }; - if(!disableMobile){ + if (!disableMobile) { formObj.oauth_client_id = "DE45CD61"; formObj.oauth_scope = "read_profile write_profile read_client write_client"; formObj.loginfriendlyname = "#login_emailauth_friendlyname_mobile"; @@ -161,22 +161,20 @@ SteamCommunity.prototype.login = function(details, callback) { callback(error); } else if (!body.success) { callback(new Error(body.message || "Unknown error")); - } else if (!disableMobile && !body.oauth) { - callback(new Error("Malformed response")); } else { var sessionID = generateSessionID(); - var oAuth; + var oAuth = {}; self._setCookie(Request.cookie('sessionid=' + sessionID)); var cookies = self._jar.getCookieString("https://steamcommunity.com").split(';').map(function(cookie) { return cookie.trim(); }); - if (!disableMobile){ + if (!disableMobile && body.oauth) { oAuth = JSON.parse(body.oauth); self.steamID = new SteamID(oAuth.steamid); self.oAuthToken = oAuth.oauth_token; - }else{ + } else { for(var i = 0; i < cookies.length; i++) { var parts = cookies[i].split('='); if(parts[0] == 'steamLogin') { @@ -190,7 +188,7 @@ SteamCommunity.prototype.login = function(details, callback) { // Find the Steam Guard cookie var steamguard = null; - for(var i = 0; i < cookies.length; i++) { + for (var i = 0; i < cookies.length; i++) { var parts = cookies[i].split('='); if(parts[0] == 'steamMachineAuth' + self.steamID) { steamguard = self.steamID.toString() + '||' + decodeURIComponent(parts[1]); @@ -216,6 +214,12 @@ SteamCommunity.prototype.login = function(details, callback) { } }; +/** + * @deprecated + * @param {string} steamguard + * @param {string} token + * @param {function} callback + */ SteamCommunity.prototype.oAuthLogin = function(steamguard, token, callback) { steamguard = steamguard.split('||'); var steamID = new SteamID(steamguard[0]); @@ -302,6 +306,10 @@ SteamCommunity.prototype.setCookies = function(cookies) { this._setCookie(Request.cookie(cookie), !!(cookieName.match(/^steamMachineAuth/) || cookieName.match(/Secure$/))); }); + + // The account we're logged in as might have changed, so verify that our mobile access token (if any) is still valid + // for this account. + this._verifyMobileAccessToken(); }; SteamCommunity.prototype.getSessionID = function(host = "http://steamcommunity.com") { diff --git a/package.json b/package.json index afd186a..a55aa78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "steamcommunity", - "version": "3.44.3", + "version": "3.45.0", "description": "Provides an interface for logging into and interacting with the Steam Community website", "keywords": [ "steam",