Kaynağa Gözat

Merge pull request #2901 from weseek/dev/4.1.x

Dev/4.1.x
Yuki Takei 5 yıl önce
ebeveyn
işleme
4e569a7acd

+ 1 - 0
config/logger/config.dev.js

@@ -17,6 +17,7 @@ module.exports = {
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
+  // 'growi:service:socket-io': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:mail': 'debug',
   'growi:lib:search': 'debug',

+ 1 - 0
package.json

@@ -73,6 +73,7 @@
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
     "@google-cloud/storage": "^3.3.0",
+    "@kobalab/socket.io-session": "^1.0.3",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",

+ 6 - 0
src/client/js/services/AdminSocketIoContainer.js

@@ -1,4 +1,5 @@
 import SocketIoContainer from './SocketIoContainer';
+import { toastError } from '../util/apiNotification';
 
 /**
  * A subclass of SocketIoContainer for /admin namespace
@@ -7,6 +8,11 @@ export default class AdminSocketIoContainer extends SocketIoContainer {
 
   constructor(appContainer) {
     super(appContainer, '/admin');
+
+    // show toastr
+    this.socket.on('error', (error) => {
+      toastError(new Error(error));
+    });
   }
 
   /**

+ 11 - 0
src/client/js/services/SocketIoContainer.js

@@ -2,6 +2,10 @@ import { Container } from 'unstated';
 
 import io from 'socket.io-client';
 
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:cli:SocketIoContainer');
+
 /**
  * Service container related to options for WebSocket
  * @extends {Container} unstated Container
@@ -21,6 +25,13 @@ export default class SocketIoContainer extends Container {
     });
     this.socketClientId = Math.floor(Math.random() * 100000);
 
+    this.socket.on('connect_error', (error) => {
+      logger.error(error);
+    });
+    this.socket.on('error', (error) => {
+      logger.error(error);
+    });
+
     this.state = {
     };
 

+ 1 - 1
src/server/crowi/index.js

@@ -278,7 +278,7 @@ Crowi.prototype.setupS2sMessagingService = async function() {
 Crowi.prototype.setupSocketIoService = async function() {
   const SocketIoService = require('../service/socket-io');
   if (this.socketIoService == null) {
-    this.socketIoService = new SocketIoService();
+    this.socketIoService = new SocketIoService(this);
   }
 };
 

+ 7 - 1
src/server/middlewares/admin-required.js

@@ -2,7 +2,7 @@ const loggerFactory = require('@alias/logger');
 
 const logger = loggerFactory('growi:middleware:admin-required');
 
-module.exports = (crowi) => {
+module.exports = (crowi, fallback = null) => {
 
   return async(req, res, next) => {
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
@@ -13,11 +13,17 @@ module.exports = (crowi) => {
 
       logger.warn('This user is not admin.');
 
+      if (fallback != null) {
+        return fallback(req, res);
+      }
       return res.redirect('/');
     }
 
     logger.warn('This user has not logged in.');
 
+    if (fallback != null) {
+      return fallback(req, res);
+    }
     return res.redirect('/login');
   };
 

+ 8 - 1
src/server/middlewares/login-required.js

@@ -6,8 +6,9 @@ const logger = loggerFactory('growi:middleware:login-required');
  * require login handler
  *
  * @param {boolean} isGuestAllowed whethere guest user is allowed (default false)
+ * @param {function} fallback fallback function which will be triggered when the check cannot be passed
  */
-module.exports = (crowi, isGuestAllowed = false) => {
+module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
   return function(req, res, next) {
 
@@ -45,9 +46,15 @@ module.exports = (crowi, isGuestAllowed = false) => {
     // is api path
     const path = req.path || '';
     if (path.match(/^\/_api\/.+$/)) {
+      if (fallback != null) {
+        return fallback(req, res);
+      }
       return res.sendStatus(403);
     }
 
+    if (fallback != null) {
+      return fallback(req, res);
+    }
     req.session.redirectTo = req.originalUrl;
     return res.redirect('/login');
   };

+ 18 - 0
src/server/service/config-loader.js

@@ -155,6 +155,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  S2CMSG_PUBSUB_CONNECTIONS_LIMIT: {
+    ns:      'crowi',
+    key:     's2cMessagingPubsub:connectionsLimit',
+    type:    TYPES.NUMBER,
+    default: 5000,
+  },
+  S2CMSG_PUBSUB_CONNECTIONS_LIMIT_FOR_ADMIN: {
+    ns:      'crowi',
+    key:     's2cMessagingPubsub:connectionsLimitForAdmin',
+    type:    TYPES.NUMBER,
+    default: 100,
+  },
+  S2CMSG_PUBSUB_CONNECTIONS_LIMIT_FOR_GUEST: {
+    ns:      'crowi',
+    key:     's2cMessagingPubsub:connectionsLimitForGuest',
+    type:    TYPES.NUMBER,
+    default: 2000,
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

+ 154 - 0
src/server/service/socket-io.js

@@ -1,10 +1,23 @@
 const socketIo = require('socket.io');
+const expressSession = require('express-session');
+const passport = require('passport');
+const socketioSession = require('@kobalab/socket.io-session');
+
+const logger = require('@alias/logger')('growi:service:socket-io');
+
 
 /**
  * Serve socket.io for server-to-client messaging
  */
 class SocketIoService {
 
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+
+    this.guestClients = new Set();
+  }
+
   get isInitialized() {
     return (this.io != null);
   }
@@ -16,6 +29,15 @@ class SocketIoService {
 
     // create namespace for admin
     this.adminNamespace = this.io.of('/admin');
+
+    // setup middlewares
+    // !!CAUTION!! -- ORDER IS IMPORTANT
+    this.setupSessionMiddleware();
+    this.setupLoginRequiredMiddleware();
+    this.setupAdminRequiredMiddleware();
+    this.setupCheckConnectionLimitsMiddleware();
+
+    this.setupStoreGuestIdEventHandler();
   }
 
   getDefaultSocket() {
@@ -29,9 +51,141 @@ class SocketIoService {
     if (this.io == null) {
       throw new Error('Http server has not attached yet.');
     }
+
     return this.adminNamespace;
   }
 
+  /**
+   * use passport session
+   * @see https://qiita.com/kobalab/items/083e507fb01159fe9774
+   */
+  setupSessionMiddleware() {
+    const sessionMiddleware = socketioSession(expressSession(this.crowi.sessionConfig), passport);
+    this.io.use(sessionMiddleware.express_session);
+    this.io.use(sessionMiddleware.passport_initialize);
+    this.io.use(sessionMiddleware.passport_session);
+  }
+
+  /**
+   * use loginRequired middleware
+   */
+  setupLoginRequiredMiddleware() {
+    const loginRequired = require('../middlewares/login-required')(this.crowi, true, (req, res, next) => {
+      next(new Error('Login is required to connect.'));
+    });
+
+    // convert Connect/Express middleware to Socket.io middleware
+    this.io.use((socket, next) => {
+      loginRequired(socket.request, {}, next);
+    });
+  }
+
+  /**
+   * use adminRequired middleware
+   */
+  setupAdminRequiredMiddleware() {
+    const adminRequired = require('../middlewares/admin-required')(this.crowi, (req, res, next) => {
+      next(new Error('Admin priviledge is required to connect.'));
+    });
+
+    // convert Connect/Express middleware to Socket.io middleware
+    this.getAdminSocket().use((socket, next) => {
+      adminRequired(socket.request, {}, next);
+    });
+  }
+
+  /**
+   * use checkConnectionLimits middleware
+   */
+  setupCheckConnectionLimitsMiddleware() {
+    this.getAdminSocket().use(this.checkConnectionLimitsForAdmin.bind(this));
+    this.getDefaultSocket().use(this.checkConnectionLimitsForGuest.bind(this));
+    this.getDefaultSocket().use(this.checkConnectionLimits.bind(this));
+  }
+
+  setupStoreGuestIdEventHandler() {
+    this.io.on('connection', (socket) => {
+      if (socket.request.user == null) {
+        this.guestClients.add(socket.id);
+
+        socket.on('disconnect', () => {
+          this.guestClients.delete(socket.id);
+        });
+      }
+    });
+  }
+
+  async getClients(namespace) {
+    return new Promise((resolve, reject) => {
+      namespace.clients((error, clients) => {
+        if (error) {
+          reject(error);
+        }
+        resolve(clients);
+      });
+    });
+  }
+
+  async checkConnectionLimitsForAdmin(socket, next) {
+    const namespaceName = socket.nsp.name;
+
+    if (namespaceName === '/admin') {
+      const clients = await this.getClients(this.getAdminSocket());
+      const clientsCount = clients.length;
+
+      logger.debug('Current count of clients for \'/admin\':', clientsCount);
+
+      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
+      if (limit <= clientsCount) {
+        const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
+        logger.warn(msg);
+        next(new Error(msg));
+        return;
+      }
+    }
+
+    next();
+  }
+
+  async checkConnectionLimitsForGuest(socket, next) {
+
+    if (socket.request.user == null) {
+      const clientsCount = this.guestClients.size;
+
+      logger.debug('Current count of clients for guests:', clientsCount);
+
+      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
+      if (limit <= clientsCount) {
+        const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
+        logger.warn(msg);
+        next(new Error(msg));
+        return;
+      }
+    }
+
+    next();
+  }
+
+  /**
+   * @see https://socket.io/docs/server-api/#socket-client
+   */
+  async checkConnectionLimits(socket, next) {
+    const clients = await this.getClients(this.getDefaultSocket());
+    const clientsCount = clients.length;
+
+    logger.debug('Current count of clients for \'/\':', clientsCount);
+
+    const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
+    if (limit <= clientsCount) {
+      const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
+      logger.warn(msg);
+      next(new Error(msg));
+      return;
+    }
+
+    next();
+  }
+
 }
 
 module.exports = SocketIoService;

+ 63 - 0
src/test/middlewares/login-required.test.js

@@ -4,13 +4,17 @@ const { getInstance } = require('../setup-crowi');
 
 describe('loginRequired', () => {
   let crowi;
+  const fallbackMock = jest.fn().mockReturnValue('fallback');
+
   let loginRequiredStrictly;
   let loginRequired;
+  let loginRequiredWithFallback;
 
   beforeEach(async(done) => {
     crowi = await getInstance();
     loginRequiredStrictly = require('@server/middlewares/login-required')(crowi);
     loginRequired = require('@server/middlewares/login-required')(crowi, true);
+    loginRequiredWithFallback = require('@server/middlewares/login-required')(crowi, false, fallbackMock);
     done();
   });
 
@@ -33,6 +37,7 @@ describe('loginRequired', () => {
       const result = loginRequired(req, res, next);
 
       expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(result).toBe('next');
@@ -46,6 +51,7 @@ describe('loginRequired', () => {
       const result = loginRequired(req, res, next);
 
       expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
@@ -63,6 +69,7 @@ describe('loginRequired', () => {
       const result = loginRequired(req, res, next);
 
       expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(result).toBe('next');
@@ -100,6 +107,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(res.sendStatus).toHaveBeenCalledTimes(1);
       expect(res.sendStatus).toHaveBeenCalledWith(403);
@@ -113,6 +121,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
@@ -131,6 +140,7 @@ describe('loginRequired', () => {
       const result = loginRequiredStrictly(req, res, next);
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalledTimes(1);
@@ -154,6 +164,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith(expectedPath);
@@ -175,6 +186,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
@@ -184,4 +196,55 @@ describe('loginRequired', () => {
 
   });
 
+  describe('specified fallback', () => {
+    // setup req/res/next
+    const req = {
+      originalUrl: 'original url 1',
+      session: null,
+    };
+    const res = {
+      redirect: jest.fn().mockReturnValue('redirect'),
+      sendStatus: jest.fn().mockReturnValue('sendStatus'),
+    };
+    const next = jest.fn().mockReturnValue('next');
+
+    let isGuestAllowedToReadSpy;
+
+    beforeEach(async(done) => {
+      // reset session object
+      req.session = {};
+      // spy for AclService.isGuestAllowedToRead
+      isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead');
+      done();
+    });
+
+    test('invoke fallback when \'req.path\' starts with \'_api\'', () => {
+      req.path = '/_api/someapi';
+
+      const result = loginRequiredWithFallback(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(fallbackMock).toHaveBeenCalledTimes(1);
+      expect(fallbackMock).toHaveBeenCalledWith(req, res);
+      expect(result).toBe('fallback');
+    });
+
+    test('invoke fallback when the user does not loggedin', () => {
+      req.path = '/path/that/requires/loggedin';
+
+      const result = loginRequiredWithFallback(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(fallbackMock).toHaveBeenCalledTimes(1);
+      expect(fallbackMock).toHaveBeenCalledWith(req, res);
+      expect(result).toBe('fallback');
+    });
+
+  });
 });

+ 5 - 0
yarn.lock

@@ -1673,6 +1673,11 @@
   resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-1.0.1.tgz#1ba7886c0474cc31707acd195f7b9073406b743d"
   integrity sha512-gQZ0a3JcrCi1g+00D9CIbo2uPc6lnykqAsVaCbew8jsrdyF0f0cBngYgFKcTxW2vliT5I3K4lwD4DhM6hXeOjg==
 
+"@kobalab/socket.io-session@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@kobalab/socket.io-session/-/socket.io-session-1.0.3.tgz#87d55896bb48f57c57f26f0235bf53345a0a4615"
+  integrity sha512-pen2rqNuZUsR453EVM9owqDIbelFKa5gizyNM9hscphKrdPIYissNa9efddYSVBH24q7pknxS5kxbfSw/YYOMg==
+
 "@lykmapipo/common@>=0.34.2", "@lykmapipo/common@>=0.34.3":
   version "0.34.3"
   resolved "https://registry.yarnpkg.com/@lykmapipo/common/-/common-0.34.3.tgz#eb74fa4af14f2f1e59ddd42491f05ab69f96bd71"