From b37a8be684f819159d10ba8f9eb6d5c00e09ddb2 Mon Sep 17 00:00:00 2001 From: fdel-car Date: Sun, 11 Dec 2022 02:56:14 +0100 Subject: [PATCH] Add online license check --- packages/core/admin/strapi-server.js | 2 +- packages/core/strapi/ee/index.js | 295 +++++++++++------- packages/core/strapi/lib/Strapi.js | 22 +- .../strapi/lib/commands/builders/admin.js | 2 +- .../core/strapi/lib/services/metrics/index.js | 58 +--- 5 files changed, 201 insertions(+), 178 deletions(-) diff --git a/packages/core/admin/strapi-server.js b/packages/core/admin/strapi-server.js index 988bed043d..a2e849f593 100644 --- a/packages/core/admin/strapi-server.js +++ b/packages/core/admin/strapi-server.js @@ -8,7 +8,7 @@ const mergeRoutes = (a, b, key) => { return _.isArray(a) && _.isArray(b) && key === 'routes' ? a.concat(b) : undefined; }; -if (process.env.STRAPI_DISABLE_EE !== 'true' && strapi.EE) { +if (strapi.EE) { const eeAdmin = require('./ee/strapi-server'); module.exports = _.mergeWith({}, admin, eeAdmin, mergeRoutes); diff --git a/packages/core/strapi/ee/index.js b/packages/core/strapi/ee/index.js index 930ffb1b83..261ec7a8f6 100644 --- a/packages/core/strapi/ee/index.js +++ b/packages/core/strapi/ee/index.js @@ -1,132 +1,211 @@ 'use strict'; -const path = require('path'); const fs = require('fs'); +const { join } = require('path'); const crypto = require('crypto'); -const _ = require('lodash'); +const fetch = require('node-fetch'); +const { pick } = require('lodash/fp'); -const publicKey = fs.readFileSync(path.join(__dirname, 'resources/key.pub')); +const { coreStoreModel } = require('../lib/services/core-store'); -const noop = () => {}; +const publicKey = fs.readFileSync(join(__dirname, 'resources/key.pub')); -const noLog = { - warn: noop, - info: noop, +const ee = { + enabled: false, + licenseInfo: {}, +}; + +const disable = (message = 'Invalid license. Starting in CE.') => { + ee.logger?.warn(message); + // Only keep the license key for potential re-enabling during a later check + ee.licenseInfo = pick('licenseKey', ee.licenseInfo); + ee.enabled = false; +}; + +const readLicense = (directory) => { + try { + const path = join(directory, 'license.txt'); + return fs.readFileSync(path).toString(); + } catch (error) { + if (error.code !== 'ENOENT') { + // Permission denied, directory found instead of file, etc. + } + } +}; + +const fetchLicense = async (key, fallback) => { + try { + const response = await fetch(`https://license.strapi.io/api/licenses/${key}`); + + if (response.status !== 200) { + disable(); + return null; + } + + return response.text(); + } catch (error) { + if (error instanceof fetch.FetchError) { + if (fallback) { + ee.logger( + 'Could not proceed to the online verification of your license. We will try to use your locally stored one as a potential fallback.' + ); + return fallback; + } + + disable( + 'Could not proceed to the online verification of your license, sorry for the inconvenience. Starting in CE.' + ); + } + + return null; + } +}; + +const verifyLicense = (license) => { + const [signature, base64Content] = Buffer.from(license, 'base64').toString().split('\n'); + const stringifiedContent = Buffer.from(base64Content, 'base64').toString(); + + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(stringifiedContent); + verifier.end(); + + const verified = verifier.verify(publicKey, signature, 'base64'); + + return { verified, licenseInfo: verified ? JSON.parse(stringifiedContent) : null }; +}; + +let initialized = false; + +const init = (licenseDir, logger) => { + // Can only be executed once, to prevent any abuse of the optimistic behavior + if (initialized) { + return; + } + + initialized = true; + ee.logger = logger; + + if (process.env.STRAPI_DISABLE_EE?.toLowerCase() === 'true') { + return; + } + + const license = process.env.STRAPI_LICENSE || readLicense(licenseDir); + + if (license) { + const { verified, licenseInfo } = verifyLicense(license); + + if (verified) { + ee.enabled = true; // Optimistically enable EE during initialization + ee.licenseInfo = licenseInfo; + } else { + return disable(); + } + } +}; + +const oneMinute = 1000 * 60; + +const onlineUpdate = async (db) => { + const transaction = await db.transaction(); + + try { + // TODO: Use the core store interface instead, it does not support transactions and "FOR UPDATE" at the moment + const eeInfo = await db + .queryBuilder(coreStoreModel.uid) + .where({ key: 'ee_information' }) + .select('value') + .first() + .transacting(transaction) + .forUpdate() + .execute() + .then((result) => (result ? JSON.parse(result.value) : result)); + + const useStoredLicense = eeInfo?.lastOnlineCheck > Date.now() - oneMinute; + const license = useStoredLicense + ? eeInfo.license + : await fetchLicense(ee.licenseInfo.licenseKey, eeInfo?.license); + + if (license) { + const { verified, licenseInfo } = verifyLicense(license); + + if (verified) { + ee.licenseInfo = licenseInfo; + } else { + disable(); + } + } + + if (!useStoredLicense) { + const value = { license, lastOnlineCheck: Date.now() }; + const query = db.queryBuilder(coreStoreModel.uid).transacting(transaction); + + if (!eeInfo) { + query.insert({ key: 'ee_information', value: JSON.stringify(value), type: typeof value }); + } else { + query.update({ value: JSON.stringify(value) }).where({ key: 'ee_information' }); + } + + await query.execute(); + } else if (!license) { + disable(); + } + + await transaction.commit(); + } catch (error) { + // TODO: The database can be locked at the time of writing, could just a SQLite issue only + await transaction.rollback(); + return disable(error.message); + } }; -const internals = {}; const defaultFeatures = { bronze: [], silver: [], gold: ['sso'], }; -const EEService = ({ dir, logger = noLog }) => { - if (_.has(internals, 'isEE')) return internals.isEE; - - const warnAndReturn = (msg = 'Invalid license. Starting in CE.') => { - logger.warn(msg); - internals.isEE = false; - return false; - }; - - if (process.env.STRAPI_DISABLE_EE === 'true') { - internals.isEE = false; - return false; +const validateInfo = () => { + if (ee.licenseInfo.expireAt) { + return; } - const licensePath = path.join(dir, 'license.txt'); + const expirationTime = new Date(ee.licenseInfo.expireAt).getTime(); - let license; - if (_.has(process.env, 'STRAPI_LICENSE')) { - license = process.env.STRAPI_LICENSE; - } else if (fs.existsSync(licensePath)) { - license = fs.readFileSync(licensePath).toString(); + if (expirationTime < new Date().getTime()) { + return disable('License expired. Starting in CE.'); } - if (_.isNil(license)) { - internals.isEE = false; - return false; - } + ee.enabled = true; - // TODO: optimistically return true if license key is valid - - try { - const plainLicense = Buffer.from(license, 'base64').toString(); - const [signatureb64, contentb64] = plainLicense.split('\n'); - - const signature = Buffer.from(signatureb64, 'base64'); - const content = Buffer.from(contentb64, 'base64').toString(); - - const verifier = crypto.createVerify('RSA-SHA256'); - verifier.update(content); - verifier.end(); - - const isValid = verifier.verify(publicKey, signature); - if (!isValid) return warnAndReturn(); - - internals.licenseInfo = JSON.parse(content); - internals.licenseInfo.features = - internals.licenseInfo.features || defaultFeatures[internals.licenseInfo.type]; - - const expirationTime = new Date(internals.licenseInfo.expireAt).getTime(); - if (expirationTime < new Date().getTime()) { - return warnAndReturn('License expired. Starting in CE'); - } - } catch (err) { - return warnAndReturn(); - } - - internals.isEE = true; - return true; -}; - -EEService.checkLicense = async () => { - // TODO: online / DB check of the license info - // TODO: refresh info if the DB info is outdated - // TODO: register cron - // internals.licenseInfo = await db.getLicense(); -}; - -Object.defineProperty(EEService, 'licenseInfo', { - get() { - mustHaveKey('licenseInfo'); - return internals.licenseInfo; - }, - configurable: false, - enumerable: false, -}); - -Object.defineProperty(EEService, 'isEE', { - get() { - mustHaveKey('isEE'); - return internals.isEE; - }, - configurable: false, - enumerable: false, -}); - -Object.defineProperty(EEService, 'features', { - get() { - return { - isEnabled(feature) { - return internals.licenseInfo.features.includes(feature); - }, - getEnabled() { - return internals.licenseInfo.features; - }, - }; - }, - configurable: false, - enumerable: false, -}); - -const mustHaveKey = (key) => { - if (!_.has(internals, key)) { - const err = new Error('Tampering with license'); - // err.stack = null; - throw err; + if (!ee.licenseInfo.features) { + ee.licenseInfo.features = defaultFeatures[ee.licenseInfo.type]; } }; -module.exports = EEService; +const shouldStayOffline = process.env.STRAPI_DISABLE_LICENSE_PING?.toLowerCase() === 'true'; + +const checkLicense = async (db) => { + if (!shouldStayOffline) { + await onlineUpdate(db); + // TODO: Register cron, try to spread it out across projects to avoid regular request spikes + } else if (!ee.licenseInfo.expireAt) { + return disable('Your license does not have offline support. Starting in CE.'); + } + + if (ee.enabled) { + validateInfo(); + } +}; + +module.exports = { + init, + disable, + features: { + isEnabled: (feature) => (ee.enabled && ee.licenseInfo.features?.includes(feature)) || false, + getEnabled: () => (ee.enabled && Object.freeze(ee.licenseInfo.features)) || [], + }, + checkLicense, + get isEE() { + return ee.enabled; + }, +}; diff --git a/packages/core/strapi/lib/Strapi.js b/packages/core/strapi/lib/Strapi.js index 6b7e58b188..76ab1fde8b 100644 --- a/packages/core/strapi/lib/Strapi.js +++ b/packages/core/strapi/lib/Strapi.js @@ -126,6 +126,10 @@ class Strapi { return this.container.get('config'); } + get EE() { + return ee.isEE; + } + get services() { return this.container.get('services').getAll(); } @@ -365,7 +369,7 @@ class Strapi { } async register() { - await this.loadEE(); + ee.init(this.dirs.app.root, this.log); await Promise.all([ this.loadApp(), @@ -445,6 +449,10 @@ class Strapi { await this.db.schema.sync(); + if (this.EE) { + await ee.checkLicense(this.db); + } + await this.hook('strapi::content-types.afterSync').call({ oldContentTypes, contentTypes: strapi.contentTypes, @@ -457,8 +465,6 @@ class Strapi { value: strapi.contentTypes, }); - await ee.checkLicense(); - await this.startWebhooks(); await this.server.initMiddlewares(); @@ -473,16 +479,6 @@ class Strapi { return this; } - loadEE() { - Object.defineProperty(this, 'EE', { - get() { - return ee({ dir: this.dirs.app.root, logger: this.log }); - }, - configurable: false, - enumerable: false, - }); - } - async load() { await this.register(); await this.bootstrap(); diff --git a/packages/core/strapi/lib/commands/builders/admin.js b/packages/core/strapi/lib/commands/builders/admin.js index fb3b5d9b53..be60319e0d 100644 --- a/packages/core/strapi/lib/commands/builders/admin.js +++ b/packages/core/strapi/lib/commands/builders/admin.js @@ -30,7 +30,7 @@ module.exports = async ({ buildDestDir, forceBuild = true, optimization, srcDir // Always remove the .cache and build folders await strapiAdmin.clean({ appDir: srcDir, buildDestDir }); - ee({ dir: srcDir }); + ee.init(srcDir); return strapiAdmin .build({ diff --git a/packages/core/strapi/lib/services/metrics/index.js b/packages/core/strapi/lib/services/metrics/index.js index 32ad131ca3..46094dfafb 100644 --- a/packages/core/strapi/lib/services/metrics/index.js +++ b/packages/core/strapi/lib/services/metrics/index.js @@ -5,12 +5,8 @@ * You can learn more at https://docs.strapi.io/developer-docs/latest/getting-started/usage-information.html */ -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); const { scheduleJob } = require('node-schedule'); -const ee = require('../../utils/ee'); const wrapWithRateLimit = require('./rate-limiter'); const createSender = require('./sender'); const createMiddleware = require('./middleware'); @@ -46,42 +42,14 @@ const createTelemetryInstance = (strapi) => { strapi.server.use(createMiddleware({ sendEvent })); } }, - bootstrap() { - // TODO: remove - if (strapi.EE === true && ee.isEE === true) { - const pingDisabled = - isTruthy(process.env.STRAPI_LICENSE_PING_DISABLED) && ee.licenseInfo.type === 'gold'; - const sendLicenseCheck = () => { - return sendEvent( - 'didCheckLicense', - { - groupProperties: { - licenseInfo: { - ...ee.licenseInfo, - projectHash: hashProject(strapi), - dependencyHash: hashDep(strapi), - }, - }, - }, - { - headers: { 'x-strapi-project': 'enterprise' }, - } - ); - }; + bootstrap() {}, - if (!pingDisabled) { - const licenseCron = scheduleJob('0 0 0 * * 7', () => sendLicenseCheck()); - crons.push(licenseCron); - - sendLicenseCheck(); - } - } - }, destroy() { - // clear open handles + // Clear open handles crons.forEach((cron) => cron.cancel()); }, + async send(event, payload) { if (isDisabled) return true; return sendEvent(event, payload); @@ -89,24 +57,4 @@ const createTelemetryInstance = (strapi) => { }; }; -const hash = (str) => crypto.createHash('sha256').update(str).digest('hex'); - -const hashProject = (strapi) => - hash(`${strapi.config.get('info.name')}${strapi.config.get('info.description')}`); - -const hashDep = (strapi) => { - const depStr = JSON.stringify(strapi.config.info.dependencies); - const readmePath = path.join(strapi.dirs.app.root, 'README.md'); - - try { - if (fs.existsSync(readmePath)) { - return hash(`${depStr}${fs.readFileSync(readmePath)}`); - } - } catch (err) { - return hash(`${depStr}`); - } - - return hash(`${depStr}`); -}; - module.exports = createTelemetryInstance;