diff --git a/wi/BUILD.md b/wi/BUILD.md new file mode 100644 index 000000000..a3fa9a989 --- /dev/null +++ b/wi/BUILD.md @@ -0,0 +1,17 @@ +# Building EQEmu Web Interface Reference Implementation + +## Required Software +- [NodeJS](https://nodejs.org) + +## Install + +First: Make sure you have required software installed. + +Install 3rd Party Libraries first with the following command: + npm install + + +## Run + +Run with either your favorite NodeJS process manager or with the following command: + node . \ No newline at end of file diff --git a/wi/core/jwt_auth.js b/wi/core/jwt_auth.js new file mode 100644 index 000000000..f63f22346 --- /dev/null +++ b/wi/core/jwt_auth.js @@ -0,0 +1,27 @@ +const jwt = require('jsonwebtoken'); + +var Auth = function (req, res, next) { + var token = ''; + try { + token = req.headers.authorization.substring(7); + } catch(ex) { + console.log(ex); + res.sendStatus(401); + return; + } + + jwt.verify(token, req.key, function(err, decoded) { + if(err) { + console.log(err); + res.sendStatus(401); + return; + } + + req.token = decoded; + next(); + }); +}; + +module.exports = { + 'auth': Auth +} diff --git a/wi/generate_pw_hash.js b/wi/generate_pw_hash.js new file mode 100644 index 000000000..6648e48c3 --- /dev/null +++ b/wi/generate_pw_hash.js @@ -0,0 +1,4 @@ +var sodium = require('libsodium-wrappers-sumo'); + +var hash = sodium.crypto_pwhash_str('password', 3, 32768); +console.log(hash); \ No newline at end of file diff --git a/wi/http/eqw.js b/wi/http/eqw.js new file mode 100644 index 000000000..45585abf5 --- /dev/null +++ b/wi/http/eqw.js @@ -0,0 +1,37 @@ +var auth = require('../core/jwt_auth.js').auth; + +var RegisterEQW = function(app, api) { + app.post('/api/eqw/islocked', auth, function (req, res) { + api.Call('EQW::IsLocked', []) + .then(function(value) { + res.send({ response: value }); + }) + .catch(function(reason) { + res.sendStatus(500); + }); + }); + + app.post('/api/eqw/lock', auth, function (req, res) { + api.Call('EQW::Lock', []) + .then(function(value) { + res.send({ response: value }); + }) + .catch(function(reason) { + res.sendStatus(500); + }); + }); + + app.post('/api/eqw/unlock', auth, function (req, res) { + api.Call('EQW::Unlock', []) + .then(function(value) { + res.send({ response: value }); + }) + .catch(function(reason) { + res.sendStatus(500); + }); + }); +}; + +module.exports = { + 'Register': RegisterEQW +} \ No newline at end of file diff --git a/wi/http/token.js b/wi/http/token.js new file mode 100644 index 000000000..a3815121f --- /dev/null +++ b/wi/http/token.js @@ -0,0 +1,43 @@ +const sodium = require('libsodium-wrappers-sumo'); +const jwt = require('jsonwebtoken'); + +var RegisterToken = function(app) { + app.post('/api/token', function (req, res) { + try { + req.mysql.getConnection(function(err, connection) { + if(err) { + console.log(err); + res.sendStatus(500); + connection.release(); + return; + } + + connection.query('SELECT password FROM account WHERE name = ? LIMIT 1', [req.body.username], function (error, results, fields) { + if(results.length == 0) { + res.sendStatus(401); + connection.release(); + return; + } + + + if(sodium.crypto_pwhash_str_verify(results[0].password, req.body.password)) { + var expires = Math.floor(Date.now() / 1000) + (60 * 60 * 24 * 7); + var token = jwt.sign({ username: req.body.username, exp: expires }, req.key); + res.send({token: token, expires: expires}); + connection.release(); + } else { + res.sendStatus(401); + connection.release(); + } + }); + }); + } catch(ex) { + res.sendStatus(500); + console.log(ex); + } + }); +}; + +module.exports = { + 'Register': RegisterToken +} \ No newline at end of file diff --git a/wi/index.js b/wi/index.js index 09958863c..308d34d17 100644 --- a/wi/index.js +++ b/wi/index.js @@ -1,33 +1,51 @@ -var servertalk = require('./servertalk_client.js'); -var fs = require('fs'); -var settings = JSON.parse(fs.readFileSync('settings.json', 'utf8')); +const fs = require('fs'); +const settings = JSON.parse(fs.readFileSync('settings.json', 'utf8')); +const key = fs.readFileSync(settings.key, 'utf8'); -var client = new servertalk.client(); +var server; +if(settings.https.enabled) { + const options = { + key: fs.readFileSync(settings.https.key), + cert: fs.readFileSync(settings.https.cert) + }; -client.Init(settings.addr, settings.port, false, 'WebInterface', settings.key); + server = require('https').createServer(); +} else { + server = require('http').createServer(); +} -client.on('connecting', function(){ - console.log('Connecting...'); +const servertalk = require('./network/servertalk_api.js'); +const websocket_iterface = require('./ws/ws_interface.js'); +const express = require('express'); +const app = express(); +const bodyParser = require('body-parser'); +const uuid = require('node-uuid'); +const jwt = require('jsonwebtoken'); +var mysql = require('mysql').createPool(settings.db); + +var wsi = new websocket_iterface.wsi(server, key); +var api = new servertalk.api(); +api.Init(settings.servertalk.addr, settings.servertalk.port, false, settings.servertalk.key); + +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); + +//make sure all routes can see our injected dependencies +app.use(function (req, res, next) { + req.servertalk = api; + req.mysql = mysql; + req.key = key; + next(); }); -client.on('connect', function(){ - console.log('Connected...'); - - this.Send(47, Buffer.from(JSON.stringify({ method: 'IsLocked', params: [], id: '12345' }))); - this.Send(47, Buffer.from(JSON.stringify({ method: 'Lock', params: [] }))); - this.Send(47, Buffer.from(JSON.stringify({ method: 'IsLocked', params: [], id: '12346' }))); - this.Send(47, Buffer.from(JSON.stringify({ method: 'Unlock', params: [] }))); - this.Send(47, Buffer.from(JSON.stringify({ method: 'IsLocked', params: [], id: '12347' }))); +app.get('/', function (req, res) { + res.send({ status: "online" }); }); -client.on('close', function(){ - console.log('Closed'); -}); +require('./http/token.js').Register(app); +require('./http/eqw.js').Register(app, api); +//require('./ws/token.js').Register(app); +require('./ws/eqw.js').Register(wsi, api); -client.on('error', function(err){ - console.log(err); -}); - -client.on('message', function(opcode, packet) { - console.log(Buffer.from(packet).toString('utf8')); -}); +server.on('request', app); +server.listen(settings.port, function () { console.log('Listening on ' + server.address().port) }); diff --git a/wi/key.pem b/wi/key.pem new file mode 100644 index 000000000..1f0cbdc91 --- /dev/null +++ b/wi/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoijNhaW4sH2yLEQOUCNLSU0qIGnr9mxewEPXSNURKFExC1WE +ah983xy+WTbKjakH6Rp2OwCvLxNIu6QBKRgcJ963ICWY7ysn4bU2Q2KoSJgAEel8 +UMDHWYfyyAPdr4DUwUw7YMf4LBThCGBC5DTPilZiVqQNyOf8KL5w/oKcavVMddod +eBNE1ewoxVveHN6WUDkYQKZK2AsrpNG6TjfJc3wI3Z722tRHui4E772l/sD0SuEj +41pBzOG0VM7DHwUpHQosnvnwx9kjefPNE/uvo14PuzP5yYG2h2PFkQ7uuXjK2/le +iVcyap/zgheOHjlYmOJGT1cnVSodv+rY56eilwIDAQABAoIBAQAM/sAZqcI3Qpt4 +uKt8+Jcb9Lcfid2DDgQ53DXwfEK3vGn1wpCuAH/9UUxf0ehBmf4sTBaVe+SOHTmC +8A23wVrgRxTd2qV65TZ4/BCxLcLWrney98cioZBYOHDYXpbxbZ2fMADCLMRSpAm0 +piI2L5VCPNH8p4EDTLQEf96GRulKGOWETeVNai3C7Ept6Fxv0YIiiER8j2oPsb1O +LVCBKBPsNs0IlabJAzfDnaqdfWzuLWIT0L4w/qvzfwkdM8tVxch2zjEVosbz4ser +rPO3tle3mobgDvXrW9jEYkIpOtEqCS7l4ybidVuEfY55KlkZ7rGBQ2N1jbLvKjb5 +AUyHUBchAoGBAOKjzzBPB/mofycF8iF1QJwripGTDUGM7aXBS0Clp4mh0ksvBsUf +Zg+Qnzr2xZaN53lU65xQlMrebMJow4iJj71VesF9FWPPNbIhh7eMTX4pABcKZNvc +Y0iFf5XZAl3LFdDocQSuB3j5WLNrjSFMBZuYUiZhgiRadtcdpQr+O4lbAoGBALcq +ltbFogxXoo7/CIajbYdNUGbba96jQMOzC1D7yeim1MTtDNGs56ZhDjFZepMRMyfX +/Z7iqxjZQQ1m1THtuiM4g+ug08EYI8G/7DYO5DqMABGFb3vKU9ilhYASqfznpKMJ +2sl/d5j8ocS7crkKwR8Tbo3ZG8NgObQNTL+mIFR1AoGAJS66zzIoHM2IDt7q2pJi +Bz0dfsShaB+23XrY3cJPukTSO4N7mNuN4v/XH9VclVayozVLclnGD4JuVXbanYv0 +CRv9B8F9wOI97PuTSIm8LPaNDTqnUWrW3w8H34261ah768o2wI3MrAw8gTMj9FKE +mQJkd+eHcm9lD+XNLgCHxAECgYBiMQ2t00L89NnraKLscp4b44GPsl9QehoVD12o +q2JhO1Ziv2WY3eVNV0hhgkNopdbTrEGFNKRebNEn2xG9c2DO0tQ9s/jw0f0RN87s +Z+1HyZebzPmn1h4+zPUVZGwGbTPgRz8nuBKoS/541bg5pJ9FBojEuDfe9C3a7SpQ +r0EzpQKBgBmYrKi07wTUSZ3TjHWvOK75XhJ5pOdfbuDZk+N02jzhmihzI2M/Sh7s +l1gavtY9o9JGUAW35L/Ju4X1Xgm3t5Cg9+4n6ecOfSKP9nJpgj1EvHyWvw9t8ZSg +V9M0Hf5EoSPWuEj+mlWrIuvV/HgkouUVqDzUm6wUuyTqdTCgUQrA +-----END RSA PRIVATE KEY----- diff --git a/wi/network/servertalk_api.js b/wi/network/servertalk_api.js new file mode 100644 index 000000000..4e0587c1a --- /dev/null +++ b/wi/network/servertalk_api.js @@ -0,0 +1,90 @@ +const servertalk = require('./servertalk_client.js'); +const uuid = require('node-uuid'); + +class ServertalkAPI +{ + Init(addr, port, ipv6, credentials) { + this.client = new servertalk.client(); + this.client.Init(addr, port, ipv6, 'WebInterface', credentials); + this.pending_calls = {}; + var self = this; + + this.client.on('connecting', function() { + console.log('Connecting...'); + }); + + this.client.on('connect', function(){ + console.log('Connected'); + }); + + this.client.on('close', function(){ + console.log('Closed'); + }); + + this.client.on('error', function(err){ + }); + + this.client.on('message', function(opcode, packet) { + var response = Buffer.from(packet).toString('utf8'); + try { + var res = JSON.parse(response); + + if(res.id) { + if(self.pending_calls.hasOwnProperty(res.id)) { + var entry = self.pending_calls[res.id]; + + if(res.error) { + var reject = entry[1]; + reject(res.error); + } else { + var resolve = entry[0]; + resolve(res.response); + } + + delete self.pending_calls[res.id]; + } + } + + } catch(ex) { + console.log('Error processing response from server:\n', ex); + } + }); + } + + Call(method, args, timeout) { + if(!timeout) { + timeout = 15000 + } + + var self = this; + return new Promise( + function(resolve, reject) { + if(!self.client.Connected()) { + reject('Not connected to world server.'); + return; + } + + var id = uuid.v4(); + + self.pending_calls[id] = [resolve, reject]; + + var c = { id: id, method: method, params: args }; + self.client.Send(47, Buffer.from(JSON.stringify(c))); + + setTimeout(function() { + delete self.pending_calls[id]; + reject('Request timed out after ' + timeout + 'ms'); + }, timeout); + } + ); + } + + Notify(method, args) { + var c = { method: method, params: args }; + client.Send(47, Buffer.from(JSON.stringify(c))); + } +} + +module.exports = { + 'api': ServertalkAPI +} \ No newline at end of file diff --git a/wi/servertalk_client.js b/wi/network/servertalk_client.js similarity index 100% rename from wi/servertalk_client.js rename to wi/network/servertalk_client.js diff --git a/wi/package.json b/wi/package.json index 883966b86..674bd1954 100644 --- a/wi/package.json +++ b/wi/package.json @@ -9,7 +9,14 @@ "author": "KimLS", "license": "GPL-3.0", "dependencies": { + "body-parser": "^1.15.2", + "express": "^4.14.0", + "jsonwebtoken": "^7.2.1", "libsodium": "^0.4.8", - "libsodium-wrappers": "^0.4.8" + "libsodium-wrappers": "^0.4.8", + "libsodium-wrappers-sumo": "^0.4.8", + "mysql": "^2.12.0", + "node-uuid": "^1.4.7", + "ws": "^1.1.1" } } diff --git a/wi/settings.json b/wi/settings.json index 64c8c2d25..de325ddde 100644 --- a/wi/settings.json +++ b/wi/settings.json @@ -1,5 +1,21 @@ { - "addr": "localhost", - "port": "9101", - "key": "ujwn2isnal1987scanb" + "db": { + "connectionLimit": 10, + "host": "localhost", + "user": "root", + "password": "blink", + "database": "eqdb" + }, + "servertalk": { + "addr": "localhost", + "port": "9101", + "key": "ujwn2isnal1987scanb" + }, + "https": { + "enabled": false, + "key": "key.pem", + "cert": "cert.pem" + }, + "port": 9080, + "key": "key.pem" } \ No newline at end of file diff --git a/wi/test.js b/wi/test.js new file mode 100644 index 000000000..24ddb4423 --- /dev/null +++ b/wi/test.js @@ -0,0 +1,10 @@ +const WebSocket = require('ws'); +const ws = new WebSocket('ws://localhost:9080'); + +ws.on('open', function open() { + ws.send(JSON.stringify({authorization: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IktTcHJpdGUxIiwiZXhwIjoxNDg0NzIzNDQxLCJpYXQiOjE0ODQxMTg2NDF9.Lmwm572yMWIu1DUrfer8JVvl1DGEkdnMsMFp5WDzp_A', id: '1', method: 'EQW::IsLocked', params: []})); +}); + +ws.on('message', function(data, flags) { + console.log(data); +}); \ No newline at end of file diff --git a/wi/ws/eqw.js b/wi/ws/eqw.js new file mode 100644 index 000000000..af119aab5 --- /dev/null +++ b/wi/ws/eqw.js @@ -0,0 +1,22 @@ +function Register(name, wsi, api) { + wsi.Register(name, + function(request) { + api.Call(name, request.params) + .then(function(value) { + wsi.Send(request, value); + }) + .catch(function(reason) { + wsi.SendError(request, reason); + }); + }, true); +} + +var RegisterEQW = function(wsi, api) { + Register('EQW::IsLocked', wsi, api); + Register('EQW::Lock', wsi, api); + Register('EQW::Unlock', wsi, api); +}; + +module.exports = { + 'Register': RegisterEQW +} \ No newline at end of file diff --git a/wi/ws/ws_interface.js b/wi/ws/ws_interface.js new file mode 100644 index 000000000..1e6a06202 --- /dev/null +++ b/wi/ws/ws_interface.js @@ -0,0 +1,99 @@ +const WebSocketServer = require('ws').Server; +const jwt = require('jsonwebtoken'); + +class WebSocketInterface +{ + constructor(server, key) { + this.wss = new WebSocketServer({ server: server }); + this.methods = {}; + var self = this; + + this.wss.on('connection', function connection(ws) { + self.ws = ws; + ws.on('message', function incoming(message) { + try { + var request = JSON.parse(message); + + if(request.method) { + var method = self.methods[request.method]; + if(!method) { + self.SendError(request, 'Method not found: ' + request.method); + return; + } + + if(method.requires_auth) { + if(!request.authorization) { + self.SendError(request, 'Authorization Required'); + return; + } + + jwt.verify(request.authorization, key, function(err, decoded) { + if(err) { + self.SendError(request, 'Authorization Required'); + return; + } + + request.token = decoded; + method.fn(request); + }); + + return; + } + + method.fn(request); + + } else { + self.SendError(request, 'No method supplied'); + } + + } catch(ex) { + console.log('Error parsing message:', ex); + self.SendError(null, 'No method supplied'); + } + }); + }); + } + + Register(method, fn, requires_auth) { + var entry = { fn: fn, requires_auth: requires_auth }; + this.methods[method] = entry; + } + + SendError(request, msg) { + try { + if(this.ws) { + var error = {}; + + if(request && request.id) { + error.id = request.id; + } + + error.error = msg; + this.ws.send(JSON.stringify(error)); + } + } catch(ex) { + console.log(ex); + } + } + + Send(request, value) { + try { + if(this.ws) { + var response = {}; + + if(request && request.id) { + response.id = response.id; + } + + response.response = value; + this.ws.send(JSON.stringify(response)); + } + } catch(ex) { + console.log(ex); + } + } +} + +module.exports = { + 'wsi': WebSocketInterface +} \ No newline at end of file diff --git a/world/web_interface_eqw.cpp b/world/web_interface_eqw.cpp index 597639c05..720947643 100644 --- a/world/web_interface_eqw.cpp +++ b/world/web_interface_eqw.cpp @@ -34,7 +34,7 @@ void EQW__Unlock(WebInterface *i, const std::string& method, const std::string& void RegisterEQW(WebInterface *i) { - i->AddCall("IsLocked", std::bind(EQW__IsLocked, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); - i->AddCall("Lock", std::bind(EQW__Lock, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); - i->AddCall("Unlock", std::bind(EQW__Unlock, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); + i->AddCall("EQW::IsLocked", std::bind(EQW__IsLocked, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); + i->AddCall("EQW::Lock", std::bind(EQW__Lock, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); + i->AddCall("EQW::Unlock", std::bind(EQW__Unlock, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); }