554 lines
19 KiB
JavaScript
554 lines
19 KiB
JavaScript
//
|
|
// groupTeleportApp.js
|
|
//
|
|
// Created by Thijs Wenker on 2/1/18.
|
|
// Copyright 2018 High Fidelity, Inc.
|
|
//
|
|
// Distributed under the Apache License, Version 2.0
|
|
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
|
|
//
|
|
|
|
// index.js
|
|
|
|
const serverless = require('serverless-http');
|
|
const bodyParser = require('body-parser');
|
|
const express = require('express');
|
|
const app = express();
|
|
const AWS = require('aws-sdk');
|
|
const requestPromise = require('request-promise-native');
|
|
|
|
const crypto = require('crypto');
|
|
const {USERS_TABLE, GROUPS_TABLE, IS_OFFLINE} = process.env;
|
|
|
|
const DEBUG = false;
|
|
|
|
let dynamoDb;
|
|
|
|
if (IS_OFFLINE === 'true') {
|
|
dynamoDb = new AWS.DynamoDB.DocumentClient({
|
|
region: 'localhost',
|
|
endpoint: 'http://localhost:8000'
|
|
});
|
|
} else {
|
|
dynamoDb = new AWS.DynamoDB.DocumentClient();
|
|
}
|
|
|
|
// time a group guide has to wait before they can overtake a group in milliseconds
|
|
// build in for security, we don't want a group jump around between two domains over and over
|
|
const MIN_WAIT_OVERTAKE_MS = 10000;
|
|
|
|
|
|
const MAX_GROUP_INACTIVE_LIST_TIME_MS = 10 /* min */ * 60 /* sec/min */ * 1000 /* ms/sec */;
|
|
|
|
const DEFAULT_SALT_LENGTH = 128;
|
|
const DEFAULT_HASHING_ITERATIONS = 10000;
|
|
const DEFAULT_HASH_KEY_LENGTH = 512;
|
|
const DEFAULT_HASH_DIGEST = 'sha512';
|
|
|
|
// {
|
|
// groupName: "My class",
|
|
// guide: {
|
|
// username: "",
|
|
// hifiSessionUUID: ""
|
|
// },
|
|
// authorizedGuides: ['thoys'],
|
|
// location: "Mexico",
|
|
// position: {x: 100, y: 100, z: 100},
|
|
// restricted-area: ?
|
|
// required-proximity: ?
|
|
// public: false
|
|
// followerUsernames: ["tho", "thoys"], // only when not public
|
|
// password: "123456" // only when not public
|
|
// }
|
|
|
|
// https://stackoverflow.com/questions/17201450/salt-and-hash-password-in-nodejs-w-crypto
|
|
function hashPassword(password) {
|
|
let salt = crypto.randomBytes(DEFAULT_SALT_LENGTH).toString('base64');
|
|
let hash = crypto.pbkdf2Sync(password, salt, DEFAULT_HASHING_ITERATIONS, DEFAULT_HASH_KEY_LENGTH,
|
|
DEFAULT_HASH_DIGEST).toString('hex');
|
|
|
|
return {
|
|
salt: salt,
|
|
hash: hash,
|
|
iterations: DEFAULT_HASHING_ITERATIONS
|
|
};
|
|
}
|
|
|
|
function isPasswordCorrect(savedHash, savedSalt, savedIterations, passwordAttempt) {
|
|
return savedHash === crypto.pbkdf2Sync(passwordAttempt, savedSalt, savedIterations, DEFAULT_HASH_KEY_LENGTH,
|
|
DEFAULT_HASH_DIGEST).toString('hex');
|
|
}
|
|
|
|
function isUserPasswordCorrect(username, password, callback) {
|
|
const params = {
|
|
TableName: USERS_TABLE,
|
|
Key: {
|
|
username: username
|
|
}
|
|
};
|
|
dynamoDb.get(params, (error, result) => {
|
|
if (error) {
|
|
console.log('error = ' + error);
|
|
callback.call(this, false);
|
|
return;
|
|
}
|
|
let user = result.Item;
|
|
if (!user) {
|
|
console.log(`user ${username} not found`);
|
|
callback.call(this, false);
|
|
return;
|
|
}
|
|
let hashedPassword = user.hashedPassword;
|
|
callback.call(this, isPasswordCorrect(hashedPassword.hash, hashedPassword.salt, hashedPassword.iterations, password));
|
|
});
|
|
}
|
|
|
|
app.use(express.static('public'));
|
|
|
|
// parse application/json
|
|
app.use(bodyParser.json({ strict: false }));
|
|
|
|
app.get('/', function (request, response) {
|
|
response.sendFile(__dirname + '/views/index.html');
|
|
});
|
|
|
|
app.post('/groups', (request, response) => {
|
|
let {username, password} = request.body;
|
|
new Promise((resolve, reject) => {
|
|
isUserPasswordCorrect(username, password, (isLoggedIn) => {
|
|
dynamoDb.scan({
|
|
TableName: GROUPS_TABLE
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject('Could not get groups');
|
|
return;
|
|
}
|
|
let mappedGroups = result.Items.filter(function (group) {
|
|
let groupTimedOut = (Date.now() - group.lastUpdate) > MAX_GROUP_INACTIVE_LIST_TIME_MS;
|
|
console.log('groupTimedOut = ' + groupTimedOut ? 'true' : 'false');
|
|
return (!groupTimedOut && (group.public || (group.followerUsernames.length === 0 ||
|
|
group.followerUsernames.indexOf(username) !== -1))) ||
|
|
(isLoggedIn && (group.authorizedGuides.indexOf(username) !== -1 || group.creator === username));
|
|
}).map(function (group) {
|
|
let isCreator = group.creator === username;
|
|
return {
|
|
groupName: group.groupName,
|
|
public: group.public,
|
|
passwordProtected: !!group.hashedPassword,
|
|
isGuide: isLoggedIn && (group.authorizedGuides.indexOf(username) !== -1 || isCreator),
|
|
isCreator
|
|
};
|
|
}).sort(function (groupA, groupB) {
|
|
return (groupA.isGuide === groupB.isGuide) ? groupA.groupName > groupB.groupName : groupA.isGuide ? -1 : 1;
|
|
});
|
|
resolve({
|
|
groups: mappedGroups,
|
|
isLoggedIn: isLoggedIn
|
|
});
|
|
});
|
|
});
|
|
}).then((groupResultData) => {
|
|
// when all succeeds
|
|
response.json(Object.assign({
|
|
success: true
|
|
}, groupResultData));
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
if (DEBUG) {
|
|
app.post('/addGroup', (request, response) => {
|
|
let {groupName, password, followerUsernames, authorizedGuides} = request.body;
|
|
// public is a reserved word in javascript
|
|
let isPublic = request.body.public;
|
|
new Promise((resolve, reject) => {
|
|
dynamoDb.get({
|
|
TableName: GROUPS_TABLE,
|
|
Key: {
|
|
groupName
|
|
}
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject('Could not get group');
|
|
return;
|
|
}
|
|
if (result.Item) {
|
|
reject(`Group ${groupName} already exists`);
|
|
return;
|
|
}
|
|
let newGroup = {
|
|
groupName,
|
|
public: !!isPublic,
|
|
authorizedGuides
|
|
};
|
|
if (!newGroup.public) {
|
|
newGroup.hashedPassword = hashPassword(password);
|
|
newGroup.followerUsernames = followerUsernames ? followerUsernames : [];
|
|
}
|
|
dynamoDb.put({
|
|
TableName: GROUPS_TABLE,
|
|
Item: newGroup
|
|
}, (error) => {
|
|
if (error) {
|
|
console.log(error);
|
|
reject('Could not create group');
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}).then(() => {
|
|
// when all succeeds
|
|
response.json({
|
|
success: true
|
|
});
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
app.post('/addUser', (request, response) => {
|
|
let {username, password} = request.body;
|
|
new Promise((resolve, reject) => {
|
|
dynamoDb.get({
|
|
TableName: USERS_TABLE,
|
|
Key: {
|
|
username
|
|
}
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject("Database error");
|
|
return;
|
|
}
|
|
if (result.Item) {
|
|
reject(`User ${username} already exists`);
|
|
return;
|
|
}
|
|
dynamoDb.put({
|
|
TableName: USERS_TABLE,
|
|
Item: {
|
|
username,
|
|
hashedPassword: hashPassword(password)
|
|
}
|
|
}, (error) => {
|
|
if (error) {
|
|
reject("Database error");
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}).then(() => {
|
|
// when all succeeds
|
|
response.json({
|
|
success: true
|
|
});
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
app.post('/setGuidePassword', (request, response) => {
|
|
let {username, token} = request.body;
|
|
requestPromise({
|
|
uri: "https://metaverse.highfidelity.com/api/v1/user/profile",
|
|
method: 'GET',
|
|
headers: {
|
|
"Authorization": `Bearer ${token}`
|
|
},
|
|
json: true
|
|
}).then((response) => {
|
|
// lets check if the token api is valid and belongs to the requesting user
|
|
return new Promise((resolve, reject) => {
|
|
if (response.status !== "success") {
|
|
reject("user profile request failed, please make sure your token is valid");
|
|
}
|
|
if (response.data.user.username !== username) {
|
|
reject("username doesn't match.");
|
|
}
|
|
resolve()
|
|
})
|
|
}).then(() => {
|
|
// valid token, lets see if the user already exists
|
|
return new Promise((resolve, reject) => {
|
|
dynamoDb.get({
|
|
TableName: USERS_TABLE,
|
|
Key: {
|
|
username
|
|
}
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject('Database error');
|
|
return;
|
|
}
|
|
// returns true if the item is defined
|
|
resolve(!!result.Item);
|
|
});
|
|
});
|
|
}).then((isAlreadyInDatabase) => {
|
|
return new Promise((resolve, reject) => {
|
|
let hashedPassword = hashPassword(request.body.password);
|
|
if (isAlreadyInDatabase) {
|
|
dynamoDb.update({
|
|
TableName: USERS_TABLE,
|
|
Key: {
|
|
username
|
|
},
|
|
UpdateExpression: 'SET hashedPassword = :hashedPassword',
|
|
ExpressionAttributeValues: {
|
|
':hashedPassword': hashedPassword
|
|
}
|
|
}, (error) => {
|
|
if (error) {
|
|
reject('Database error');
|
|
return;
|
|
}
|
|
resolve(isAlreadyInDatabase);
|
|
});
|
|
} else {
|
|
dynamoDb.put({
|
|
TableName: USERS_TABLE,
|
|
Item: {
|
|
username,
|
|
hashedPassword
|
|
}
|
|
}, (error) => {
|
|
if (error) {
|
|
reject('Database error');
|
|
return;
|
|
}
|
|
resolve(isAlreadyInDatabase);
|
|
});
|
|
}
|
|
});
|
|
}).then((updatedExisting) => {
|
|
// when all succeeds, lets celebrate...
|
|
response.json({
|
|
success: true,
|
|
updatedExisting
|
|
});
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
app.post('/getPosition', (request, response) => {
|
|
// TODO: implement password check
|
|
let {groupName} = request.body;
|
|
new Promise((resolve, reject) => {
|
|
dynamoDb.get({
|
|
TableName: GROUPS_TABLE,
|
|
Key: {
|
|
groupName
|
|
}
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject('Database error');
|
|
return;
|
|
}
|
|
let group = result.Item;
|
|
if (!group) {
|
|
reject('group not found');
|
|
return;
|
|
}
|
|
if (!group.guide) {
|
|
reject('group has no guide');
|
|
return;
|
|
}
|
|
let guideSessionUUID = group.guide.hifiSessionUUID;
|
|
let {orientation, position, location, autoFollow, lastSummon} = group;
|
|
resolve({
|
|
guideSessionUUID,
|
|
location,
|
|
position,
|
|
orientation,
|
|
autoFollow,
|
|
lastSummon
|
|
});
|
|
});
|
|
}).then((positionData) => {
|
|
response.send(Object.assign({
|
|
success: true
|
|
}, positionData));
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
app.post('/updatePosition', (request, response) => {
|
|
let {username, password, groupName, hifiSessionUUID, location, position, orientation, autoFollow, summon} = request.body;
|
|
new Promise((resolve, reject) => {
|
|
isUserPasswordCorrect(username, password, (isLoggedIn) => {
|
|
if (!isLoggedIn) {
|
|
reject('password failed');
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
}).then(() => {
|
|
return new Promise((resolve, reject) => {
|
|
dynamoDb.get({
|
|
TableName: GROUPS_TABLE,
|
|
Key: {
|
|
groupName
|
|
}
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject('Could not get group');
|
|
return;
|
|
}
|
|
let group = result.Item;
|
|
if (!group || (group.authorizedGuides.indexOf(username) === -1 && group.creator !== username)) {
|
|
reject('group auth failed');
|
|
return;
|
|
}
|
|
let currentTimeMs = Date.now();
|
|
if (group.guide !== undefined && group.guide.username !== username && group.lastUpdate !== undefined
|
|
&& (currentTimeMs - group.lastUpdate) < MIN_WAIT_OVERTAKE_MS) {
|
|
|
|
reject('failed to overtake session');
|
|
return;
|
|
}
|
|
if (!location || location === "localhost") {
|
|
reject('invalid location');
|
|
return;
|
|
}
|
|
if (!hifiSessionUUID) {
|
|
reject('invalid sessionUUID');
|
|
return;
|
|
}
|
|
|
|
let updateExpression = 'SET guide = :guide, #location = :location, #position = :position, ' +
|
|
'orientation = :orientation, lastUpdate = :lastUpdate, autoFollow = :autoFollow';
|
|
let expressionAttributeValues = { // a map of substitutions for all attribute values
|
|
':guide': {
|
|
username,
|
|
hifiSessionUUID
|
|
},
|
|
':location': location,
|
|
':position': position,
|
|
':orientation': orientation,
|
|
':lastUpdate': currentTimeMs,
|
|
':autoFollow': autoFollow
|
|
};
|
|
|
|
if (summon) {
|
|
updateExpression += ', lastSummon = :lastSummon';
|
|
expressionAttributeValues[':lastSummon'] = Date.now();
|
|
}
|
|
|
|
dynamoDb.update({
|
|
TableName: GROUPS_TABLE,
|
|
Key: {
|
|
groupName,
|
|
},
|
|
UpdateExpression: updateExpression,
|
|
ExpressionAttributeValues: expressionAttributeValues,
|
|
ExpressionAttributeNames: {
|
|
'#location': 'location',
|
|
'#position': 'position'
|
|
}
|
|
}, (error) => {
|
|
if (error) {
|
|
reject('Database error');
|
|
return;
|
|
}
|
|
console.log('position updated');
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
}).then(() => {
|
|
response.send({
|
|
success: true
|
|
});
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
app.post('/createGroup', (request, response) => {
|
|
let {username, password, groupName, groupPassword, followerUsernames, authorizedGuides} = request.body;
|
|
// public is a reserved word in javascript
|
|
let isPublic = request.body.public;
|
|
new Promise((resolve, reject) => {
|
|
isUserPasswordCorrect(username, password, (isLoggedIn) => {
|
|
if (!isLoggedIn) {
|
|
reject('password failed');
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
}).then(() => {
|
|
return new Promise((resolve, reject) => {
|
|
dynamoDb.get({
|
|
TableName: GROUPS_TABLE,
|
|
Key: {
|
|
groupName
|
|
}
|
|
}, (error, result) => {
|
|
if (error) {
|
|
reject('Could not get group');
|
|
return;
|
|
}
|
|
if (result.Item) {
|
|
reject(`Group ${groupName} already exists`);
|
|
return;
|
|
}
|
|
let newGroup = {
|
|
groupName,
|
|
public: !!isPublic,
|
|
creator: username,
|
|
authorizedGuides
|
|
};
|
|
if (!newGroup.public) {
|
|
newGroup.hashedPassword = hashPassword(groupPassword);
|
|
newGroup.followerUsernames = followerUsernames ? followerUsernames : [];
|
|
}
|
|
dynamoDb.put({
|
|
TableName: GROUPS_TABLE,
|
|
Item: newGroup
|
|
}, (error) => {
|
|
if (error) {
|
|
console.log(error);
|
|
reject('Could not create group');
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
}).then(() => {
|
|
// when all succeeds
|
|
response.json({
|
|
success: true
|
|
});
|
|
}).catch((error) => {
|
|
response.json({
|
|
success: false,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
module.exports.handler = serverless(app);
|