diff --git a/.ci/scripts/run-test-cluster.sh b/.ci/scripts/run-test-cluster.sh index cdd7a6059c..ee9ee1cae2 100755 --- a/.ci/scripts/run-test-cluster.sh +++ b/.ci/scripts/run-test-cluster.sh @@ -37,10 +37,10 @@ trap 'docker compose -f $YML_FILE logs' err docker compose -f $YML_FILE up -d -# don't wait on 7512: nginx will accept connections far before Kuzzle does KUZZLE_PORT=17510 ./bin/wait-kuzzle KUZZLE_PORT=17511 ./bin/wait-kuzzle KUZZLE_PORT=17512 ./bin/wait-kuzzle +KUZZLE_PORT=7512 ./bin/wait-kuzzle trap - err diff --git a/.eslintrc.json b/.eslintrc.json index fb9dbb8b5c..caf7da7f0d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,9 @@ { "plugins": ["kuzzle"], "extends": ["plugin:kuzzle/default", "plugin:kuzzle/node"], + "parserOptions": { + "ecmaVersion": 2020 + }, "rules": { "sort-keys": "warn", "kuzzle/array-foreach": "warn" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e9848cf81..f028d435ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,5 +123,5 @@ npm run test:unit ### Functional tests ```bash -KUZZLE_FUNCTIONAL_TESTS="test:functional:websocket" NODE_VERSION="20" ./.ci/scripts/run-test-cluster.sh +KUZZLE_FUNCTIONAL_TESTS="test:functional:websocket" NODE_VERSION="20" ES_VERSION=8 ./.ci/scripts/run-test-cluster.sh ``` diff --git a/doc/2/guides/getting-started/deploy-your-application/index.md b/doc/2/guides/getting-started/deploy-your-application/index.md index 48b44c29a0..b1b5c275ca 100644 --- a/doc/2/guides/getting-started/deploy-your-application/index.md +++ b/doc/2/guides/getting-started/deploy-your-application/index.md @@ -48,6 +48,26 @@ This deployment does not use any SSL encryption (HTTPS). A production deployment must include a reverse proxy to securize the connection with SSL. ::: +::: warning +# Authentication Security in Production + +## ⚠️ Important Security Requirement + +You must set the `kuzzle_security__authToken__secret` environment variable before deploying Kuzzle to production. This secret is used to sign and verify JSON Web Tokens (JWTs) for user authentication. + +## Why This Matters +- Prevents tokens from being stored in Elasticsearch +- Improves overall security +- Gives you direct control over token management + +## Security Notes +1. **Fallback Warning**: If you don't set this variable, Kuzzle will use a less secure fallback method (not recommended for production) +2. **Token Invalidation**: Changing the secret value will immediately invalidate all existing authentication tokens +3. **User Impact**: Users will need to log in again if the secret changes + +## Additional Resources +For other configuration options, see the [sample configuration file](https://github.com/kuzzleio/kuzzle/blob/master/.kuzzlerc.sample.jsonc). +::: ## Prepare our Docker Compose deployment We are going to write a `docker-compose.yml` file that describes our services. diff --git a/lib/core/security/tokenRepository.ts b/lib/core/security/tokenRepository.ts index 9c9c1fdeb0..05f2868c63 100644 --- a/lib/core/security/tokenRepository.ts +++ b/lib/core/security/tokenRepository.ts @@ -30,8 +30,8 @@ import { Token } from "../../model/security/token"; import { User } from "../../model/security/user"; import ApiKey from "../../model/storage/apiKey"; import debugFactory from "../../util/debug"; -import { Mutex } from "../../util/mutex"; import { ObjectRepository } from "../shared/ObjectRepository"; +import { sha256 } from "../../util/crypto"; const securityError = kerror.wrap("security", "token"); const debug = debugFactory("kuzzle:bootstrap:tokens"); @@ -61,8 +61,6 @@ export class TokenRepository extends ObjectRepository { } async init() { - await this.loadApiKeys(); - /** * Assign an existing token to a user. Stores the token in Kuzzle's cache. * @param {String} hash - JWT @@ -136,6 +134,24 @@ export class TokenRepository extends ObjectRepository { global.kuzzle.onAsk("core:security:token:verify", (hash) => this.verifyToken(hash), ); + + // ? those checks are necessary to detect JWT seed changes and delete existing tokens if necessary + const existingTokens = await global.kuzzle.ask( + "core:cache:internal:searchKeys", + "repos/kuzzle/token/*", + ); + + if (existingTokens.length > 0) { + try { + const [, token] = existingTokens[0].split("#"); + await this.verifyToken(token); + } catch (e) { + // ? seed has changed + if (e.id === "security.token.invalid") { + await global.kuzzle.ask("core:cache:internal:del", existingTokens); + } + } + } } /** @@ -189,8 +205,10 @@ export class TokenRepository extends ObjectRepository { async generateToken( user: User, { - algorithm = global.kuzzle.config.security.jwt.algorithm, - expiresIn = global.kuzzle.config.security.jwt.expiresIn, + algorithm = global.kuzzle.config.security.authToken.algorithm ?? + global.kuzzle.config.security.jwt.algorithm, + expiresIn = global.kuzzle.config.security.authToken.expiresIn ?? + global.kuzzle.config.security.jwt.expiresIn, bypassMaxTTL = false, type = "authToken", singleUse = false, @@ -211,7 +229,8 @@ export class TokenRepository extends ObjectRepository { const maxTTL = type === "apiKey" ? global.kuzzle.config.security.apiKey.maxTTL - : global.kuzzle.config.security.jwt.maxTTL; + : global.kuzzle.config.security.authToken.maxTTL ?? + global.kuzzle.config.security.jwt.maxTTL; if ( !bypassMaxTTL && @@ -251,10 +270,21 @@ export class TokenRepository extends ObjectRepository { if (type === "apiKey") { encodedToken = Token.APIKEY_PREFIX + encodedToken; - } else { - encodedToken = Token.AUTH_PREFIX + encodedToken; + + // For API keys, we don't persist the token + const expiresAt = + parsedExpiresIn === -1 ? -1 : Date.now() + parsedExpiresIn; + return new Token({ + _id: `${user._id}#${encodedToken}`, + expiresAt, + jwt: encodedToken, + ttl: parsedExpiresIn, + userId: user._id, + }); } + encodedToken = Token.AUTH_PREFIX + encodedToken; + // Persist regular tokens return this.persistForUser(encodedToken, user._id, { singleUse, ttl: parsedExpiresIn, @@ -308,28 +338,34 @@ export class TokenRepository extends ObjectRepository { return this.anonymousToken; } + const isApiKey = token.startsWith(Token.APIKEY_PREFIX); + const tokenWithoutPrefix = this.removeTokenPrefix(token); + let decoded = null; try { - decoded = jwt.verify(this.removeTokenPrefix(token), global.kuzzle.secret); - + decoded = jwt.verify(tokenWithoutPrefix, global.kuzzle.secret); // probably forged token => throw without providing any information if (!decoded._id) { throw new jwt.JsonWebTokenError("Invalid token"); } } catch (err) { - if (err instanceof jwt.TokenExpiredError) { - throw securityError.get("expired"); - } - if (err instanceof jwt.JsonWebTokenError) { throw securityError.get("invalid"); } + if (err instanceof jwt.TokenExpiredError) { + throw securityError.get("expired"); + } + throw securityError.getFrom(err, "verification_error", err.message); } - let userToken: Token; + if (isApiKey) { + return this._verifyApiKey(decoded, token); + } + + let userToken; try { userToken = await this.loadForUser(decoded._id, token); @@ -337,7 +373,6 @@ export class TokenRepository extends ObjectRepository { if (err instanceof UnauthorizedError) { throw err; } - throw securityError.getFrom(err, "verification_error", err.message); } @@ -352,6 +387,38 @@ export class TokenRepository extends ObjectRepository { return userToken; } + async _verifyApiKey(decoded, token: string) { + const fingerprint = sha256(token); + + const userApiKeys = await ApiKey.search({ + query: { + term: { + userId: decoded._id, + }, + }, + }); + + const targetApiKey = userApiKeys?.find( + (apiKey) => apiKey.fingerprint === fingerprint, + ); + + if (!targetApiKey) { + throw securityError.get("invalid"); + } + + const apiKey = await ApiKey.load(decoded._id, targetApiKey._id); + + const userToken = new Token({ + _id: `${decoded._id}#${token}`, + expiresAt: apiKey.expiresAt, + jwt: token, + ttl: apiKey.ttl, + userId: decoded._id, + }); + + return userToken; + } + removeTokenPrefix(token: string) { return token .replace(Token.AUTH_PREFIX, "") @@ -436,55 +503,6 @@ export class TokenRepository extends ObjectRepository { await Promise.all(promises); } - /** - * Loads authentication token from API key into Redis - */ - private async loadApiKeys() { - const mutex = new Mutex("ApiKeysBootstrap", { - timeout: -1, - ttl: 30000, - }); - - await mutex.lock(); - - try { - const bootstrapped = await global.kuzzle.ask( - "core:cache:internal:get", - BOOTSTRAP_DONE_KEY, - ); - - if (bootstrapped) { - debug("API keys already in cache. Skip."); - return; - } - - debug("Loading API keys into Redis"); - - const promises = []; - - await ApiKey.batchExecute({ match_all: {} }, (documents) => { - for (const { _source } of documents) { - promises.push( - this.persistForUser(_source.token, _source.userId, { - singleUse: false, - ttl: _source.ttl, - }), - ); - } - }); - - await Promise.all(promises); - - await global.kuzzle.ask( - "core:cache:internal:store", - BOOTSTRAP_DONE_KEY, - 1, - ); - } finally { - await mutex.unlock(); - } - } - /** * The repository main class refreshes automatically the TTL * of accessed entries, letting only unaccessed entries expire diff --git a/lib/kuzzle/internalIndexHandler.js b/lib/kuzzle/internalIndexHandler.js index e944284f86..1b1e52d3b0 100644 --- a/lib/kuzzle/internalIndexHandler.js +++ b/lib/kuzzle/internalIndexHandler.js @@ -111,6 +111,7 @@ class InternalIndexHandler extends Store { const bootstrapped = await this.exists("config", this._BOOTSTRAP_DONE_ID); if (bootstrapped) { + await this._initSecret(); return; } @@ -150,7 +151,7 @@ class InternalIndexHandler extends Store { await this.createInitialValidations(); debug("Bootstrapping JWT secret"); - await this._persistSecret(); + await this._initSecret(); // Create datamodel version await this.create( @@ -202,24 +203,29 @@ class InternalIndexHandler extends Store { await Bluebird.all(promises); } - async getSecret() { - const response = await this.get("config", this._JWT_SECRET_ID); + async _initSecret() { + const { authToken, jwt } = global.kuzzle.config.security; + const configSeed = authToken?.secret ?? jwt?.secret; - return response._source.seed; - } + let storedSeed = await this.exists("config", this._JWT_SECRET_ID); - async _persistSecret() { - const seed = - global.kuzzle.config.security.jwt.secret || - crypto.randomBytes(512).toString("hex"); + if (!configSeed) { + if (!storedSeed) { + storedSeed = crypto.randomBytes(512).toString("hex"); + await this.create( + "config", + { seed: storedSeed }, + { id: this._JWT_SECRET_ID }, + ); + } - await this.create( - "config", - { seed }, - { - id: this._JWT_SECRET_ID, - }, - ); + global.kuzzle.log.warn( + "[!] Kuzzle is using a generated seed for authentication. This is suitable for development but should NEVER be used in production. See https://docs.kuzzle.io/core/2/guides/getting-started/deploy-your-application/", + ); + } + global.kuzzle.secret = configSeed + ? configSeed + : (await this.get("config", this._JWT_SECRET_ID))._source.seed; } } diff --git a/lib/kuzzle/kuzzle.ts b/lib/kuzzle/kuzzle.ts index 683c1701ae..937b45d6a8 100644 --- a/lib/kuzzle/kuzzle.ts +++ b/lib/kuzzle/kuzzle.ts @@ -259,9 +259,6 @@ class Kuzzle extends KuzzleEventEmitter { // This will init the cluster module if enabled this.id = await this.initKuzzleNode(); - // Secret used to generate JWTs - this.secret = await this.internalIndex.getSecret(); - this.vault = vault.load(options.vaultKey, options.secretsFile); await this.validation.init(); diff --git a/lib/model/storage/apiKey.js b/lib/model/storage/apiKey.js index 108d17e336..707102e56e 100644 --- a/lib/model/storage/apiKey.js +++ b/lib/model/storage/apiKey.js @@ -49,7 +49,7 @@ class ApiKey extends BaseModel { serialize({ includeToken = false } = {}) { const serialized = super.serialize(); - if (!includeToken) { + if (!includeToken && this.token) { delete serialized._source.token; } @@ -107,15 +107,15 @@ class ApiKey extends BaseModel { description, expiresAt: token.expiresAt, fingerprint, - token: token.jwt, ttl: token.ttl, userId: user._id, }, apiKeyId || fingerprint, ); - await apiKey.save({ refresh, userId: creatorId }); + apiKey.token = token.jwt; + return apiKey; } diff --git a/lib/model/storage/baseModel.js b/lib/model/storage/baseModel.js index bb0ce0a49d..97e014593b 100644 --- a/lib/model/storage/baseModel.js +++ b/lib/model/storage/baseModel.js @@ -218,7 +218,6 @@ class BaseModel { searchBody, options, ); - return resp.hits.map((hit) => this._instantiateFromDb(hit)); } @@ -232,7 +231,7 @@ class BaseModel { static truncate({ refresh } = {}) { return this.deleteByQuery({ match_all: {} }, { refresh }); } - + // ? This looks not in use anymore ? static batchExecute(query, callback) { return global.kuzzle.internalIndex.mExecute( this.collection, diff --git a/test/kuzzle/internalIndexHandler.test.js b/test/kuzzle/internalIndexHandler.test.js index 1b68a85e52..ae6080bb9e 100644 --- a/test/kuzzle/internalIndexHandler.test.js +++ b/test/kuzzle/internalIndexHandler.test.js @@ -52,6 +52,8 @@ describe("#kuzzle/InternalIndexHandler", () => { internalIndexHandler = new InternalIndexHandler(); + sinon.stub(internalIndexHandler, "_initSecret").resolves(); + await internalIndexHandler.init(); should(kuzzle.ask).calledWith( @@ -113,9 +115,11 @@ describe("#kuzzle/InternalIndexHandler", () => { kuzzle.ask.withArgs("core:storage:private:document:exist").resolves(true); sinon.stub(internalIndexHandler, "_bootstrapSequence").resolves(); + sinon.stub(internalIndexHandler, "_initSecret").resolves(); await internalIndexHandler.init(); + should(internalIndexHandler._initSecret).called(); should(internalIndexHandler._bootstrapSequence).not.called(); should(kuzzle.ask).not.calledWith( @@ -174,13 +178,13 @@ describe("#kuzzle/InternalIndexHandler", () => { it("should trigger a complete bootstrap of the internal structures", async () => { sinon.stub(internalIndexHandler, "createInitialSecurities"); sinon.stub(internalIndexHandler, "createInitialValidations"); - sinon.stub(internalIndexHandler, "_persistSecret"); + sinon.stub(internalIndexHandler, "_initSecret"); await internalIndexHandler._bootstrapSequence(); should(internalIndexHandler.createInitialSecurities).called(); should(internalIndexHandler.createInitialValidations).called(); - should(internalIndexHandler._persistSecret).called(); + should(internalIndexHandler._initSecret).called(); should(kuzzle.ask).calledWith( "core:storage:private:document:create", @@ -309,7 +313,7 @@ describe("#kuzzle/InternalIndexHandler", () => { }); }); - describe("#_persistSecret", () => { + describe("#_initSecret", () => { const randomBytesMock = sinon.stub().returns(Buffer.from("12345")); before(() => { @@ -332,10 +336,22 @@ describe("#kuzzle/InternalIndexHandler", () => { it("should use the configured seed, if one is present", async () => { kuzzle.config.security.jwt.secret = "foobar"; + kuzzle.ask + .withArgs( + "core:storage:private:document:get", + internalIndexName, + "config", + internalIndexHandler._JWT_SECRET_ID, + ) + .resolves({ + _source: { + seed: "foobar", + }, + }); - await internalIndexHandler._persistSecret(); + await internalIndexHandler._initSecret(); - should(kuzzle.ask).calledWith( + should(kuzzle.ask).not.calledWith( "core:storage:private:document:create", internalIndexName, "config", @@ -347,7 +363,35 @@ describe("#kuzzle/InternalIndexHandler", () => { }); it("should auto-generate a new random seed if none is present in the config file", async () => { - await internalIndexHandler._persistSecret(); + kuzzle.ask + .withArgs( + "core:storage:private:document:exists", + internalIndexName, + "config", + internalIndexHandler._JWT_SECRET_ID, + ) + .resolves(false); + kuzzle.ask + .withArgs( + "core:storage:private:document:create", + internalIndexName, + "config", + internalIndexHandler._JWT_SECRET_ID, + ) + .resolves(); + kuzzle.ask + .withArgs( + "core:storage:private:document:get", + internalIndexName, + "config", + internalIndexHandler._JWT_SECRET_ID, + ) + .resolves({ + _source: { + seed: randomBytesMock().toString("hex"), + }, + }); + await internalIndexHandler._initSecret(); should(kuzzle.ask).calledWith( "core:storage:private:document:create", @@ -365,26 +409,7 @@ describe("#kuzzle/InternalIndexHandler", () => { kuzzle.ask.withArgs("core:storage:private:document:create").rejects(err); - return should(internalIndexHandler._persistSecret()).rejectedWith(err); - }); - }); - - describe("#getSecret", () => { - it("should fetch the secret seed from the storage space", async () => { - kuzzle.ask.withArgs("core:storage:private:document:get").resolves({ - _source: { - seed: "foobar", - }, - }); - - await should(internalIndexHandler.getSecret()).fulfilledWith("foobar"); - - should(kuzzle.ask).calledWith( - "core:storage:private:document:get", - internalIndexName, - "config", - internalIndexHandler._JWT_SECRET_ID, - ); + return should(internalIndexHandler._initSecret()).rejectedWith(err); }); }); }); diff --git a/test/mocks/internalIndexHandler.mock.js b/test/mocks/internalIndexHandler.mock.js index dd27ca740a..9b75fb023a 100644 --- a/test/mocks/internalIndexHandler.mock.js +++ b/test/mocks/internalIndexHandler.mock.js @@ -11,7 +11,6 @@ class InternalIndexHandlerMock extends InternalIndexHandler { sinon.stub(this, "init"); sinon.stub(this, "createInitialSecurities").resolves(); sinon.stub(this, "createInitialValidations").resolves(); - sinon.stub(this, "getSecret").resolves(); } } diff --git a/test/model/storage/apiKey.test.js b/test/model/storage/apiKey.test.js index ffcc9b876c..4eec5a0807 100644 --- a/test/model/storage/apiKey.test.js +++ b/test/model/storage/apiKey.test.js @@ -121,7 +121,6 @@ describe("ApiKey", () => { .resolves({ userId: "mylehuong" }); const promise = ApiKey.load("aschen", "api-key-id"); - await should(promise).be.rejectedWith({ id: "services.storage.not_found", });