From f123c418256f04c55dd752bc26636c4ea629a73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 15 Jul 2018 16:44:04 +0200 Subject: [PATCH] Separate socket server from the HTTP API. (#241) * Separate socket server from the HTTP API. This changes the API so users have to set up both the HTTP API and the socket server. * Update docs * lint fixes --- README.md | 54 +++++++++++++++--- dev/u-wave-api-dev-server | 50 +++++++++------- rollup.config.js | 2 +- src/AuthRegistry.js | 29 ++++++++++ src/HttpApi.js | 54 +----------------- src/{sockets.js => SocketServer.js} | 88 +++++++++++++++++++++-------- src/controllers/authenticate.js | 7 ++- src/controllers/now.js | 14 +++-- src/index.js | 12 ++-- src/sockets/GuestConnection.js | 26 ++++----- 10 files changed, 200 insertions(+), 136 deletions(-) create mode 100644 src/AuthRegistry.js rename src/{sockets.js => SocketServer.js} (88%) diff --git a/README.md b/README.md index 0dfe97bb..033f47ea 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,16 @@ handlers. ## API +```js +import { createHttpApi, createSocketServer } from 'u-wave-http-api' +const { createHttpApi, createSocketServer } = require('u-wave-http-api') +``` + ### api = createHttpApi(uwave, options={}) Creates a middleware for use with [Express][] or another such library. The first parameter is a `u-wave-core` instance. Available options are: - - `server` - An HTTP server instance. `u-wave-http-api` uses WebSockets, and it - needs an HTTP server to listen to for incoming WebSocket connections. An - example for how to obtain this server from an Express app is shown below. - - `socketPort` - The WebSocket server can also listen on its own port instead - of attaching to the HTTP server. In that case, specify the port number here. - `secret` - A string or Buffer containing a secret used to encrypt authentication tokens. It's important that this is the same as the `secret` option passed to the core library. @@ -42,14 +42,15 @@ parameter is a `u-wave-core` instance. Available options are: - `onError` - Error handler function, use for recording errors. First parameter is the request object that caused the error, second is the error itself. +**Example** + ```js import express from 'express'; import stubTransport from 'nodemailer-stub-transport'; import uwave from 'u-wave-core'; -import createHttpApi from 'u-wave-http-api'; +import { createHttpApi } from 'u-wave-http-api'; const app = express(); -const server = app.listen(); const secret = fs.readFileSync('./secret.dat'); @@ -58,7 +59,6 @@ const uw = uwave({ }); const api = createHttpApi(uw, { secret: secret, // Encryption secret - server: server, // HTTP server recaptcha: { secret: 'AABBCC...' }, // Optional mailTransport: stubTransport(), // Optional onError: (req, error) => {}, // Optional @@ -75,6 +75,8 @@ object to the request. The `u-wave-core` instance will be available as `req.uwaveHttp`. This is useful if you want to access these objects in custom routes, that are not in the `u-wave-http-api` namespace. E.g.: +**Example** + ```js app.use('/api', api); @@ -87,6 +89,42 @@ app.get('/profile/:user', api.attachUwaveToRequest(), (req, res) => { }); ``` +### sockets = createSocketServer(uwave, options={}) + +Create the WebSocket server used for realtime communication, like advance +notifications and chat messages. + + - `server` - An HTTP server instance. `u-wave-http-api` uses WebSockets, and it + needs an HTTP server to listen to for incoming WebSocket connections. An + example for how to obtain this server from an Express app is shown below. + - `port` - The WebSocket server can also listen on its own port instead + of attaching to the HTTP server. In that case, specify the port number here. + - `secret` - A string or Buffer containing a secret used to encrypt + authentication tokens. It's important that this is the same as the `secret` + option passed to the core library and the `createHttpApi` function. + +**Example** + +```js +import express from 'express'; +import { createSocketServer } from 'u-wave-http-api'; + +const app = express(); +const server = app.listen(8080); + +const secret = fs.readFileSync('./secret.dat'); + +const sockets = createSocketServer(uw, { + server, // The HTTP server + secret: secret, // Encryption secret +}); +// ALTERNATIVELY: +const sockets = createSocketServer(uw, { + port: 6042, // Port to listen on—make sure to configure web clients for this + secret: secret, // Encryption secret +}); +``` + ## Contributing There is a development server included in this repository. To use it, first you diff --git a/dev/u-wave-api-dev-server b/dev/u-wave-api-dev-server index 78773eec..53c94fb5 100755 --- a/dev/u-wave-api-dev-server +++ b/dev/u-wave-api-dev-server @@ -15,6 +15,20 @@ const express = require('express'); const config = require('./dev-server-config.json'); const mailDebug = require('debug')('uwave:mail'); +const testTransport = { + name: 'test', + version: '0.0.0', + send(mail, callback) { + mail.message.createReadStream().pipe(concat((message) => { + mailDebug(mail.message.getEnvelope().to, message.toString('utf8')); + callback(null, { + envelope: mail.message.getEnvelope(), + messageId: mail.message.messageId() + }); + })); + } +}; + function tryRequire(file, message) { try { // eslint-disable-next-line import/no-dynamic-require @@ -44,9 +58,9 @@ function loadDevModules() { 'u-wave-core/src/index.js', 'Could not find the u-wave core module. Did you run `npm link u-wave-core`?' ); - const createWebApi = require('../src').default; + const { createHttpApi, createSocketServer } = require('../src'); - return { uwave, createWebApi }; + return { uwave, createHttpApi, createSocketServer }; } function loadProdModules() { @@ -54,9 +68,9 @@ function loadProdModules() { 'u-wave-core', 'Could not find the u-wave core module. Did you run `npm link u-wave-core`?' ); - const createWebApi = require('../'); + const { createHttpApi, createSocketServer } = require('../'); - return { uwave, createWebApi }; + return { uwave, createHttpApi, createSocketServer }; } /** @@ -68,7 +82,8 @@ function start() { const { uwave, - createWebApi + createHttpApi, + createSocketServer, } = watch ? loadDevModules() : loadProdModules(); const uw = uwave(config); @@ -92,27 +107,20 @@ function start() { app.set('json spaces', 2); const apiUrl = '/api'; + const secret = Buffer.from('none', 'utf8'); - app.use(apiUrl, createWebApi(uw, { + app.use(apiUrl, createHttpApi(uw, { recaptcha: { secret: recaptchaTestKeys.secret }, - server, - secret: Buffer.from('none', 'utf8'), + secret, auth: config.auth, - mailTransport: { - name: 'test', - version: '0.0.0', - send(mail, callback) { - mail.message.createReadStream().pipe(concat((message) => { - mailDebug(mail.message.getEnvelope().to, message.toString('utf8')); - callback(null, { - envelope: mail.message.getEnvelope(), - messageId: mail.message.messageId() - }); - })); - } - } + mailTransport: testTransport, })); + createSocketServer(uw, { + server, + secret, + }); + return app; } diff --git a/rollup.config.js b/rollup.config.js index ad14b5bf..45250240 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,7 +13,7 @@ export default { input: 'src/index.js', output: [{ file: pkg.main, - exports: 'default', + exports: 'named', format: 'cjs', sourcemap: true, }, { diff --git a/src/AuthRegistry.js b/src/AuthRegistry.js new file mode 100644 index 00000000..4a320dcb --- /dev/null +++ b/src/AuthRegistry.js @@ -0,0 +1,29 @@ +import crypto from 'crypto'; +import { promisify } from 'util'; + +const randomBytes = promisify(crypto.randomBytes); + +export default class AuthRegistry { + constructor(redis) { + this.redis = redis; + } + + async createAuthToken(user) { + const token = (await randomBytes(64)).toString('hex'); + await this.redis.set(`http-api:socketAuth:${token}`, user.id, 'EX', 60); + return token; + } + + async getTokenUser(token) { + if (token.length !== 128) { + throw new Error('Invalid token'); + } + const [userID] = await this.redis + .multi() + .get(`http-api:socketAuth:${token}`) + .del(`http-api:socketAuth:${token}`) + .exec(); + + return userID; + } +} diff --git a/src/HttpApi.js b/src/HttpApi.js index fbce4a3c..f49be563 100644 --- a/src/HttpApi.js +++ b/src/HttpApi.js @@ -24,36 +24,7 @@ import errorHandler from './middleware/errorHandler'; import rateLimit from './middleware/rateLimit'; import createPassport from './passport'; -import WSServer from './sockets'; - -function missingServerOption() { - throw new TypeError(` -Exactly one of "options.server" and "options.socketPort" is required. These -options are used to attach the WebSocket server to the correct HTTP server. - -An example of how to attach the WebSocket server to an existing HTTP server -using Express: - - import httpApi from 'u-wave-http-api'; - const app = express(); - const server = app.listen(80); - - app.use('/api', httpApi(uwave, { - server: server, - ... - })); - -Alternatively, you can provide a port for the socket server to listen on: - - import httpApi from 'u-wave-http-api'; - const app = express(); - - app.use('/api', httpApi(uwave, { - socketPort: 6042, - ... - })); - `); -} +import AuthRegistry from './AuthRegistry'; function defaultCreatePasswordResetEmail({ token, requestUrl }) { const parsed = url.parse(requestUrl); @@ -81,10 +52,6 @@ export default class UwaveHttpApi extends Router { + 'developing, you may have to upgrade your u-wave-* modules.'); } - if (!options.server && !options.socketPort) { - missingServerOption(options); - } - if (!options.secret) { throw new TypeError('"options.secret" is empty. This option is used to sign authentication ' + 'keys, and is required for security reasons.'); @@ -104,11 +71,8 @@ export default class UwaveHttpApi extends Router { const router = super(options); this.uw = uw; - this.sockets = new WSServer(uw, { - port: options.socketPort, - server: options.server, - secret: options.secret, - }); + + this.authRegistry = new AuthRegistry(uw.redis); this.passport = createPassport(uw, { secret: options.secret, @@ -159,16 +123,4 @@ export default class UwaveHttpApi extends Router { attachUwaveToRequest() { return attachUwaveMeta(this, this.uw); } - - /** - * @return Number of open guest connections. - */ - getGuestCount() { - return this.sockets.getGuestCount(); - } - - destroy() { - this.sockets.destroy(); - this.sockets = null; - } } diff --git a/src/sockets.js b/src/SocketServer.js similarity index 88% rename from src/sockets.js rename to src/SocketServer.js index f5e91063..e1e007b8 100644 --- a/src/sockets.js +++ b/src/SocketServer.js @@ -3,18 +3,43 @@ import tryJsonParse from 'try-json-parse'; import WebSocket from 'ws'; import ms from 'ms'; import createDebug from 'debug'; -import { promisify } from 'util'; -import crypto from 'crypto'; - import { vote } from './controllers/booth'; import { disconnectUser } from './controllers/users'; - +import AuthRegistry from './AuthRegistry'; import GuestConnection from './sockets/GuestConnection'; import AuthedConnection from './sockets/AuthedConnection'; import LostConnection from './sockets/LostConnection'; const debug = createDebug('uwave:api:sockets'); -const randomBytes = promisify(crypto.randomBytes); + +function missingServerOption() { + throw new TypeError(` +Exactly one of "options.server" and "options.port" is required. These +options are used to attach the WebSocket server to the correct HTTP server. + +An example of how to attach the WebSocket server to an existing HTTP server +using Express: + + import { createSocketServer } from 'u-wave-http-api'; + const app = express(); + const server = app.listen(80); + + createSocketServer(uwave, { + server: server, + /* ... */ + }); + +Alternatively, you can provide a port for the socket server to listen on: + + import { createSocketServer } from 'u-wave-http-api'; + const app = express(); + + createSocketServer(uwave, { + port: 6042, + /* ... */ + }); + `); +} export default class SocketServer { connections = []; @@ -23,8 +48,6 @@ export default class SocketServer { timeout: 30, }; - lastGuestCount = 0; - pinger = setInterval(() => { this.ping(); }, ms('10 seconds')); @@ -32,16 +55,33 @@ export default class SocketServer { /** * Create a socket server. * - * @param {UWaveServer} uw üWave Core instance. + * @param {Uwave} uw üWave Core instance. * @param {object} options Socket server options. * @param {number} options.timeout Time in seconds to wait for disconnected * users to reconnect before removing them. */ constructor(uw, options = {}) { + if (!uw || !('mongo' in uw)) { + throw new TypeError('Expected a u-wave-core instance in the first parameter. If you are ' + + 'developing, you may have to upgrade your u-wave-* modules.'); + } + + if (!options.server && !options.port) { + missingServerOption(options); + } + + if (!options.secret) { + throw new TypeError('"options.secret" is empty. This option is used to sign authentication ' + + 'keys, and is required for security reasons.'); + } + + this.uw = uw; this.sub = uw.subscription(); Object.assign(this.options, options); + this.authRegistry = new AuthRegistry(uw.redis); + this.wss = new WebSocket.Server({ server: options.server, port: options.port, @@ -80,13 +120,6 @@ export default class SocketServer { this.add(this.createGuestConnection(socket, req)); } - async createAuthToken(user) { - const { redis } = this.uw; - const token = (await randomBytes(64)).toString('hex'); - await redis.set(`http-api:socketAuth:${token}`, user.id, 'EX', 60); - return token; - } - /** * Get a LostConnection for a user, if one exists. */ @@ -102,6 +135,7 @@ export default class SocketServer { createGuestConnection(socket, req?) { const connection = new GuestConnection(this.uw, socket, req, { secret: this.options.secret, + authRegistry: this.authRegistry, }); connection.on('close', () => { this.remove(connection); @@ -450,13 +484,6 @@ export default class SocketServer { } } - /** - * @return Number of active guest connections. - */ - getGuestCount() { - return this.lastGuestCount; - } - /** * Stop the socket server. */ @@ -519,17 +546,28 @@ export default class SocketServer { }); } + async getGuestCount() { + const { redis } = this.uw; + const rawCount = await redis.get('http-api:guests'); + if (typeof rawCount !== 'string' || !/^\d+$/.test(rawCount)) { + return 0; + } + return parseInt(rawCount, 10); + } + /** * Update online guests count and broadcast an update if necessary. */ - recountGuests = debounce(() => { + recountGuests = debounce(async () => { + const { redis } = this.uw; const guests = this.connections .filter(connection => connection instanceof GuestConnection) .length; - if (guests !== this.lastGuestCount) { + const lastGuestCount = await this.getGuestCount(); + if (guests !== lastGuestCount) { + await redis.set('http-api:guests', guests); this.broadcast('guests', guests); - this.lastGuestCount = guests; } }, ms('2 seconds')); } diff --git a/src/controllers/authenticate.js b/src/controllers/authenticate.js index bcb11ada..a38a26bd 100644 --- a/src/controllers/authenticate.js +++ b/src/controllers/authenticate.js @@ -43,7 +43,7 @@ export async function refreshSession(res, api, user, options) { { expiresIn: '31d' }, ); - const socketToken = await api.sockets.createAuthToken(user); + const socketToken = await api.authRegistry.createAuthToken(user); if (options.session === 'cookie') { const serialized = cookie.serialize('uwsession', token, { @@ -112,8 +112,9 @@ export async function socialLoginCallback(options, req, res) { } export async function getSocketToken(req) { - const { sockets } = req.uwaveHttp; - const socketToken = await sockets.createAuthToken(req.user); + const { authRegistry } = req.uwaveHttp; + + const socketToken = await authRegistry.createAuthToken(req.user); return toItemResponse({ socketToken }, { url: req.fullUrl, }); diff --git a/src/controllers/now.js b/src/controllers/now.js index ed13d743..344aaed5 100644 --- a/src/controllers/now.js +++ b/src/controllers/now.js @@ -18,10 +18,16 @@ async function getFirstItem(user, activePlaylist) { return null; } +function toInt(str) { + if (typeof str !== 'string') return 0; + if (!/^\d+$/.test(str)) return 0; + return parseInt(str, 10); +} + // eslint-disable-next-line import/prefer-default-export export async function getState(req) { const uw = req.uwave; - const api = req.uwaveHttp; + const { authRegistry, passport } = req.uwaveHttp; const { user } = req; const User = uw.model('User'); @@ -29,7 +35,7 @@ export async function getState(req) { const motd = uw.getMotd(); const users = uw.redis.lrange('users', 0, -1) .then(userIDs => User.find({ _id: { $in: userIDs } })); - const guests = api.getGuestCount(); + const guests = uw.redis.get('http-api:guests').then(toInt); const roles = uw.acl.getAllRoles(); const booth = getBoothData(uw); const waitlist = uw.redis.lrange('waitlist', 0, -1); @@ -37,8 +43,8 @@ export async function getState(req) { const activePlaylist = user ? user.getActivePlaylistID() : null; const playlists = user ? user.getPlaylists() : null; const firstActivePlaylistItem = activePlaylist ? getFirstItem(user, activePlaylist) : null; - const socketToken = user ? api.sockets.createAuthToken(user) : null; - const authStrategies = api.passport.strategies(); + const socketToken = user ? authRegistry.createAuthToken(user) : null; + const authStrategies = passport.strategies(); const time = Date.now(); const state = await props({ diff --git a/src/index.js b/src/index.js index 7963fd8d..a7b63639 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,9 @@ import UwaveHttpApi from './HttpApi'; +import UwaveSocketServer from './SocketServer'; -export default function createHttpApi(uw, opts) { +export function createHttpApi(uw, opts) { return new UwaveHttpApi(uw, opts); } - -createHttpApi.HttpApi = UwaveHttpApi; - -// Backwards compat? -createHttpApi.V1 = UwaveHttpApi; -createHttpApi.ApiV1 = UwaveHttpApi; +export function createSocketServer(uw, opts) { + return new UwaveSocketServer(uw, opts); +} diff --git a/src/sockets/GuestConnection.js b/src/sockets/GuestConnection.js index 37eb9bf3..c886e832 100644 --- a/src/sockets/GuestConnection.js +++ b/src/sockets/GuestConnection.js @@ -2,10 +2,14 @@ import EventEmitter from 'events'; import Ultron from 'ultron'; import WebSocket from 'ws'; import createDebug from 'debug'; +import AuthRegistry from '../AuthRegistry'; const debug = createDebug('uwave:api:sockets:guest'); -type ConnectionOptions = { timeout: number }; +type ConnectionOptions = { + timeout: number, + authRegistry: AuthRegistry, +}; export default class GuestConnection extends EventEmitter { lastMessage = Date.now(); @@ -31,25 +35,15 @@ export default class GuestConnection extends EventEmitter { }); } - async getTokenUser(token) { - if (token.length !== 128) { - throw new Error('Invalid token'); - } - const [userID] = await this.uw.redis - .multi() - .get(`http-api:socketAuth:${token}`) - .del(`http-api:socketAuth:${token}`) - .exec(); - - return userID; - } - async attemptAuth(token) { - const userID = await this.getTokenUser(token); + const { users } = this.uw; + const { authRegistry } = this.options; + + const userID = await authRegistry.getTokenUser(token); if (!userID) { throw new Error('Invalid token'); } - const userModel = await this.uw.getUser(userID); + const userModel = await users.getUser(userID); if (!userModel) { throw new Error('Invalid session'); }