diff --git a/components/helpers.js b/components/helpers.js index 1da5443..d611686 100644 --- a/components/helpers.js +++ b/components/helpers.js @@ -54,3 +54,15 @@ exports.eresultError = function(eresult) { err.eresult = 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')); +} 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 17c73b7..dc38428 100644 --- a/index.js +++ b/index.js @@ -214,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]); @@ -300,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") {