Skip to content

Commit

Permalink
feat(sockets): secure socket connection with tokens
Browse files Browse the repository at this point in the history
Fixes #2815
  • Loading branch information
sogehige committed Oct 23, 2019
1 parent af2ecaf commit 7598e82
Show file tree
Hide file tree
Showing 51 changed files with 361 additions and 354 deletions.
2 changes: 0 additions & 2 deletions Dockerfile
Expand Up @@ -2,8 +2,6 @@ FROM node:12.12.0-alpine

ENV LAST_UPDATED 201910151750

ENV DOMAIN localhost
ENV TOKEN __random__
ENV DB mongodb
ENV MONGOURI mongodb://localhost:27017/your-db-name
ENV NODE_ENV production
Expand Down
1 change: 1 addition & 0 deletions d.ts/index.d.ts
Expand Up @@ -112,6 +112,7 @@ declare namespace NodeJS {
users: import('../src/bot/users').Users;
lib: any;
twitch: import('../src/bot/twitch').Twitch;
socket: import('../src/bot/socket').Socket;
workers: import('../src/bot/workers').Workers;
permissions: import('../src/bot/permissions').Permissions;
customvariables: any;
Expand Down
7 changes: 0 additions & 7 deletions docker.sh
Expand Up @@ -3,13 +3,6 @@ cd /app

export DOCKER_HOST_IP=`route -n | awk '/UG[ \t]/{print $2}'`

if [ "$TOKEN" = '__random__' ]; then
export TOKEN=`cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1`
fi

sed -i 's#"domain": "localhost"#"domain": "localhost, '$DOMAIN'"#g' config.json
sed -i 's#"token": "7911776886"#"token": "'$TOKEN'"#g' config.json

if [ "$DB" = 'mongodb' ]; then
sed -i "s#nedb#mongodb#g" config.json
sed -i 's#mongodb:\/\/localhost:27017\/your-db-name#'$MONGOURI'#g' config.json
Expand Down
1 change: 0 additions & 1 deletion docs/_master/_sidebar.md
Expand Up @@ -7,7 +7,6 @@
* [Environment variables](_master/configuration/env.md)
* [Metrics](_master/configuration/metrics.md)
* [Threads](_master/configuration/threads.md)
* [Panel](_master/configuration/panel.md)
* [Timezone](_master/configuration/timezone.md)
* Systems
* [Alias](_master/commands/alias.md)
Expand Down
44 changes: 0 additions & 44 deletions docs/_master/configuration/panel.md

This file was deleted.

6 changes: 0 additions & 6 deletions docs/_master/install-and-upgrade.md
Expand Up @@ -22,7 +22,6 @@

``` bash
docker run -it --name <name-of-container> \
--env TOKEN=<token> \
--env MONGOURI=<mongouri> \
-p <port>:20000 \
<image>
Expand All @@ -36,18 +35,13 @@ docker run -it --name <name-of-container> \
- Change `<mongouri>` to your mongodb uri connection
- Change `<version>` to `latest` or release tag (e.g. `9.8.0`)
- Change `<name-of-container>` to set name of your container
- Change `<token>` to set your image specific token, if you want to random token, omit whole `--env TOKEN`

- If you serve bot on different than `localhost`, add `--env DOMAIN=<domain>` to
enable bot UI on specific domain

- Example full command

!> When using non-specific image for latest version, use `docker pull <image>` to update your local image

``` bash
docker run -it --name sogebot \
--env DOMAIN=my.publicdoma.in \
--env MONGOURI=mongodb://localhost:27017/sogebot \
-p 80:20000 docker.pkg.github.com/sogehige/sogebot/release:9.8.0
```
Expand Down
1 change: 1 addition & 0 deletions locales/cs/ui.menu.json
Expand Up @@ -45,6 +45,7 @@
"wheeloffortune": "Kolo štěstí",
"heist": "Heist",
"oauth": "OAuth",
"socket": "Socket",
"textoverlay": "Text overlay",
"carouseloverlay": "Carousel overlay",
"alerts": "Alerty",
Expand Down
7 changes: 7 additions & 0 deletions locales/cs/ui/core/socket.json
@@ -0,0 +1,7 @@
{
"settings": {
"purgeAllConnections": "Zrušit všechna současná připojení (i to tvé)",
"accessTokenExpirationTime": "Čas expirace access tokenu (ve vteřinách)",
"refreshTokenExpirationTime": "Čas expirace refresh tokenu (ve vteřinách)"
}
}
1 change: 1 addition & 0 deletions locales/en/ui.menu.json
Expand Up @@ -45,6 +45,7 @@
"wheeloffortune": "Wheel of Fortune",
"heist": "Heist",
"oauth": "OAuth",
"socket": "Socket",
"textoverlay": "Text overlay",
"carouseloverlay": "Carousel overlay",
"alerts": "Alerts",
Expand Down
7 changes: 7 additions & 0 deletions locales/en/ui/core/socket.json
@@ -0,0 +1,7 @@
{
"settings": {
"purgeAllConnections": "Purge All Authenticated Connection (yours as well)",
"accessTokenExpirationTime": "Access Token Expiration Time (seconds)",
"refreshTokenExpirationTime": "Refresh Token Expiration Time (seconds)"
}
}
9 changes: 7 additions & 2 deletions src/bot/_interface.ts
Expand Up @@ -19,6 +19,10 @@ class Module {
public on: InterfaceSettings.On;
public socket: SocketIOClient.Socket | null;

get nsp(): string {
return '/' + this._name + '/' + this.constructor.name.toLowerCase()
}

get enabled(): boolean {
return _.get(this, '_enabled', true);
}
Expand Down Expand Up @@ -176,10 +180,11 @@ class Module {
if (_.isNil(global.panel)) {
this.timeouts[`${this.constructor.name}._sockets`] = setTimeout(() => this._sockets(), 1000);
} else {
this.socket = global.panel.io.of('/' + this._name + '/' + this.constructor.name.toLowerCase());
global.panel.io.of(this.nsp).use(global.socket.authorize);
this.socket = global.panel.io.of(this.nsp);
this.sockets();
this.sockets = function() {
error('/' + this._name + '/' + this.constructor.name.toLowerCase() + ': Cannot initialize sockets second time');
error(this.nsp + ': Cannot initialize sockets second time');
};

if (this.socket) {
Expand Down
2 changes: 0 additions & 2 deletions src/bot/data/config.example.json
Expand Up @@ -8,8 +8,6 @@
"panel": {
"__COMMENT__": "set correctly your domain and to be safe, change your token",
"port": 20000,
"domain": "localhost",
"token": "7911776886"
},
"database": {
"__README__": "https://github.com/sogehige/sogeBot/wiki/Database-configuration",
Expand Down
2 changes: 2 additions & 0 deletions src/bot/main.js
Expand Up @@ -12,6 +12,7 @@ import { error, info, warning } from './helpers/log';
import { TMI } from './tmi';
import { API } from './api';
import { Twitch } from './twitch';
import { Socket } from './socket';
import { Webhooks } from './webhooks';
import { Users } from './users';
import { UI } from './ui';
Expand Down Expand Up @@ -62,6 +63,7 @@ async function main () {
if (!global.db.engine.connected || global.cpu !== global.workers.onlineCount) return setTimeout(() => main(), 10)
try {
global.general = new (require('./general.js'))()
global.socket = new Socket()
global.ui = new UI()
global.currency = new Currency()
global.stats2 = new (require('./stats.js'))()
Expand Down
24 changes: 2 additions & 22 deletions src/bot/panel.js
Expand Up @@ -79,23 +79,6 @@ function Panel () {
app.get('/oauth/:page', function (req, res) {
res.sendFile(path.join(__dirname, '..', 'public', 'oauth-' + req.params.page + '.html'))
})
app.get('/auth/token.js', async function (req, res) {
const origin = req.headers.referer ? req.headers.referer.substring(0, req.headers.referer.length - 1) : undefined
const domain = config.panel.domain.split(',').map((o) => o.trim()).join('|')
if (_.isNil(origin)) {
// file CANNOT be accessed directly
res.status(401).send('401 Access Denied - This is not a file you are looking for.')
return;
}

if (origin.match(new RegExp('^((http|https)\\:\\/\\/|)([\\w|-]+\\.)?' + domain))) {
res.set('Content-Type', 'application/javascript')
res.send(`window.token="${config.panel.token.trim()}"`);
} else {
// file CANNOT be accessed from different domain
res.status(403).send('403 Forbidden - You are looking at wrong castle.')
}
})
app.get('/overlays/:overlay', function (req, res) {
res.sendFile(path.join(__dirname, '..', 'public', 'overlays.html'))
})
Expand Down Expand Up @@ -129,10 +112,7 @@ function Panel () {
finally: null
})

this.io.use(function (socket, next) {
if (config.panel.token.trim() === socket.request._query['token']) next()
return false
})
this.io.use(global.socket.authorize);

var self = this
this.io.on('connection', function (socket) {
Expand Down Expand Up @@ -359,7 +339,7 @@ function Panel () {
})
socket.on('core', async (cb) => {
let toEmit = []
for (let system of ['oauth', 'tmi', 'currency', 'ui', 'general', 'twitch']) {
for (let system of ['oauth', 'tmi', 'currency', 'ui', 'general', 'twitch', 'socket']) {
toEmit.push({
name: system.toLowerCase()
})
Expand Down
157 changes: 157 additions & 0 deletions src/bot/socket.ts
@@ -0,0 +1,157 @@
import Core from './_interface';
import { settings, ui } from './decorators';
import { MINUTE, SECOND } from './constants';
import { isMainThread } from 'worker_threads';
import uuid = require('uuid/v4');
import { permission } from './helpers/permissions';

type Auth = {
userId: string;
type: 'admin' | 'viewer' | 'public',
accessToken: string | null;
accessTokenTimestamp: number;
refreshToken: string;
refreshTokenTimestamp: number;
}

let sockets: Auth[] = [];

const endpoints: {
type: 'admin' | 'viewer' | 'public';
on: string;
nsp: string;
callback: Function;
}[] = [];

const adminEndpoint = (nsp: string, on: string, callback: Function) => {
endpoints.push({ nsp, on, callback, type: 'admin' });
};
const viewerEndpoint = (nsp: string, on: string, callback: Function) => {
endpoints.push({ nsp, on, callback, type: 'viewer' });
};
const publicEndpoint = (nsp: string, on: string, callback: Function) => {
endpoints.push({ nsp, on, callback, type: 'public' });
};

class Socket extends Core {
@settings('connection')
accessTokenExpirationTime = 120;

@settings('connection')
refreshTokenExpirationTime = 604800;

@ui({
type: 'btn-emit',
class: 'btn btn-danger btn-block mt-1 mb-1',
emit: 'purgeAllConnections',
}, 'connection')
purgeAllConnections = null;

constructor() {
super();

if (isMainThread) {
setInterval(() => {
sockets = sockets.filter(socket => {
const isAccessTokenExpired = socket.accessTokenTimestamp + (this.accessTokenExpirationTime * 1000) < Date.now();
const isRefreshTokenExpired = socket.refreshTokenTimestamp + (this.refreshTokenExpirationTime * 1000) < Date.now();
return !(isRefreshTokenExpired && isAccessTokenExpired);
});
}, MINUTE);

setInterval(() => {
sockets = sockets.map(socket => {
const isAccessTokenExpired = socket.accessTokenTimestamp + (this.accessTokenExpirationTime * 1000) < Date.now();
if (isAccessTokenExpired) {
// expire token
socket.accessToken = null;
}
return socket;
});
}, 10 * SECOND);
}
}

authorize(socket, next) {
const sendAuthorized = (socket, auth) => {
socket.emit('authorized', { accessToken: auth.accessToken, refreshToken: auth.refreshToken, type: auth.type });
if (auth.type === 'admin') {
for (const endpoint of endpoints.filter(o => o.type === 'admin' && o.nsp === socket.nsp.name)) {
if (!Object.keys(socket._events).includes(endpoint.on)) {
socket.on(endpoint.on, endpoint.callback);
}
}
}
for (const endpoint of endpoints.filter(o => o.type === 'viewer' && o.nsp === socket.nsp.name)) {
if (!Object.keys(socket._events).includes(endpoint.on)) {
socket.on(endpoint.on, endpoint.callback);
}
}

// reauth every minute
setTimeout(() => emitAuthorize(socket), MINUTE);
}
const emitAuthorize = (socket) => {
socket.emit('authorize', (cb: { accessToken: string; refreshToken: string; }) => {
if (cb.accessToken === '' || cb.refreshToken === '') {
// we don't have anything
return socket.emit('unauthorized')
} else {
const auth = sockets.find(o => (o.accessToken === cb.accessToken || o.refreshToken === cb.refreshToken) )
if (!auth) {
return socket.emit('unauthorized')
} else {
if (auth.accessToken === cb.accessToken) {
// update refreshToken timestamp to expire only if not used
auth.refreshTokenTimestamp = Date.now();
sendAuthorized(socket, auth);
} else {
auth.accessToken = uuid();
auth.accessTokenTimestamp = Date.now();
auth.refreshTokenTimestamp = Date.now();
sendAuthorized(socket, auth);
}
}
}
});
}
next();

socket.on('newAuthorization', async (userId, cb) => {
const userPermission = await global.permissions.getUserHighestPermission(userId);
const auth: Auth = {
accessToken: uuid(),
refreshToken: uuid(),
accessTokenTimestamp: Date.now(),
refreshTokenTimestamp: Date.now(),
userId,
type: 'viewer'
}
if (userPermission === permission.CASTERS) {
auth.type = 'admin'
}
sockets.push(auth);
sendAuthorized(socket, auth);
cb();
})
emitAuthorize(socket);

for (const endpoint of endpoints.filter(o => o.type === 'public' && o.nsp === socket.nsp.name)) {
if (!Object.keys(socket._events).includes(endpoint.on)) {
socket.on(endpoint.on, endpoint.callback);
}
}
}

sockets () {
global.panel.io.of('/core/socket').on('connection', (socket) => {
socket.on('purgeAllConnections', (cb) => {
sockets = [];
cb(null);
});
});
}
}

export default Socket;
export { adminEndpoint, viewerEndpoint, publicEndpoint, Socket };

0 comments on commit 7598e82

Please sign in to comment.