MFA support

This commit is contained in:
Casper Warden
2022-05-06 12:20:50 +01:00
parent e658e459f3
commit f49acfec35
9 changed files with 178 additions and 58 deletions

View File

@@ -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<void>
@@ -103,7 +105,7 @@ export class ExampleBot
}
private async login(): Promise<void>
protected async login(): Promise<void>
{
if (this.isConnecting)
{

55
examples/MFA/MFA.ts Normal file
View File

@@ -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<void>
{
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<void>
{
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);
});

View File

@@ -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<void>
{
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);
});

View File

@@ -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();

View File

@@ -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,11 +23,9 @@ export class LoginHandler
this.options = options;
}
Login(params: LoginParameters): Promise<LoginResponse>
public async Login(params: LoginParameters): Promise<LoginResponse>
{
return new Promise<LoginResponse>((resolve, reject) =>
{
const loginURI = url.parse(params.url);
const loginURI = new URL(params.url);
let secure = false;
@@ -40,7 +43,7 @@ export class LoginHandler
const secureClientOptions = {
host: loginURI.hostname || undefined,
port: parseInt(port, 10),
path: loginURI.path || undefined,
path: loginURI.pathname || undefined,
rejectUnauthorized: false,
timeout: 60000
};
@@ -54,6 +57,31 @@ export class LoginHandler
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<LoginResponse>((resolve, reject) =>
{
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
);
});
}
}

17
lib/classes/LoginError.ts Normal file
View File

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

View File

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

View File

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

View File

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