Proper name spoofing protection (API change, no actual protection!) (#42)

* 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
This commit is contained in:
Barnaby 2022-03-24 21:22:12 +00:00 committed by GitHub
parent 85c0cd2966
commit 737c637c9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 422 additions and 22 deletions

6
.gitignore vendored
View File

@ -120,3 +120,9 @@ dist
# db files
.db
playerdata.db
# ssl keys
*.pem
# origin sid cookie
sid.cookie

0
.husky/pre-commit Executable file → Normal file
View File

View File

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

57
dbSchema.json Normal file
View File

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

View File

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

49
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 ] )

212
shared/origin.js Normal file
View File

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