MFA support
This commit is contained in:
@@ -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
55
examples/MFA/MFA.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<LoginResponse>
|
||||
public async Login(params: LoginParameters): Promise<LoginResponse>
|
||||
{
|
||||
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<LoginResponse>((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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
17
lib/classes/LoginError.ts
Normal file
17
lib/classes/LoginError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user