From f49acfec35809681087304b2681fa45e9cd23d37 Mon Sep 17 00:00:00 2001 From: Casper Warden <216465704+casperwardensl@users.noreply.github.com> Date: Fri, 6 May 2022 12:20:50 +0100 Subject: [PATCH] MFA support --- examples/ExampleBot.ts | 18 +++--- examples/MFA/MFA.ts | 55 +++++++++++++++++ examples/Region/Agents.ts | 26 ++++---- lib/Bot.ts | 5 ++ lib/LoginHandler.ts | 106 +++++++++++++++++++++------------ lib/classes/LoginError.ts | 17 ++++++ lib/classes/LoginParameters.ts | 2 + lib/classes/LoginResponse.ts | 5 +- package.json | 2 +- 9 files changed, 178 insertions(+), 58 deletions(-) create mode 100644 examples/MFA/MFA.ts create mode 100644 lib/classes/LoginError.ts diff --git a/examples/ExampleBot.ts b/examples/ExampleBot.ts index 52b2ded..41ca4cd 100644 --- a/examples/ExampleBot.ts +++ b/examples/ExampleBot.ts @@ -14,21 +14,23 @@ export class ExampleBot protected masterAvatar = 'd1cd5b71-6209-4595-9bf0-771bf689ce00'; protected isConnected = false; protected isConnecting = false; + protected loginParamsJsonFile: string; protected loginResponse?: LoginResponse; protected bot: Bot; private reconnectTimer?: Timeout; + protected loginParameters: LoginParameters; protected stayRegion?: string; protected stayPosition?: Vector3; + protected firstName?: string; + protected lastName?: string; constructor() { - const loginParameters = new LoginParameters(); - const parameters = require(path.join(__dirname, '..', '..', 'examples', 'loginParameters.json')); - loginParameters.firstName = parameters.firstName; - loginParameters.lastName = parameters.lastName; - loginParameters.password = parameters.password; - loginParameters.start = parameters.start; + this.loginParamsJsonFile = path.join(__dirname, '..', '..', 'examples', 'loginParameters.json'); + this.loginParameters = require(this.loginParamsJsonFile); + this.firstName = this.loginParameters.firstName; + this.lastName = this.loginParameters.lastName; // If you don't intend to use the object store (i.e you have no interest in inworld objects, textures, etc, // using nmv.BotOptionFlags.LiteObjectStore will drastically reduce the footprint and CPU usage. @@ -41,7 +43,7 @@ export class ExampleBot const options = BotOptionFlags.None; - this.bot = new Bot(loginParameters, options); + this.bot = new Bot(this.loginParameters, options); } public async run(): Promise @@ -103,7 +105,7 @@ export class ExampleBot } - private async login(): Promise + protected async login(): Promise { if (this.isConnecting) { diff --git a/examples/MFA/MFA.ts b/examples/MFA/MFA.ts new file mode 100644 index 0000000..9702376 --- /dev/null +++ b/examples/MFA/MFA.ts @@ -0,0 +1,55 @@ +import { Logger } from '../../lib/classes/Logger'; +import { LoginError } from '../../lib/classes/LoginError'; +import { ExampleBot } from '../ExampleBot'; +import * as readline from 'readline'; +import * as fs from 'fs'; + +class MFA extends ExampleBot +{ + public async login(): Promise + { + try + { + await super.login(); + } + catch (e: unknown) + { + if (e instanceof LoginError) + { + if (e.reason === 'mfa_challenge') + { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + rl.question('Please enter authenticator code for ' + String(this.firstName) + ' ' + String(this.lastName) + '\n# ', (code) => + { + this.bot.loginParameters.token = code; + void this.login(); + }); + return; + } + } + Logger.Error(e); + } + } + + public async onConnected(): Promise + { + if (this.loginResponse && this.loginResponse.mfaHash) + { + // Store MFA hash in our login credentials for next time + this.loginParameters.mfa_hash = this.loginResponse.mfaHash; + delete this.loginParameters.token; + await fs.promises.writeFile(this.loginParamsJsonFile, JSON.stringify(this.loginParameters, null, 4)); + } + } +} + +new MFA().run().then(() => +{ + +}).catch((err) => +{ + console.error(err); +}); diff --git a/examples/Region/Agents.ts b/examples/Region/Agents.ts index 4db76d7..d52fe33 100644 --- a/examples/Region/Agents.ts +++ b/examples/Region/Agents.ts @@ -4,14 +4,14 @@ import { Subscription } from 'rxjs'; class Region extends ExampleBot { - private subscriptions: {[key: string]: { + private subscriptions: { [key: string]: { onMovedSubscription: Subscription; onTitleSubscription: Subscription; onLeftRegionSubscription: Subscription; onVisibleSubscription: Subscription; - }}; + } } = {}; - async onConnected() + public async onConnected(): Promise { this.bot.clientEvents.onAvatarEnteredRegion.subscribe(this.onAvatarEntered.bind(this)); @@ -22,7 +22,7 @@ class Region extends ExampleBot } } - onAvatarEntered(av: Avatar) + private onAvatarEntered(av: Avatar): void { console.log(av.getName() + ' entered the region (' + ((av.isVisible) ? 'visible' : 'invisible') + ')'); const avatarKey = av.getKey().toString(); @@ -35,7 +35,7 @@ class Region extends ExampleBot } } - private unsubscribe(key: string) + private unsubscribe(key: string): void { const sub = this.subscriptions[key]; if (sub === undefined) @@ -50,26 +50,32 @@ class Region extends ExampleBot sub.onLeftRegionSubscription.unsubscribe(); } - onAvatarLeft(av: Avatar) + private onAvatarLeft(av: Avatar): void { console.log(av.getName() + ' left the region'); this.unsubscribe(av.getKey().toString()); } - onAvatarMoved(av: Avatar) + private onAvatarMoved(av: Avatar): void { console.log(av.getName() + ' moved, position: ' + av.position.toString()); } - onTitleChanged(av: Avatar) + private onTitleChanged(av: Avatar): void { console.log(av.getName() + ' changed their title to: ' + av.getTitle()); } - onAvatarVisible(av: Avatar) + private onAvatarVisible(av: Avatar): void { console.log(av.getName() + ' is now ' + (av.isVisible ? 'visible' : 'invisible')); } } -new Region().run().then(() => {}).catch((err) => { console.error(err) }); +new Region().run().then(() => +{ + +}).catch((err) => +{ + console.error(err); +}); diff --git a/lib/Bot.ts b/lib/Bot.ts index 9cbaa5d..1af507d 100644 --- a/lib/Bot.ts +++ b/lib/Bot.ts @@ -79,6 +79,11 @@ export class Bot return this._clientCommands; } + get loginParameters(): LoginParameters + { + return this.loginParams; + } + constructor(login: LoginParameters, options: BotOptionFlags) { this.clientEvents = new ClientEvents(); diff --git a/lib/LoginHandler.ts b/lib/LoginHandler.ts index 1a32905..d111fd8 100644 --- a/lib/LoginHandler.ts +++ b/lib/LoginHandler.ts @@ -1,16 +1,21 @@ +import validator from 'validator'; import * as xmlrpc from 'xmlrpc'; import * as crypto from 'crypto'; -import * as url from 'url'; +import * as fs from 'fs'; +import * as path from 'path'; +import { LoginError } from './classes/LoginError'; import { LoginParameters } from './classes/LoginParameters'; import { LoginResponse } from './classes/LoginResponse'; import { ClientEvents } from './classes/ClientEvents'; import { Utils } from './classes/Utils'; +import { UUID } from './classes/UUID'; import { BotOptionFlags } from './enums/BotOptionFlags'; +import { URL } from 'url'; export class LoginHandler { - private clientEvents: ClientEvents; - private options: BotOptionFlags; + private readonly clientEvents: ClientEvents; + private readonly options: BotOptionFlags; constructor(ce: ClientEvents, options: BotOptionFlags) { @@ -18,42 +23,65 @@ export class LoginHandler this.options = options; } - Login(params: LoginParameters): Promise + public async Login(params: LoginParameters): Promise { + const loginURI = new URL(params.url); + + let secure = false; + + if (loginURI.protocol !== null && loginURI.protocol.trim().toLowerCase() === 'https:') + { + secure = true; + } + + let port: string | null = loginURI.port; + if (port === null) + { + port = secure ? '443' : '80'; + } + + const secureClientOptions = { + host: loginURI.hostname || undefined, + port: parseInt(port, 10), + path: loginURI.pathname || undefined, + rejectUnauthorized: false, + timeout: 60000 + }; + const viewerDigest = 'ce50e500-e6f0-15ab-4b9d-0591afb91ffe'; + const client = (secure) ? xmlrpc.createSecureClient(secureClientOptions) : xmlrpc.createClient(secureClientOptions); + + const nameHash = Utils.SHA1String(params.firstName + params.lastName + viewerDigest); + const macAddress: string[] = []; + for (let i = 0; i < 12; i = i + 2) + { + macAddress.push(nameHash.substr(i, 2)); + } + + let hardwareID: string | null = null; + + const hardwareIDFile = path.resolve(__dirname, 'deviceToken.json'); + try + { + const hwID = await fs.promises.readFile(hardwareIDFile); + const data = JSON.parse(hwID.toString('utf-8')); + hardwareID = data.id0; + } + catch (e: unknown) + { + // Ignore any error + } + + if (hardwareID === null || !validator.isUUID(String(hardwareID))) + { + hardwareID = UUID.random().toString(); + await fs.promises.writeFile(hardwareIDFile, JSON.stringify({ id0: hardwareID })); + } + + const mfaToken = params.token ?? ''; + const mfaHash = params.mfa_hash ?? ''; + return new Promise((resolve, reject) => { - const loginURI = url.parse(params.url); - - let secure = false; - - if (loginURI.protocol !== null && loginURI.protocol.trim().toLowerCase() === 'https:') - { - secure = true; - } - - let port: string | null = loginURI.port; - if (port === null) - { - port = secure ? '443' : '80'; - } - - const secureClientOptions = { - host: loginURI.hostname || undefined, - port: parseInt(port, 10), - path: loginURI.path || undefined, - rejectUnauthorized: false, - timeout: 60000 - }; - const viewerDigest = 'ce50e500-e6f0-15ab-4b9d-0591afb91ffe'; - const client = (secure) ? xmlrpc.createSecureClient(secureClientOptions) : xmlrpc.createClient(secureClientOptions); - - const nameHash = Utils.SHA1String(params.firstName + params.lastName + viewerDigest); - const macAddress: string[] = []; - for (let i = 0; i < 12; i = i + 2) - { - macAddress.push(nameHash.substr(i, 2)); - } - client.methodCall('login_to_simulator', [ { @@ -66,6 +94,9 @@ export class LoginHandler 'patch': '1', 'build': '0', 'platform': 'win', + 'token': mfaToken, + 'mfa_hash': mfaHash, + 'id0': hardwareID, 'mac': macAddress.join(':'), 'viewer_digest': viewerDigest, 'user_agent': 'node-metaverse', @@ -96,7 +127,7 @@ export class LoginHandler { if (!value['login'] || value['login'] === 'false') { - reject(new Error(value['message'])); + reject(new LoginError(value)); } else { @@ -108,5 +139,4 @@ export class LoginHandler ); }); } - } diff --git a/lib/classes/LoginError.ts b/lib/classes/LoginError.ts new file mode 100644 index 0000000..e7c178d --- /dev/null +++ b/lib/classes/LoginError.ts @@ -0,0 +1,17 @@ +export class LoginError extends Error +{ + public reason: string; + public message_id: string; + + public constructor(err: { + login: 'false', + reason: string, + message: string, + message_id: string + }) + { + super(err.message); + this.reason = err.reason; + this.message_id = err.message_id; + } +} diff --git a/lib/classes/LoginParameters.ts b/lib/classes/LoginParameters.ts index b842773..843123f 100644 --- a/lib/classes/LoginParameters.ts +++ b/lib/classes/LoginParameters.ts @@ -5,4 +5,6 @@ export class LoginParameters password: string; start = 'last'; url = 'https://login.agni.lindenlab.com/cgi-bin/login.cgi'; + token?: string; + mfa_hash?: string; } diff --git a/lib/classes/LoginResponse.ts b/lib/classes/LoginResponse.ts index 46bfbaa..d1098d6 100644 --- a/lib/classes/LoginResponse.ts +++ b/lib/classes/LoginResponse.ts @@ -37,6 +37,7 @@ export class LoginResponse 'moonTextureID'?: UUID, } = {}; searchToken: string; + mfaHash?: string; clientEvents: ClientEvents; private static toRegionHandle(x_global: number, y_global: number): Long @@ -121,7 +122,6 @@ export class LoginResponse this.clientEvents = clientEvents; this.agent = new Agent(this.clientEvents); this.region = new Region(this.agent, this.clientEvents, options); - if (json['agent_id']) { this.agent.agentID = new UUID(json['agent_id']); @@ -218,6 +218,9 @@ export class LoginResponse } } break; + case 'mfa_hash': + this.mfaHash = String(val); + break; case 'search_token': this.searchToken = String(val); break; diff --git a/package.json b/package.json index a8ed9a6..f9ba432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@caspertech/node-metaverse", - "version": "0.5.37", + "version": "0.5.40", "description": "A node.js interface for Second Life.", "main": "dist/lib/index.js", "types": "dist/lib/index.d.ts",