diff --git a/.github/actions/cache-node-modules/action.yml b/.github/actions/cache-node-modules/action.yml new file mode 100644 index 0000000000..efbffb8b5f --- /dev/null +++ b/.github/actions/cache-node-modules/action.yml @@ -0,0 +1,22 @@ +name: Cache Node Modules +description: Cache Node Modules + +inputs: + NODE_VERSION: + description: node version + required: true + +runs: + using: "composite" + steps: + - name: Cache node modules + uses: actions/cache@v2 + env: + cache-name: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ inputs.NODE_VERSION }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}-${{ inputs.NODE_VERSION }}-${{ hashFiles('package-lock.json') }} + + diff --git a/.github/actions/functional-tests/action.yml b/.github/actions/functional-tests/action.yml index 6913f90609..bbe78f7c0d 100644 --- a/.github/actions/functional-tests/action.yml +++ b/.github/actions/functional-tests/action.yml @@ -18,5 +18,4 @@ runs: env: KUZZLE_FUNCTIONAL_TESTS: ${{ inputs.test-set }} NODE_VERSION: ${{ inputs.node-version }} - REBUILD: "true" shell: bash diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 640eebefbf..e233a9704c 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -6,8 +6,6 @@ runs: steps: - run: npm install shell: bash - - run: npm rebuild - shell: bash - run: npm run build shell: bash - run: npm run test:unit:coverage diff --git a/.github/workflows/pull_request.workflow.yml b/.github/workflows/pull_request.workflow.yml index c0fb0a686e..76cd3bec9d 100644 --- a/.github/workflows/pull_request.workflow.yml +++ b/.github/workflows/pull_request.workflow.yml @@ -11,6 +11,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/es-lint unit-tests: @@ -25,6 +28,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/unit-tests functional-tests-legacy: @@ -40,6 +46,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/functional-tests with: test-set: test:functional:legacy:${{ matrix.test_set }} @@ -58,6 +67,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/functional-tests with: test-set: test:functional:${{ matrix.test_set }} @@ -89,6 +101,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/monkey-tests with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/push_dev.workflow.yml b/.github/workflows/push_dev.workflow.yml index a8abe9422d..1d38c063bb 100644 --- a/.github/workflows/push_dev.workflow.yml +++ b/.github/workflows/push_dev.workflow.yml @@ -14,6 +14,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/es-lint unit-tests: @@ -28,6 +31,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/unit-tests functional-tests-legacy: @@ -43,6 +49,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/functional-tests with: test-set: test:functional:legacy:${{ matrix.test_set }} @@ -61,6 +70,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/functional-tests with: test-set: test:functional:${{ matrix.test_set }} @@ -83,6 +95,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/monkey-tests with: node-version: ${{ matrix.node-version }} @@ -105,6 +120,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/deploy-doc with: REGION: us-west-2 diff --git a/.github/workflows/push_master.workflow.yml b/.github/workflows/push_master.workflow.yml index e31a36d33e..365e00633f 100644 --- a/.github/workflows/push_master.workflow.yml +++ b/.github/workflows/push_master.workflow.yml @@ -14,6 +14,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/es-lint unit-tests: @@ -28,6 +31,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/unit-tests functional-tests-legacy: @@ -43,6 +49,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/functional-tests with: test-set: test:functional:legacy:${{ matrix.test_set }} @@ -61,6 +70,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/functional-tests with: test-set: test:functional:${{ matrix.test_set }} @@ -83,6 +95,9 @@ jobs: - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/cache-node-modules + with: + NODE_VERSION: ${{ matrix.node-version }} - uses: ./.github/actions/monkey-tests with: node-version: ${{ matrix.node-version }} diff --git a/.kuzzlerc.sample b/.kuzzlerc.sample index 06376dc791..f5d3292886 100644 --- a/.kuzzlerc.sample +++ b/.kuzzlerc.sample @@ -105,6 +105,11 @@ // (see https://docs.kuzzle.io/core/2/guides/write-plugins) "plugins": { // [Common] + // * failsafeMode + // If true, Kuzzle will not load custom plugin and features (including + // the ones defined in the application). + // The API will only be available to administrators ("admin" profile) + // during failsafe mode. // * bootstrapLockTimeout // Maximum amount of time (in milliseconds) // to wait for a concurrent plugin bootstrap @@ -131,6 +136,7 @@ // Maximum number of pipes that can be delayed. If full, new pipes // are rejected. "common": { + "failsafeMode": false, "bootstrapLockTimeout": 30000, "pipeWarnTime": 40, "initTimeout": 2000, diff --git a/doc/2/api/errors/error-codes/api/index.md b/doc/2/api/errors/error-codes/api/index.md index 427155ba22..8442b1234b 100644 --- a/doc/2/api/errors/error-codes/api/index.md +++ b/doc/2/api/errors/error-codes/api/index.md @@ -50,5 +50,6 @@ description: Error codes definitions | api.process.incomplete_multiple_request
0x0202000a
| [MultipleErrorsError](/core/2/api/errors/error-codes#multipleerrorserror)
(400)
| At least one of the %s actions failed. | Failed to execute some or all actions requested | | api.process.not_enough_nodes
0x0202000b
| [ServiceUnavailableError](/core/2/api/errors/error-codes#serviceunavailableerror)
(503)
| Rejected: this cluster is disabled because there aren't enough nodes connected. | The Kuzzle cluster has not enough nodes available, and no new requests can be processed until new nodes are added | | api.process.unauthorized_origin
0x0202000c
| [UnauthorizedError](/core/2/api/errors/error-codes#unauthorizederror)
(401)
| The origin "%s" is not authorized. | The domain reaching out to Kuzzle is not authorized | +| api.process.too_many_logins_requests
0x0202000d
| [TooManyRequestsError](/core/2/api/errors/error-codes#toomanyrequestserror)
(429)
| Rejected: Too many login attempts per second | The request was denied because the maximum ("limits.loginsPerSecond") number of login attempts per second has been exceeded. | --- diff --git a/doc/2/api/errors/error-codes/plugin/index.md b/doc/2/api/errors/error-codes/plugin/index.md index 106f3728bd..3e62cb45cc 100644 --- a/doc/2/api/errors/error-codes/plugin/index.md +++ b/doc/2/api/errors/error-codes/plugin/index.md @@ -39,7 +39,7 @@ description: Error codes definitions | id / code | class / status | message | description | | --------- | -------------- | --------| ----------- | -| plugin.runtime.failed_init
0x04020001
| [PluginImplementationError](/core/2/api/errors/error-codes#pluginimplementationerror)
(500)
| Something went wrong during initialization of "%s" plugin. | An exception was thrown by a plugin's init function | +| plugin.runtime.failed_init
0x04020001
| [PluginImplementationError](/core/2/api/errors/error-codes#pluginimplementationerror)
(500)
| Something went wrong during initialization of "%s" plugin. Set "plugins.common.failsafeMode" to true to bypass plugin initialization. | An exception was thrown by a plugin's init function | | plugin.runtime.unexpected_error
0x04020002
| [PluginImplementationError](/core/2/api/errors/error-codes#pluginimplementationerror)
(500)
| Caught an unexpected plugin error: %s | Embeds an unexpected plugin error into a standardized KuzzleError object | | plugin.runtime.pipe_timeout
0x04020003
| [GatewayTimeoutError](/core/2/api/errors/error-codes#gatewaytimeouterror)
(504)
| Plugin "%s": timeout error. A pipe on the event "%s" exceeded the timeout delay (%sms). Aborting. | A pipe function execution took more than the configured server limit | | plugin.runtime.too_many_pipes
0x04020004
| [ServiceUnavailableError](/core/2/api/errors/error-codes#serviceunavailableerror)
(503)
| Request discarded: maximum number of executing pipe functions reached. | The number of running pipes exceeds the configured capacity (see configuration files). This may be caused by pipes being too slow, or by an insufficient number of Kuzzle nodes. | diff --git a/doc/2/api/errors/error-codes/security/index.md b/doc/2/api/errors/error-codes/security/index.md index 5fa73ac4d8..436f1d9a79 100644 --- a/doc/2/api/errors/error-codes/security/index.md +++ b/doc/2/api/errors/error-codes/security/index.md @@ -45,6 +45,7 @@ description: Error codes definitions | --------- | -------------- | --------| ----------- | | security.rights.unauthorized
0x07030001
| [UnauthorizedError](/core/2/api/errors/error-codes#unauthorizederror)
(401)
| Unauthorized: authentication required to execute the action "%s:%s". | Authentication required to execute this action | | security.rights.forbidden
0x07030002
| [ForbiddenError](/core/2/api/errors/error-codes#forbiddenerror)
(403)
| Insufficient permissions to execute the action "%s:%s" (User "%s"). | Insufficient permissions to execute this action | +| security.rights.failsafe_mode_admin_only
0x07030003
| [ForbiddenError](/core/2/api/errors/error-codes#forbiddenerror)
(403)
| Only administrators ("admin" profile) can use the API in failsafe mode. | Only administrators ("admin" profile) can use the API in failsafe mode. Authenticate as admin or reboot without failsafe mode ("config.plugins.common.failsafeMode") to access the API. | --- diff --git a/features-legacy/support/api/mqtt.js b/features-legacy/support/api/mqtt.js index d02f7e57cf..d39514258a 100644 --- a/features-legacy/support/api/mqtt.js +++ b/features-legacy/support/api/mqtt.js @@ -120,6 +120,9 @@ class MqttApi extends ApiBase { } } else { + if (message.type === 'TokenExpired') { + this.responses = message; + } // notification const channel = topic; const roomId = topic.split('-')[0]; diff --git a/features-legacy/support/api/websocket.js b/features-legacy/support/api/websocket.js index f0eb53186d..910d117422 100644 --- a/features-legacy/support/api/websocket.js +++ b/features-legacy/support/api/websocket.js @@ -35,6 +35,9 @@ class WebSocketApi extends WsApiBase { const data = JSON.parse(message); if (data.scope || data.type === 'user' || data.type === 'TokenExpired') { + if (data.type === 'TokenExpired') { + this.responses = data; + } // notification const channel = data.room; const roomId = channel.split('-')[0]; diff --git a/lib/api/funnel.js b/lib/api/funnel.js index c89a8c50ac..d95ce32cee 100644 --- a/lib/api/funnel.js +++ b/lib/api/funnel.js @@ -50,6 +50,10 @@ const debug = require('../util/debug')('kuzzle:funnel'); const processError = kerror.wrap('api', 'process'); const { has } = require('../util/safeObject'); +// Actions of the auth controller that does not necessite to verify the token +// when cookie auth is active +const SKIP_TOKEN_VERIF_ACTIONS = ['login', 'checkToken', 'logout']; + /** * @class PendingRequest * @param {Request} request @@ -303,6 +307,9 @@ class Funnel { }) .then(allowed => { if (!allowed) { + if (request.input.controller === 'auth' && request.input.action === 'login') { + throw processError.get('too_many_logins_requests'); + } throw processError.get('too_many_requests'); } @@ -439,18 +446,18 @@ class Funnel { skipTokenVerification = request.getBoolean('cookieAuth') && request.input.controller === 'auth' - && ( request.input.action === 'login' - || request.input.action === 'checkToken' - || request.input.action === 'logout' - ); + && SKIP_TOKEN_VERIF_ACTIONS.includes(request.input.action); } } try { - // If the verification should be skipped, we pass a null token, this way the verification will be made as anonymous + // If the verification should be skipped, we pass a null token, + // this way the verification will be made as anonymous + const token = skipTokenVerification ? null : request.input.jwt; + request.context.token = await global.kuzzle.ask( 'core:security:token:verify', - !skipTokenVerification && request.input.jwt || null); + token); } catch (error) { await global.kuzzle.pipe('request:onUnauthorized', request); @@ -487,9 +494,21 @@ class Funnel { throw error; } + if ( global.kuzzle.config.plugins.common.failsafeMode + && ! this._isLogin(request) + && ! request.context.user.profileIds.includes('admin') + ) { + await global.kuzzle.pipe('request:onUnauthorized', request); + throw kerror.get('security', 'rights', 'failsafe_mode_admin_only'); + } + return global.kuzzle.pipe('request:onAuthorized', request); } + _isLogin (request) { + return request.input.controller === 'auth' && request.input.action === 'login'; + } + /** * Executes the request immediately. * /!\ To be used only by methods having already passed the overload check. diff --git a/lib/cluster/node.js b/lib/cluster/node.js index edc2e76983..06c1ce672a 100644 --- a/lib/cluster/node.js +++ b/lib/cluster/node.js @@ -375,7 +375,7 @@ class ClusterNode { // The goal of this process is to force at least 1 node to kill itself, // with all nodes concluding on their own on the same list of nodes to // shut down. - + // First remove every non existing node from topologies splits = splits.map( topology => topology.filter( @@ -462,24 +462,28 @@ class ClusterNode { timeout: this.config.joinTimeout, }); - await mutex.lock(); - try { + await mutex.lock(); + // Create the ID Key AFTER the handshake mutex is actually locked, // to prevent race conditions (other nodes attempting to connect to this // node while it's still initializing) await this.idCardHandler.createIdCard(); + global.kuzzle.log.debug('[CLUSTER] ID Card created'); this.nodeId = this.idCardHandler.nodeId; await this.startHeartbeat(); + global.kuzzle.log.debug('[CLUSTER] Start heartbeat'); let retried = false; let fullState = null; let nodes; + global.kuzzle.log.debug('[CLUSTER] Start retrieving full state..'); do { nodes = await this.idCardHandler.getRemoteIdCards(); + global.kuzzle.log.debug(`[CLUSTER] ${nodes.length} remote nodes discovered`); // No other nodes detected = no handshake required if (nodes.length === 0) { @@ -502,6 +506,7 @@ class ClusterNode { this.remoteNodes.set(id, subscriber); return subscriber.init(); }); + global.kuzzle.log.debug('[CLUSTER] Successfully subscribed to nodes'); fullState = await this.command.getFullState(nodes); @@ -531,14 +536,19 @@ class ClusterNode { } } while (fullState === null); + global.kuzzle.log.debug('[CLUSTER] Fullstate retrieved, loading into node..'); await this.fullState.loadFullState(fullState); this.activity = fullState.activity ? fullState.activity : this.activity; + global.kuzzle.log.debug('[CLUSTER] Fullstate loaded.'); + const handshakeResponses = await this.command.broadcastHandshake(nodes); + global.kuzzle.log.debug('[CLUSTER] Successful handshakes with other nodes.'); + // Update subscribers: start synchronizing, or unsubscribes from nodes who // didn't respond for (const [nodeId, handshakeData] of Object.entries(handshakeResponses)) { @@ -549,7 +559,7 @@ class ClusterNode { } else { await this.idCardHandler.addNode(nodeId); - const nodesStates = fullState.nodesState || []; + const nodesStates = fullState.nodesState || []; const nodeStatus = nodesStates.find(node => node.id === nodeId); subscriber.sync(nodeStatus ? nodeStatus.lastMessageId diff --git a/lib/cluster/workers/IDCardRenewer.js b/lib/cluster/workers/IDCardRenewer.js index 1a1c8db596..b0a87c7ca9 100644 --- a/lib/cluster/workers/IDCardRenewer.js +++ b/lib/cluster/workers/IDCardRenewer.js @@ -11,10 +11,14 @@ class IDCardRenewer { this.refreshTimer = null; this.nodeIdKey = null; this.refreshDelay = 2000; - this.disposeed = false; + this.disposed = true; // Disposed until initialized } async init (config) { + if (!this.disposed) { + return; // Already intialized + } + this.disposed = false; this.nodeIdKey = config.nodeIdKey; this.refreshDelay = config.refreshDelay || 2000; @@ -52,17 +56,19 @@ class IDCardRenewer { } async renewIDCard () { + if (this.disposed) { + return; // Do not refresh ID Card when worker has been disposed + } + try { - if (!this.disposed) { - const refreshed = await this.redis.commands.pexpire( - this.nodeIdKey, - this.refreshDelay * 3); - // Unable to refresh the key in time before it expires - // => this node is too slow, we need to remove it from the cluster - if (refreshed === 0) { - await this.dispose(); - this.parentPort.postMessage({ error: 'Node too slow: ID card expired' }); - } + const refreshed = await this.redis.commands.pexpire( + this.nodeIdKey, + this.refreshDelay * 3); + // Unable to refresh the key in time before it expires + // => this node is too slow, we need to remove it from the cluster + if (refreshed === 0) { + await this.dispose(); + this.parentPort.postMessage({ error: 'Node too slow: ID card expired' }); } } catch (error) { @@ -74,17 +80,19 @@ class IDCardRenewer { } async dispose () { - if (!this.disposed) { - this.disposed = true; - clearInterval(this.refreshTimer); - this.refreshTimer = null; - try { - await this.redis.commands.del(this.nodeIdKey); - } - catch (error) { - // eslint-disable-next-line no-console - console.error(`Could not delete key ${this.nodeIdKey} from redis: ${error.message}`); - } + if (this.disposed) { + return; // Already disposed + } + + this.disposed = true; + clearInterval(this.refreshTimer); + this.refreshTimer = null; + try { + await this.redis.commands.del(this.nodeIdKey); + } + catch (error) { + // eslint-disable-next-line no-console + console.error(`Could not delete key '${this.nodeIdKey}' from redis: ${error.message}`); } } } diff --git a/lib/config/default.config.js b/lib/config/default.config.js index 097c1058ab..d03eafa997 100644 --- a/lib/config/default.config.js +++ b/lib/config/default.config.js @@ -70,6 +70,7 @@ module.exports = { plugins: { common: { + failsafeMode: false, bootstrapLockTimeout: 30000, pipeWarnTime: 500, initTimeout: 10000, diff --git a/lib/core/plugin/pluginsManager.js b/lib/core/plugin/pluginsManager.js index 482a1af8da..df36746d2f 100644 --- a/lib/core/plugin/pluginsManager.js +++ b/lib/core/plugin/pluginsManager.js @@ -42,6 +42,9 @@ const runtimeError = kerror.wrap('plugin', 'runtime'); const strategyError = kerror.wrap('plugin', 'strategy'); const controllerError = kerror.wrap('plugin', 'controller'); +// Without those plugins, Kuzzle won't start at all. +const CORE_PLUGINS = ['kuzzle-plugin-logger', 'kuzzle-plugin-auth-passport-local']; + /** * @class PluginsManager * @param {Kuzzle} kuzzle @@ -89,6 +92,8 @@ class PluginsManager { if (this.config.common.pipeTimeout) { global.kuzzle.log.warn('The configuration "plugins.common.pipeTimeout" has been deprecated and is now unused. It can be safely removed from configuration files'); } + + this.loadedPlugins = []; } set application (plugin) { @@ -199,6 +204,11 @@ class PluginsManager { const loadPlugins = []; for (const plugin of this._plugins.values()) { + if (this.config.common.failsafeMode && ! CORE_PLUGINS.includes(plugin.name)) { + global.kuzzle.log.info(`Failsafe mode activated, skipping plugin "${plugin.name}"`); + continue; + } + if (plugin.application) { plugin.init(plugin.name); } @@ -255,6 +265,8 @@ class PluginsManager { debug('[%s] plugin started', plugin.name); + this.loadedPlugins.push(plugin.name); + return null; }); diff --git a/lib/core/realtime/notifier.js b/lib/core/realtime/notifier.js index 363af04734..bf59e48a8f 100644 --- a/lib/core/realtime/notifier.js +++ b/lib/core/realtime/notifier.js @@ -33,6 +33,13 @@ const { UserNotification, } = require('./notification'); +/** + * Notification are meant to be dispatched on "channels" created when subscribing. + * But some notification like TokenExpired don't have a specific channel to be received on, + * so we need a constant channel name to send Kuzzle notification without having to subscribe. + */ +const KUZZLE_NOTIFICATION_CHANNEL = 'kuzzle:notification:server'; + /** * @typedef {Object} DocumentChanges * @property {string} _id of the document @@ -178,30 +185,13 @@ class NotifierController { * @returns {Promise} */ async notifyTokenExpired (connectionId) { - const rooms = this.module.hotelClerk.getUserRooms(connectionId); - - if (rooms.length === 0) { - return; - } - - const channels = []; - - for (const room of rooms) { - const hotelClerkRoom = this.module.hotelClerk.rooms.get(room); - - if (hotelClerkRoom !== undefined) { - channels.push(...Object.keys(hotelClerkRoom.channels)); - } - } - - if (channels.length > 0) { - await this._dispatch( - 'notify:server', - channels, - new ServerNotification('TokenExpired', 'Authentication Token Expired'), - connectionId); - } - + + await this._dispatch( + 'notify:server', + [KUZZLE_NOTIFICATION_CHANNEL], // Sending notification on Kuzzle notification channel + new ServerNotification('TokenExpired', 'Authentication Token Expired'), + connectionId); + await this.module.hotelClerk.removeUser(connectionId); } diff --git a/lib/kerror/codes/2-api.json b/lib/kerror/codes/2-api.json index 8f040630b7..bbc07bb187 100644 --- a/lib/kerror/codes/2-api.json +++ b/lib/kerror/codes/2-api.json @@ -164,6 +164,12 @@ "code": 12, "message": "The origin \"%s\" is not authorized.", "class": "UnauthorizedError" + }, + "too_many_logins_requests": { + "description": "The request was denied because the maximum (\"limits.loginsPerSecond\") number of login attempts per second has been exceeded.", + "code": 13, + "message": "Rejected: Too many login attempts per second", + "class": "TooManyRequestsError" } } } diff --git a/lib/kerror/codes/4-plugin.json b/lib/kerror/codes/4-plugin.json index 27c60cb67a..9c727454cd 100644 --- a/lib/kerror/codes/4-plugin.json +++ b/lib/kerror/codes/4-plugin.json @@ -102,7 +102,7 @@ "failed_init": { "description": "An exception was thrown by a plugin's init function", "code": 1, - "message": "Something went wrong during initialization of \"%s\" plugin.", + "message": "Something went wrong during initialization of \"%s\" plugin. Set \"plugins.common.failsafeMode\" to true to bypass plugin initialization.", "class": "PluginImplementationError" }, "unexpected_error": { diff --git a/lib/kerror/codes/7-security.json b/lib/kerror/codes/7-security.json index ca2431c3b6..13fb9c43bb 100644 --- a/lib/kerror/codes/7-security.json +++ b/lib/kerror/codes/7-security.json @@ -91,6 +91,12 @@ "code": 2, "message": "Insufficient permissions to execute the action \"%s:%s\" (User \"%s\").", "class": "ForbiddenError" + }, + "failsafe_mode_admin_only": { + "description": "Only administrators (\"admin\" profile) can use the API in failsafe mode. Authenticate as admin or reboot without failsafe mode (\"config.plugins.common.failsafeMode\") to access the API.", + "code": 3, + "message": "Only administrators (\"admin\" profile) can use the API in failsafe mode.", + "class": "ForbiddenError" } } }, diff --git a/lib/kuzzle/kuzzle.ts b/lib/kuzzle/kuzzle.ts index d652b11126..673fb41022 100644 --- a/lib/kuzzle/kuzzle.ts +++ b/lib/kuzzle/kuzzle.ts @@ -125,7 +125,7 @@ class Kuzzle extends KuzzleEventEmitter { * Validation core component */ private validation: Validation; - + /** * Dump generator */ @@ -177,7 +177,7 @@ class Kuzzle extends KuzzleEventEmitter { this.log = new Logger(); this.rootPath = path.resolve(path.join(__dirname, '../..')); - + this.internalIndex = new InternalIndexHandler(); this.pluginsManager = new PluginsManager(); this.tokenManager = new TokenManager(); @@ -214,7 +214,7 @@ class Kuzzle extends KuzzleEventEmitter { try { this.log.info(`[ℹ] Starting Kuzzle ${this.version} ...`); await this.pipe('kuzzle:state:start'); - + // Koncorde realtime engine this.koncorde = new Koncorde({ @@ -253,7 +253,7 @@ class Kuzzle extends KuzzleEventEmitter { this.pluginsManager.application = application; await this.pluginsManager.init(options.plugins); - this.log.info(`[✔] Successfully loaded ${this.pluginsManager.plugins.length} plugins: ${this.pluginsManager.plugins.map(p => p.name).join(', ')}`); + this.log.info(`[✔] Successfully loaded ${this.pluginsManager.loadedPlugins.length} plugins: ${this.pluginsManager.loadedPlugins.join(', ')}`); // Authentification plugins must be loaded before users import to avoid // credentials related error which would prevent Kuzzle from starting @@ -469,7 +469,7 @@ class Kuzzle extends KuzzleEventEmitter { } const toSupport = config.toSupport; - + if (! _.isEmpty(toSupport.fixtures)) { await this.ask('core:storage:public:document:import', toSupport.fixtures); this.log.info('[✔] Fixtures import successful'); @@ -544,7 +544,7 @@ class Kuzzle extends KuzzleEventEmitter { importTypes.shift(); continue; } - + await Bluebird.delay(1000); } } @@ -568,7 +568,7 @@ class Kuzzle extends KuzzleEventEmitter { const mutex = new Mutex(`backend:import:${type}`, { timeout: 0 }); const initialized = await this.ask('core:cache:internal:get', `${BACKEND_IMPORT_KEY}:${type}`) === '1'; const locked = await mutex.lock(); - + await importMethod( { toImport, toSupport }, { @@ -584,7 +584,7 @@ class Kuzzle extends KuzzleEventEmitter { } } - + this.log.info('[✔] Waiting for imports to be finished'); await this._waitForImportToFinish(); this.log.info('[✔] Import successful'); diff --git a/lib/kuzzle/log.js b/lib/kuzzle/log.js index 948999e287..3a82ea24e9 100644 --- a/lib/kuzzle/log.js +++ b/lib/kuzzle/log.js @@ -25,6 +25,10 @@ class Logger { constructor() { this.logMethods = ['info', 'warn', 'error', 'silly', 'debug', 'verbose']; + this.failsafeModeString = global.kuzzle.config.plugins.common.failsafeMode + ? '[FAILSAFE MODE] ' + : ''; + this._useConsole(); global.kuzzle.once('core:kuzzleStart', this._useLogger.bind(this)); @@ -33,12 +37,17 @@ class Logger { _useConsole() { // until kuzzle has started, use the console to print logs for (const method of this.logMethods) { - /* eslint-disable-next-line no-console */ - this[method] = console[method] || console.log; + this[method] = message => { + /* eslint-disable-next-line no-console */ + const writer = console[method] || console.log; + + writer(`${this.failsafeModeString}${message}`); + }; } } _useLogger() { + // when kuzzle has started, use the event to dispatch logs for (const method of this.logMethods) { this[method] = message => { @@ -47,10 +56,10 @@ class Logger { ) { const request = global.kuzzle.asyncStore.get('REQUEST'); - global.kuzzle.emit(`log:${method}`, `[${global.kuzzle.id}] [${request.id}] ${message}`); + global.kuzzle.emit(`log:${method}`, `[${global.kuzzle.id}] ${this.failsafeModeString}[${request.id}] ${message}`); } else { - global.kuzzle.emit(`log:${method}`, `[${global.kuzzle.id}] ${message}`); + global.kuzzle.emit(`log:${method}`, `[${global.kuzzle.id}] ${this.failsafeModeString}${message}`); } }; } diff --git a/package-lock.json b/package-lock.json index 30579a662f..76091a84c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "kuzzle", - "version": "2.14.7", + "version": "2.14.8", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 3d9715441e..8758c66496 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kuzzle", "author": "The Kuzzle Team ", - "version": "2.14.7", + "version": "2.14.8", "description": "Kuzzle is an open-source solution that handles all the data management through a secured API, with a large choice of protocols.", "bin": { "kuzzle": "bin/start-kuzzle-server" @@ -96,7 +96,6 @@ "cucumber": "^6.0.5", "ergol": "^1.0.1", "eslint": "^7.32.0", - "js-yaml": "^4.1.0", "mocha": "^9.1.2", "mock-require": "^3.0.3", "mqtt": "^4.2.8", @@ -109,7 +108,8 @@ "sinon": "^11.1.2", "strip-json-comments": "3.1.1", "ts-node": "^10.2.1", - "typescript": "^4.4.3" + "typescript": "^4.4.3", + "yaml": "^1.10.2" }, "engines": { "node": ">= 12.13.0" diff --git a/test/api/controllers/serverController.test.js b/test/api/controllers/serverController.test.js index 5e77965829..9dc4bf40a6 100644 --- a/test/api/controllers/serverController.test.js +++ b/test/api/controllers/serverController.test.js @@ -2,7 +2,7 @@ const should = require('should'); const sinon = require('sinon'); -const yaml = require('js-yaml'); +const yaml = require('yaml'); const { Request, @@ -371,7 +371,7 @@ describe('ServerController', () => { request.input.args.format = 'yaml'; return serverController.openapi(request) .then((response) => { - const parsedResponse = yaml.load(response); + const parsedResponse = yaml.parse(response); parsedResponse.should.be.an.Object(); parsedResponse.openapi.should.be.a.String(); }); diff --git a/test/api/funnel/checkRights.test.js b/test/api/funnel/checkRights.test.js index 1901fdb684..d7b82d897e 100644 --- a/test/api/funnel/checkRights.test.js +++ b/test/api/funnel/checkRights.test.js @@ -38,6 +38,7 @@ describe('funnel.checkRights', () => { loadedUser = new User(); loadedUser._id = 'foo'; loadedUser._source = { bar: 'qux' }; + loadedUser.profileIds = ['default']; verifiedToken = new Token({ _id: 'token', @@ -57,6 +58,10 @@ describe('funnel.checkRights', () => { .resolves(loadedUser); }); + afterEach(() => { + global.kuzzle.config.plugins.common.failsafeMode = false; + }); + it('should reject with an UnauthorizedError if an anonymous user is not allowed to execute the action', async () => { verifiedToken.userId = '-1'; @@ -131,6 +136,29 @@ describe('funnel.checkRights', () => { should(kuzzle.pipe).not.calledWith('request:onUnauthorized', request); }); + it('should reject if non admin user use the API during failsafe mode', async () => { + global.kuzzle.config.plugins.common.failsafeMode = true; + sinon.stub(loadedUser, 'isActionAllowed').resolves(true); + + await should(funnel.checkRights(request)).be.rejectedWith({ + id: 'security.rights.failsafe_mode_admin_only' + }); + + should(kuzzle.pipe).not.calledWith('request:onAuthorized', request); + should(kuzzle.pipe).be.calledWith('request:onUnauthorized', request); + }); + + it('should allow admin user to use the API during failsafe mode', async () => { + global.kuzzle.config.plugins.common.failsafeMode = true; + loadedUser.profileIds = ['admin']; + sinon.stub(loadedUser, 'isActionAllowed').resolves(true); + + await funnel.checkRights(request); + + should(kuzzle.pipe).be.calledWith('request:onAuthorized', request); + should(kuzzle.pipe).not.calledWith('request:onUnauthorized', request); + }); + it('should use the token in the cookie when cookieAuth is true and internal.cookieAuthentication is true and only the cookie is present', async () => { kuzzle.config.http.cookieAuthentication = true; diff --git a/test/api/funnel/execute.test.js b/test/api/funnel/execute.test.js index 3486cb4a15..ebfe552766 100644 --- a/test/api/funnel/execute.test.js +++ b/test/api/funnel/execute.test.js @@ -224,6 +224,31 @@ describe('funnelController.execute', () => { }); }); + it('should reject limit of requests per second has been exceeded for this user.', done => { + funnel.rateLimiter.isAllowed.resolves(false); + + request = new Request({ + controller: 'auth', + action: 'login' + }, { + connection: {id: 'connectionid'}, + token: null + }); + funnel.execute(request, (err, res) => { + try { + should(res).eql(request); + should(err).be.instanceOf(TooManyRequestsError); + should(err.id).eql('api.process.too_many_logins_requests'); + should(funnel.processRequest).not.called(); + should(funnel.overloaded).be.false(); + done(); + } + catch (e) { + done(e); + } + }); + }); + it('should run the request in asyncStore.run context and set the request in async storage', done => { funnel.execute(request, (err, res) => { try { diff --git a/test/cluster/workers/IDCardRenewer.test.js b/test/cluster/workers/IDCardRenewer.test.js index 54d3d03a07..92a8952ee3 100644 --- a/test/cluster/workers/IDCardRenewer.test.js +++ b/test/cluster/workers/IDCardRenewer.test.js @@ -179,5 +179,27 @@ describe('ClusterIDCardRenewer', () => { should(idCardRenewer.redis.commands.del).not.be.called(); }); + + it('should not do anything if not initialized before calling dispose', async () => { + const clearIntervalStub = sinon.spy(global, 'clearInterval'); + idCardRenewer = new IDCardRenewer(); + + idCardRenewer.initRedis = async () => { + idCardRenewer.redis = { + commands: { + pexpire: sinon.stub().resolves(1), + del: sinon.stub().resolves(), + } + }; + }; + + should(idCardRenewer.disposed).be.true(); + + await idCardRenewer.dispose(); + + should(idCardRenewer.disposed).be.true(); + should(clearIntervalStub).not.be.called(); + + }); }); }); \ No newline at end of file diff --git a/test/core/plugin/pluginsManager.test.js b/test/core/plugin/pluginsManager.test.js index a7856bfa48..3fdbb56015 100644 --- a/test/core/plugin/pluginsManager.test.js +++ b/test/core/plugin/pluginsManager.test.js @@ -107,6 +107,21 @@ describe('Plugin', () => { pluginsManager.loadPlugins = sinon.stub().returns(new Map()); }); + it('should only load core plugins in failsafe mode', async () => { + const loggerPlugin = createPlugin('kuzzle-plugin-logger'); + const localPlugin = createPlugin('kuzzle-plugin-auth-passport-local'); + pluginsManager.loadPlugins.returns(new Map([[loggerPlugin.name, loggerPlugin], [localPlugin.name, localPlugin]])); + pluginsManager._plugins.set(plugin.name, plugin); + pluginsManager.config.common.failsafeMode = true; + + await pluginsManager.init(); + + should(pluginsManager.loadedPlugins).be.eql([ + 'kuzzle-plugin-logger', + 'kuzzle-plugin-auth-passport-local', + ]); + }); + it('should loads plugins with existing plugins', async () => { const otherPlugin = createPlugin('other-plugin'); pluginsManager.loadPlugins.returns(new Map([[otherPlugin.name, otherPlugin]])); @@ -116,6 +131,10 @@ describe('Plugin', () => { should(pluginsManager._plugins.get(plugin.name)).be.eql(plugin); should(pluginsManager._plugins.get(otherPlugin.name)).be.eql(otherPlugin); + should(pluginsManager.loadedPlugins).be.eql([ + 'other-plugin', + 'test-plugin', + ]); }); it('should registers handlers on hook events ', async () => { diff --git a/test/core/realtime/notifier/notifyMethods.test.js b/test/core/realtime/notifier/notifyMethods.test.js index 74669d1937..9b33e4a8cd 100644 --- a/test/core/realtime/notifier/notifyMethods.test.js +++ b/test/core/realtime/notifier/notifyMethods.test.js @@ -299,17 +299,7 @@ describe('notify methods', () => { should(notifier.notifyTokenExpired).calledWith('connectionId'); }); - it('should ignore non-existing rooms', async () => { - hotelClerk.customers.clear(); - - await notifier.notifyTokenExpired('foobar'); - - should(kuzzle.entryPoint.dispatch).not.be.called(); - should(kuzzle.pipe).not.be.called(); - should(hotelClerk.removeUser).not.called(); - }); - - it('should notify subscribed channels', async () => { + it('should notify on channel kuzzle:notification:server', async () => { hotelClerk.customers.set('foobar', new Map([ ['nonMatching', null], ['alwaysMatching', null], @@ -325,7 +315,7 @@ describe('notify methods', () => { should(dispatch.firstCall.args[1].connectionId).be.eql('foobar'); should(dispatch.firstCall.args[1].channels).match( - ['foobar', 'always']); + ['kuzzle:notification:server']); const notification = dispatch.firstCall.args[1].payload; diff --git a/test/mocks/kuzzle.mock.js b/test/mocks/kuzzle.mock.js index bae0824e2f..0ae4323539 100644 --- a/test/mocks/kuzzle.mock.js +++ b/test/mocks/kuzzle.mock.js @@ -133,7 +133,8 @@ class KuzzleMock extends KuzzleEventEmitter { application: { info: sinon.stub() }, - routes: [] + routes: [], + loadedPlugins: [] }; this.rootPath = '/kuzzle';