From 737c637c9f4e9708eab3d8430a908dd5e549d689 Mon Sep 17 00:00:00 2001 From: Barnaby <22575741+barnabwhy@users.noreply.github.com> Date: Thu, 24 Mar 2022 21:22:12 +0000 Subject: [PATCH] Proper name spoofing protection (API change, no actual protection!) (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Working username fetching and db field also sends the username in authenticate_incoming_player * Update dev.env * Switch from using puppeteer to just http * Fix indentation * fail open instead of closed if i’m not mistaken, failing every time they auth will result in username being “null”, otherwise will remain as the previous value * should make the default username in db be empty string * Fix username fetch (added missing package) * Format * eslint pain * eslint pain episode 2 * switch to async func over promise * i removed by accident, makes it work again * allow origin stuff to be disabled * Add persistent sid cookie * remove newline in sid.cookie * automatic db upgrading using dbSchema.json * Catch origin auth error * Nicely do this instead * Save non-authed state when failing --- .gitignore | 6 ++ .husky/pre-commit | 0 client/clientauth.js | 16 +++- dbSchema.json | 57 ++++++++++++ dev.env | 6 ++ package-lock.json | 49 ++++++++-- package.json | 3 +- shared/accounts.js | 95 ++++++++++++++++--- shared/origin.js | 212 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 422 insertions(+), 22 deletions(-) mode change 100755 => 100644 .husky/pre-commit create mode 100644 dbSchema.json create mode 100644 shared/origin.js diff --git a/.gitignore b/.gitignore index 56b1382..c0d1c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,9 @@ dist # db files .db playerdata.db + +# ssl keys +*.pem + +# origin sid cookie +sid.cookie \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 diff --git a/client/clientauth.js b/client/clientauth.js index 32c6b53..039d336 100644 --- a/client/clientauth.js +++ b/client/clientauth.js @@ -3,6 +3,7 @@ const crypto = require( "crypto" ) const { GetGameServers } = require( path.join( __dirname, "../shared/gameserver.js" ) ) const accounts = require( path.join( __dirname, "../shared/accounts.js" ) ) const asyncHttp = require( path.join( __dirname, "../shared/asynchttp.js" ) ) +const { getUserInfo, getOriginAuthState } = require( path.join( __dirname, "../shared/origin.js" ) ) let shouldRequireSessionToken = process.env.REQUIRE_SESSION_TOKEN = true @@ -75,6 +76,16 @@ module.exports = ( fastify, opts, done ) => return { success: false, error: UNAUTHORIZED_GAME } } + let playerUsername + try + { + if( getOriginAuthState() ) playerUsername = ( await getUserInfo( request.query.id ) ).EAID[0] // try to find username of player + } + catch( e ) + { + // don't do this: return { success: false } // fail if we can't find it + } + let account = await accounts.AsyncGetPlayerByID( request.query.id ) if ( !account ) // create account for user { @@ -85,6 +96,8 @@ module.exports = ( fastify, opts, done ) => let authToken = crypto.randomBytes( 16 ).toString( "hex" ) accounts.AsyncUpdateCurrentPlayerAuthToken( account.id, authToken ) + if ( playerUsername ) accounts.AsyncUpdatePlayerUsername( account.id, playerUsername ) + accounts.AsyncUpdatePlayerAuthIp( account.id, request.ip ) return { @@ -140,7 +153,7 @@ module.exports = ( fastify, opts, done ) => method: "POST", host: server.ip, port: server.authPort, - path: `/authenticate_incoming_player?id=${request.query.id}&authToken=${authToken}&serverAuthToken=${server.serverAuthToken}` + path: `/authenticate_incoming_player?id=${request.query.id}&authToken=${authToken}&serverAuthToken=${server.serverAuthToken}&username=${account.username}` }, pdata ) } catch @@ -160,6 +173,7 @@ module.exports = ( fastify, opts, done ) => return { success: true, + ip: server.ip, port: server.port, authToken: authToken diff --git a/dbSchema.json b/dbSchema.json new file mode 100644 index 0000000..4a95f67 --- /dev/null +++ b/dbSchema.json @@ -0,0 +1,57 @@ +{ + "accounts": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "modifier": "PRIMARY KEY NOT NULL" + }, + { + "name": "currentAuthToken", + "type": "TEXT" + }, + { + "name": "currentAuthTokenExpirationTime", + "type": "INTEGER" + }, + { + "name": "currentServerId", + "type": "TEXT" + }, + { + "name": "persistentDataBaseline", + "type": "BLOB", + "modifier": "NOT NULL" + }, + { + "name": "lastAuthIp", + "type": "TEXT" + }, + { + "name": "username", + "type": "TEXT", + "modifier": "DEFAULT ''" + } + ] + }, + "modPersistentData": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "modifier": "NOT NULL" + }, + { + "name": "pdiffHash", + "type": "TEXT", + "modifier": "NOT NULL" + }, + { + "name": "data", + "type": "TEXT", + "modifier": "NOT NULL" + } + ], + "extra": "PRIMARY KEY ( id, pdiffHash )" + } +} \ No newline at end of file diff --git a/dev.env b/dev.env index 85b72c0..d8541ec 100644 --- a/dev.env +++ b/dev.env @@ -27,3 +27,9 @@ REQ_PER_MINUTE__SERVER_HEARTBEAT=60 REQ_PER_MINUTE__SERVER_UPDATEVALUES=20 REQ_PER_MINUTE__SERVER_REMOVESERVER=5 REQ_PER_MINUTE__ACCOUNT_WRITEPERSISTENCE=50 + +# origin +ORIGIN_ENABLE=1 +ORIGIN_PERSIST_SID=1 +ORIGIN_EMAIL= +ORIGIN_PASSWORD= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1378c98..3638566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "fastify-multipart": "^5.1.0", "fastify-rate-limit": "^5.7.0", "fastify-static": "^4.5.0", - "sqlite3": "^5.0.2" + "sqlite3": "^5.0.2", + "xml2js": "^0.4.23" }, "devDependencies": { "eslint": "^8.6.0", @@ -788,9 +789,9 @@ } }, "node_modules/debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", "dependencies": { "ms": "2.1.2" }, @@ -3792,6 +3793,26 @@ "node": ">=8" } }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -4403,9 +4424,9 @@ } }, "debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", "requires": { "ms": "2.1.2" } @@ -6745,6 +6766,20 @@ "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", "dev": true }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 56727f2..3909cbf 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "fastify-multipart": "^5.1.0", "fastify-rate-limit": "^5.7.0", "fastify-static": "^4.5.0", - "sqlite3": "^5.0.2" + "sqlite3": "^5.0.2", + "xml2js": "^0.4.23" }, "devDependencies": { "eslint": "^8.6.0", diff --git a/shared/accounts.js b/shared/accounts.js index d5c2de1..97c6ae1 100644 --- a/shared/accounts.js +++ b/shared/accounts.js @@ -7,7 +7,10 @@ const DEFAULT_PDATA_BASELINE = fs.readFileSync( "default.pdata" ) // const pjson = require( path.join( __dirname, "../shared/pjson.js" ) ) // const DEFAULT_PDEF_OBJECT = pjson.ParseDefinition( fs.readFileSync( "persistent_player_data_version_231.pdef" ).toString() ) -let playerDB = new sqlite.Database( "playerdata.db", sqlite.OPEN_CREATE | sqlite.OPEN_READWRITE, ex => +const dbSchemaRaw = fs.readFileSync( "./dbSchema.json" ) +const dbSchema = JSON.parse( dbSchemaRaw ) + +let playerDB = new sqlite.Database( "playerdata.db", sqlite.OPEN_CREATE | sqlite.OPEN_READWRITE, async ex => { if ( ex ) console.error( ex ) @@ -18,12 +21,11 @@ let playerDB = new sqlite.Database( "playerdata.db", sqlite.OPEN_CREATE | sqlite // this should mirror the PlayerAccount class's properties playerDB.run( ` CREATE TABLE IF NOT EXISTS accounts ( - id TEXT PRIMARY KEY NOT NULL, - currentAuthToken TEXT, - currentAuthTokenExpirationTime INTEGER, - currentServerId TEXT, - persistentDataBaseline BLOB NOT NULL, - lastAuthIp TEXT + ${ dbSchema.accounts.columns.map( ( col ) => + { + return `${col.name} ${col.type} ${col.modifier ? col.modifier : ""}` + } ).join( ",\n\r\t\t" ) } + ${ dbSchema.accounts.extra ? ","+dbSchema.accounts.extra : "" } ) `, ex => { @@ -37,10 +39,11 @@ let playerDB = new sqlite.Database( "playerdata.db", sqlite.OPEN_CREATE | sqlite // this should mirror the PlayerAccount class's properties playerDB.run( ` CREATE TABLE IF NOT EXISTS modPersistentData ( - id TEXT NOT NULL, - pdiffHash TEXT NOT NULL, - data TEXT NOT NULL, - PRIMARY KEY ( id, pdiffHash ) + ${ dbSchema.modPersistentData.columns.map( ( col ) => + { + return `${col.name} ${col.type} ${col.modifier ? col.modifier : ""}` + } ).join( ",\n\r\t\t" ) } + ${ dbSchema.modPersistentData.extra ? ","+dbSchema.modPersistentData.extra : "" } ) `, ex => { @@ -49,6 +52,23 @@ let playerDB = new sqlite.Database( "playerdata.db", sqlite.OPEN_CREATE | sqlite else console.log( "Created mod persistent data table successfully" ) } ) + + for ( const col of dbSchema.accounts.columns ) + { + if( !await columnExists( "accounts", col.name ) ) + { + console.log( `The 'accounts' table is missing the '${col.name}' column` ) + await addColumnToTable( "accounts", col ) + } + } + for ( const col of dbSchema.modPersistentData.columns ) + { + if( !await columnExists( "modPersistentData", col.name ) ) + { + console.log( `The 'modPersistentData' table is missing the '${col.name}' column` ) + await addColumnToTable( "modPersistentData", col ) + } + } } ) function asyncDBGet( sql, params = [] ) @@ -85,6 +105,49 @@ function asyncDBRun( sql, params = [] ) } ) } +function columnExists( tableName, colName ) +{ + return new Promise( ( resolve, reject ) => + { + playerDB.get( ` + SELECT COUNT(*) AS CNTREC FROM pragma_table_info('${tableName}') WHERE name='${colName}' + `, [], ( ex, row ) => + { + if ( ex ) + { + console.error( "Encountered error querying database: " + ex ) + reject( ex ) + } + else + { + resolve( row.CNTREC == 1 ) + } + } ) + } ) +} + +function addColumnToTable( tableName, column ) +{ + return new Promise( ( resolve, reject ) => + { + playerDB.run( ` + ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.type} ${column.modifier ? column.modifier : ""} + `, ex => + { + if ( ex ) + { + console.error( "Encountered error adding column to database: " + ex ) + reject( ex ) + } + else + { + console.log( `Added '${column.name}' column to the '${tableName}' table` ) + resolve() + } + } ) + } ) +} + class PlayerAccount { // mirrors account struct in db @@ -95,7 +158,7 @@ class PlayerAccount // string currentServerId // Buffer persistentDataBaseline - constructor ( id, currentAuthToken, currentAuthTokenExpirationTime, currentServerId, persistentDataBaseline, lastAuthIp ) + constructor ( id, currentAuthToken, currentAuthTokenExpirationTime, currentServerId, persistentDataBaseline, lastAuthIp, username ) { this.id = id this.currentAuthToken = currentAuthToken @@ -103,6 +166,7 @@ class PlayerAccount this.currentServerId = currentServerId this.persistentDataBaseline = persistentDataBaseline this.lastAuthIp = lastAuthIp + this.username = username } } @@ -114,7 +178,7 @@ module.exports = { if ( !row ) return null - return new PlayerAccount( row.id, row.currentAuthToken, row.currentAuthTokenExpirationTime, row.currentServerId, row.persistentDataBaseline, row.lastAuthIp ) + return new PlayerAccount( row.id, row.currentAuthToken, row.currentAuthTokenExpirationTime, row.currentServerId, row.persistentDataBaseline, row.lastAuthIp, row.username ) }, AsyncCreateAccountForID: async function AsyncCreateAccountForID( id ) @@ -127,6 +191,11 @@ module.exports = { await asyncDBRun( "UPDATE accounts SET currentAuthToken = ?, currentAuthTokenExpirationTime = ? WHERE id = ?", [ token, Date.now() + TOKEN_EXPIRATION_TIME, id ] ) }, + AsyncUpdatePlayerUsername: async function AsyncUpdatePlayerUsername( id, username ) + { + await asyncDBRun( "UPDATE accounts SET username = ? WHERE id = ?", [ username, id ] ) + }, + AsyncUpdatePlayerAuthIp: async function AsyncUpdatePlayerAuthIp( id, lastAuthIp ) { await asyncDBRun( "UPDATE accounts SET lastAuthIp = ? WHERE id = ?", [ lastAuthIp, id ] ) diff --git a/shared/origin.js b/shared/origin.js new file mode 100644 index 0000000..484609d --- /dev/null +++ b/shared/origin.js @@ -0,0 +1,212 @@ +let sidCookie +let AuthToken +let authed = false +const fs = require( "fs" ) +const https = require( "https" ) +const { parseString } = require( "xml2js" ) + +async function authWithOrigin() +{ // thanks to r-ex for the help + const fidURL = "https://accounts.ea.com/connect/auth?response_type=code&client_id=ORIGIN_SPA_ID&display=originXWeb/login&locale=en_US&release_type=prod&redirect_uri=https://www.origin.com/views/login.html" + const loginURL = "https://accounts.ea.com/connect/auth?client_id=ORIGIN_JS_SDK&response_type=token&redirect_uri=nucleus:rest&prompt=none&release_type=prod" + + if( !sidCookie ) + { + let fidLocation = ( await GetHeaders( fidURL ) )["location"] + // let fid = fidLocation.match(/(?<=fid=)[a-zA-Z0-9]+?(?=&|$)/g)[0]; + let jSessionIDheaders = await GetHeaders( fidLocation ) + let jSessionID = jSessionIDheaders["set-cookie"].join( "; " ).match( /(?<=JSESSIONID=)[\S]+?(?=;)/g )[0] + let signinCookie = jSessionIDheaders["set-cookie"].join( "; " ).match( /(?<=signin-cookie=)[\S]+?(?=;)/g )[0] + let jSessionLocation = `https://signin.ea.com${jSessionIDheaders["location"]}` + + // AuthorizeLogin + let authData = { + "email": process.env.ORIGIN_EMAIL, + "password": process.env.ORIGIN_PASSWORD, + "_eventId": "submit", + "cid": GenerateCID(), + "showAgeUp": "true", + "thirdPartyCaptchaResponse": "", + "_rememberMe": "on", + "rememberMe": "on" + } + let authResponse = await PostData( jSessionLocation, authData, {"Cookie": [`JSESSIONID=${jSessionID}`, `signin-cookie=${signinCookie}`]} ) + let authResLocation = authResponse.toString().match( /(?<=window\.location = ")\S+(?=";)/g )[0] + let sidHeaders = await GetHeaders( authResLocation, {"Cookie": [`JSESSIONID=${jSessionID}`, `signin-cookie=${signinCookie}`]} ) + sidCookie = sidHeaders["set-cookie"].join( "; " ).match( /(?<=sid=)[\S]+?(?=;)/g )[0] + } + let authTokenRes = await GetData( loginURL, {"Cookie": [`sid=${sidCookie}`]} ) + let authResJson = JSON.parse( authTokenRes.toString() ) + + if( authResJson.error ) + { + authed = false + console.log( `Error authing with Origin: '${authResJson.error}'` ) + } + else + { + + AuthToken = authResJson.access_token + console.log( "Successfully got Origin auth token" ) + authed = true + + if( process.env.ORIGIN_PERSIST_SID ) + { + fs.writeFile( "./sid.cookie", sidCookie, ( err ) => + { + if( err ) console.log( "Failed to save Origin sid cookie" ) + else console.log( "Saved Origin sid cookie" ) + } ) + } + + setTimeout( authWithOrigin, Number( authResJson.expires_in )*1000 - 60000 ) // Refresh access token 1 minute before it expires just to be safe + } +} + +if( process.env.ORIGIN_ENABLE ) +{ + console.log( "Attempting to auth with Origin" ) + if( process.env.ORIGIN_PERSIST_SID && fs.existsSync( "./sid.cookie" ) ) + { + console.log( "Found Origin sid cookie, reading data" ) + sidCookie = fs.readFileSync( "./sid.cookie", "utf-8" ).replace( /\r?\n|\r/g, "" ) + } + authWithOrigin() +} + +function GenerateCID() +{ + var l = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" + var h = 32 + var j = "" + for ( var k = 0; k < h; k++ ) + { + var m = Math.floor( Math.random() * l.length ) + j += l.substring( m, m + 1 ) + } + return j +} + +function GetHeaders( location, headers = {} ) +{ + return new Promise( resolve => + { + let params = { headers } + let href = new URL( location ) + params.host = href.host + params.path = href.pathname + href.search + https.get( params, reqResult => + { + resolve( reqResult.headers ) + } ) + } ) +} +function GetData( location, headers = {} ) +{ + return new Promise( resolve => + { + let params = { headers } + let href = new URL( location ) + params.host = href.host + params.path = href.pathname + href.search + https.get( params, reqResult => + { + let data = [] + reqResult.on( "data", c => data.push( c ) ) + // eslint-disable-next-line + reqResult.on( "end", _ => resolve( Buffer.concat( data ) ) ) + } ) + } ) +} + +// data must be an object to be sent as x-www-form-urlencoded +function PostData( location, postData, headers = {} ) +{ + return new Promise( ( resolve, reject ) => + { + let params = { headers } + let href = new URL( location ) + params.method = "POST" + + params.host = href.host + params.path = href.pathname + href.search + + if ( postData ) + { + var body = new URLSearchParams() + for( var name in postData ) + { + body.append( name, postData[name] ) + } + + headers["Content-Length"] = body.toString().length + headers["Content-Type"] = "application/x-www-form-urlencoded" + } + let req = https.request( params, reqResult => + { + let data = [] + reqResult.on( "data", c => data.push( c ) ) + // eslint-disable-next-line + reqResult.on( "end", _ => resolve( Buffer.concat( data ) ) ) + } ) + + req.on( "error", e => + { + reject( e ) + } ) + + if ( postData ) + { + req.write( body.toString() ) + } + + req.end() + } ) +} + +const asyncHttp = require( "./asynchttp.js" ) + +async function getUserInfo( uid ) +{ + try + { + if( !authed || !AuthToken ) return + + let response = await asyncHttp.request( { + method: "GET", + host: "https://api1.origin.com", + port: 443, + path: `/atom/users?userIds=${uid}`, + headers: { "AuthToken": AuthToken } + } ) + + let json + try + { + json = await new Promise( resolve => + { + parseString( response.toString(), function ( err, result ) + { + resolve( result ) + } ) + } ) + } + catch ( error ) + { + return + } + return json.users.user[0] + } + catch ( error ) + { + return + } +} + +module.exports = { + getOriginAuthState: function getOriginAuthState() + { + return authed + }, + getUserInfo +}