Add online license check

This commit is contained in:
fdel-car 2022-12-11 02:56:14 +01:00
parent 5a2b379fa0
commit b37a8be684
5 changed files with 201 additions and 178 deletions

View File

@ -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);

View File

@ -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;
},
};

View File

@ -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();

View File

@ -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({

View File

@ -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;