2
0
mirror of https://github.com/lowdefy/lowdefy.git synced 2025-04-24 16:00:53 +08:00

feat: License validation in server WIP.

This commit is contained in:
Sam Tolmay 2023-10-24 16:31:01 +02:00
parent f72dccf4f3
commit 521f61a186
No known key found for this signature in database
13 changed files with 261 additions and 110 deletions
packages

@ -33,7 +33,6 @@ export default {
type: 'Button',
properties: {
title: 'Go to home page',
type: 'Link',
},
events: {
onClick: [

@ -14,15 +14,24 @@
limitations under the License.
*/
import path from 'path';
import { keygenGetLicense } from '@lowdefy/node-utils';
import { keygenValidateLicense } from '@lowdefy/node-utils';
const config = {
dev: {
accountId: 'bf35f4ae-53a8-45c6-9eed-2d1fc9f53bd6',
productId: '4254760d-e760-4932-bb96-ba767e6ae780',
publicKey: 'MCowBQYDK2VwAyEAN27y1DiHiEDYFbNGjgfFdWygxrVSetq6rApWq3psJZI=',
},
prod: {
accountId: '',
productId: '',
publicKey: '',
},
};
// TODO: Name here
async function validateLicense({ context }) {
const license = await keygenGetLicense({
config: config['dev'],
offlineFilePath: path.join(context.directories.config, 'license.lic'),
});
const license = await keygenValidateLicense({ config: config['dev'] });
if (license.code == 'NO_LICENSE') {
throw new Error('License is required.'); // TODO

@ -26,7 +26,7 @@ _ref:
content: |
A Lowdefy app provides a convenient method to host __public__ files under the `/*` app route. Add content to be hosted publicly by creating a folder named `public` in the root of a Lowdefy project folder, next to the `lowdefy.yaml` file. Place the public content in this folder to host this content with your app.
All content in this folder will be publicly accessible at `{{ APP_URL }}/{{ FILE_PATH_NAME }}`. For example, the logo at the top of this page is hosted at [`https://docs.lowdefy.com/logo-light-theme.png`](http://localhost:3000/logo-light-theme.png). Sub-folders inside the public folder are supported.
All content in this folder will be publicly accessible at `{{ APP_URL }}/{{ FILE_PATH_NAME }}`. For example, the logo at the top of this page is hosted at [`https://docs.lowdefy.com/logo-light-theme.png`](http://docs.lowdefy.com/logo-light-theme.png). Sub-folders inside the public folder are supported.
By default, the `public` folder of a Lowdefy app will serve some files which most apps need:
- `apple-touch-icon.png`: A 180x180px png image file to be used as the apple PWA icon.

@ -25,7 +25,7 @@ class Blocks {
this.id = Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(0, 5);
.substring(0, 5);
this.areas = serializer.copy(areas || []);
this.arrayIndices = arrayIndices;
this.context = context;

@ -0,0 +1,34 @@
/*
Copyright (C) 2023 Lowdefy, Inc
Use of this software is governed by the Business Source License included in the LICENSE file and at www.mariadb.com/bsl11.
Change Date: 2027-10-09
On the date above, in accordance with the Business Source License, use
of this software will be governed by the Apache License, Version 2.0.
*/
import validateLicense from '../validateLicense.js';
function defaultRedirect({ url, baseUrl }) {
if (url.startsWith('/')) return `${baseUrl}${url}`;
else if (new URL(url).origin === baseUrl) return url;
return baseUrl;
}
function createLicenseRedirectCallback(context, { originalRedirect }) {
async function licenseRedirectCallback({ url, baseUrl }) {
const { redirect } = await validateLicense(context);
if (redirect) {
return '/lowdefy/license-invalid';
}
if (originalRedirect) {
return originalRedirect({ url, baseUrl });
}
return defaultRedirect({ url, baseUrl });
}
return licenseRedirectCallback;
}
export default createLicenseRedirectCallback;

@ -0,0 +1,38 @@
/*
Copyright (C) 2023 Lowdefy, Inc
Use of this software is governed by the Business Source License included in the LICENSE file and at www.mariadb.com/bsl11.
Change Date: 2027-10-09
On the date above, in accordance with the Business Source License, use
of this software will be governed by the Apache License, Version 2.0.
*/
import { keygenValidateLicense } from '@lowdefy/node-utils';
const config = {
dev: {
accountId: 'bf35f4ae-53a8-45c6-9eed-2d1fc9f53bd6',
productId: '4254760d-e760-4932-bb96-ba767e6ae780',
publicKey: 'MCowBQYDK2VwAyEAN27y1DiHiEDYFbNGjgfFdWygxrVSetq6rApWq3psJZI=',
},
prod: {
accountId: '',
productId: '',
publicKey: '',
},
};
// TODO: Handle expired
// TODO: Cache results
// TODO: Check AUTH entitlement
async function validateLicense() {
const license = await keygenValidateLicense({ config: config['dev'] });
console.log(license);
return {
redirect: license.code !== 'VALID',
};
}
export default validateLicense;

@ -1,6 +1,6 @@
{
"name": "@lowdefy/server-enterprise",
"version": "4.0.0-rc.11",
"version": "4.0.0-rc.12",
"license": "BUSL-1.1",
"description": "",
"homepage": "https://lowdefy.com",
@ -45,17 +45,17 @@
"next": "next"
},
"dependencies": {
"@lowdefy/actions-core": "4.0.0-rc.11",
"@lowdefy/api": "4.0.0-rc.11",
"@lowdefy/blocks-antd": "4.0.0-rc.11",
"@lowdefy/blocks-basic": "4.0.0-rc.11",
"@lowdefy/blocks-loaders": "4.0.0-rc.11",
"@lowdefy/client": "4.0.0-rc.11",
"@lowdefy/helpers": "4.0.0-rc.11",
"@lowdefy/layout": "4.0.0-rc.11",
"@lowdefy/node-utils": "4.0.0-rc.11",
"@lowdefy/operators-js": "4.0.0-rc.11",
"@lowdefy/plugin-next-auth": "4.0.0-rc.11",
"@lowdefy/actions-core": "4.0.0-rc.12",
"@lowdefy/api": "4.0.0-rc.12",
"@lowdefy/blocks-antd": "4.0.0-rc.12",
"@lowdefy/blocks-basic": "4.0.0-rc.12",
"@lowdefy/blocks-loaders": "4.0.0-rc.12",
"@lowdefy/client": "4.0.0-rc.12",
"@lowdefy/helpers": "4.0.0-rc.12",
"@lowdefy/layout": "4.0.0-rc.12",
"@lowdefy/node-utils": "4.0.0-rc.12",
"@lowdefy/operators-js": "4.0.0-rc.12",
"@lowdefy/plugin-next-auth": "4.0.0-rc.12",
"next": "13.5.4",
"next-auth": "4.23.2",
"pino": "8.15.6",
@ -65,7 +65,7 @@
"react-icons": "4.11.0"
},
"devDependencies": {
"@lowdefy/build": "4.0.0-rc.11",
"@lowdefy/build": "4.0.0-rc.12",
"@next/eslint-plugin-next": "13.5.4",
"less": "4.2.0",
"less-loader": "11.1.3",

@ -12,6 +12,7 @@ import NextAuth from 'next-auth';
import apiWrapper from '../../../lib/server/apiWrapper.js';
import authJson from '../../../build/auth.json';
import createLicenseRedirectCallback from '../../../lib/server/auth/createLicenseRedirectCallback.js';
async function handler({ context, req, res }) {
if (authJson.configured === true) {
@ -20,7 +21,12 @@ async function handler({ context, req, res }) {
if (req.method === 'HEAD') {
return res.status(200).end();
}
return await NextAuth(req, res, context.authOptions);
if (req.url.startsWith('/api/auth/callback')) {
context.authOptions.callbacks.redirect = createLicenseRedirectCallback(context, {
originalRedirect: context.authOptions.callbacks.redirect,
});
}
return NextAuth(req, res, context.authOptions);
}
return res.status(404).json({

@ -0,0 +1,80 @@
/*
Copyright (C) 2023 Lowdefy, Inc
Use of this software is governed by the Business Source License included in the LICENSE file and at www.mariadb.com/bsl11.
Change Date: 2027-10-09
On the date above, in accordance with the Business Source License, use
of this software will be governed by the Apache License, Version 2.0.
*/
import Page from '../../lib/client/Page.js';
// TODO: Use default blocks (basic only)
export async function getStaticProps() {
const rootConfig = {
home: {
configured: false,
pageId: '',
},
lowdefyGlobal: {},
menus: [],
};
// TODO: ~k values here?
const pageConfig = {
id: 'page:licence-invalid',
type: 'Result',
style: { minHeight: '100vh' },
properties: {
status: 'warning',
title: 'License Invalid',
subTitle:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
areas: {
extra: {
blocks: [
{
id: 'block:licence-invalid:proceed_button:0',
type: 'Button',
properties: { title: 'Proceed to App', type: 'danger' },
blockId: 'proceed_button',
events: {
onClick: {
try: [{ id: 'link', type: 'Link', params: { home: true } }],
catch: [],
},
},
},
],
},
content: {
blocks: [
{
id: 'block:licence-invalid:more_text:0',
type: 'Html',
style: { maxWidth: '600px' },
properties: {
html: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.',
},
blockId: 'more_text',
},
],
},
},
auth: { public: true },
pageId: 'licence-invalid',
blockId: 'licence-invalid',
requests: [],
};
return {
props: {
pageConfig,
rootConfig,
},
};
}
export default Page;

@ -18,8 +18,7 @@ import cleanDirectory from './cleanDirectory.js';
import copyFileOrDirectory from './copyFileOrDirectory.js';
import getFileExtension, { getFileSubExtension } from './getFileExtension.js';
import getSecretsFromEnv from './getSecretsFromEnv.js';
import keygenGetLicense from './keygenGetLicense.js';
import keygenGetLicenseFile from './keygenGetLicenseFile.js';
import keygenValidateLicense from './keygenValidateLicense.js';
import spawnProcess from './spawnProcess.js';
import readFile from './readFile.js';
import writeFile from './writeFile.js';
@ -30,8 +29,7 @@ export {
getFileExtension,
getFileSubExtension,
getSecretsFromEnv,
keygenGetLicense,
keygenGetLicenseFile,
keygenValidateLicense,
spawnProcess,
readFile,
writeFile,

@ -1,70 +0,0 @@
/*
Copyright 2020-2023 Lowdefy, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import crypto from 'crypto';
import { readFile } from '@lowdefy/node-utils';
async function getOfflineLicense({ config, offlineFilePath }) {
const licenseFileContent = await readFile(offlineFilePath);
if (!licenseFileContent) {
return null;
}
const trimmed = licenseFileContent
.replace('-----BEGIN LICENSE FILE-----\n', '')
.replace('-----END LICENSE FILE-----\n');
const parsed = JSON.parse(Buffer.from(trimmed, 'base64'));
if (!parsed.alg === 'base64+ed25519') {
throw new Error('Invalid Alg.');
}
const verifyKey = crypto.createPublicKey({
format: 'der',
type: 'spki',
key: Buffer.from(config.publicKey, 'base64'),
});
const verified = crypto.verify(
null,
`license/${parsed.enc}`,
verifyKey,
Buffer.from(parsed.sig, 'base64')
);
if (!verified) {
throw new Error('Invalid license.'); // TODO:
}
const data = JSON.parse(Buffer.from(parsed.enc, 'base64'));
// TODO: Verify account and product
// TODO: What if there is no expiry?
const expiry = data?.meta?.expiry ? new Date(data?.meta?.expiry) : undefined;
return {
id: data?.data?.id,
code: expiry && expiry.valueOf() < Date.now() ? 'EXPIRED' : 'VALID',
entitlements: (data?.included ?? [])
.filter((i) => i.type === 'entitlements')
.map((i) => i.attributes.code),
expiry: expiry,
};
}
export default getOfflineLicense;

@ -15,23 +15,14 @@
*/
import crypto from 'crypto';
import keygenGetLicenseFile from './keygenGetLicenseFile.js';
import keygenValidateLicenseOffline from './keygenValidateLicenseOffline.js';
import keygenVerifyApiSignature from './keygenVerifyApiSignature.js';
async function keygenGetLicense({ config, offlineFilePath }) {
const offline = await keygenGetLicenseFile({
config,
offlineFilePath,
});
if (offline) {
return offline;
}
async function keygenValidateLicense({ config }) {
const licenseKey = process.env.LOWDEFY_LICENSE_KEY;
let entitlements = [];
// TODO: Return this of undefined/null?
// TODO: Return this or undefined/null?
if (!licenseKey) {
return {
code: 'NO_LICENSE',
@ -39,6 +30,16 @@ async function keygenGetLicense({ config, offlineFilePath }) {
};
}
if (licenseKey.startsWith('key/')) {
const offlineLicense = await keygenValidateLicenseOffline({
config,
licenseKey,
});
if (offlineLicense.entitlements.includes('OFFLINE')) {
return offlineLicense;
}
}
const nonce = crypto.randomInt(1_000_000_000_000);
const res = await fetch(
`https://api.keygen.sh/v1/accounts/${config.accountId}/licenses/actions/validate-key`,
@ -99,4 +100,4 @@ async function keygenGetLicense({ config, offlineFilePath }) {
};
}
export default keygenGetLicense;
export default keygenValidateLicense;

@ -0,0 +1,56 @@
/*
Copyright 2020-2023 Lowdefy, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import crypto from 'crypto';
async function keygenValidateLicenseOffline({ config, licenseKey }) {
const [data, signature] = licenseKey.split('.');
const [prefix, enc] = data.split('/');
if (prefix !== 'key') {
throw new Error(`Unsupported prefix '${prefix}'`);
}
const verifyKey = crypto.createPublicKey({
format: 'der',
type: 'spki',
key: Buffer.from(config.publicKey, 'base64'),
});
const signatureBytes = Buffer.from(signature, 'base64');
const dataBytes = Buffer.from(data);
const ok = crypto.verify(null, dataBytes, verifyKey, signatureBytes);
if (!ok) {
throw new Error('TODO');
}
const decoded = JSON.parse(Buffer.from(enc, 'base64'));
const expiry = new Date(decoded.expiry);
if (decoded.product !== config.productId) {
throw new Error('Invalid license.');
}
return {
id: decoded.id,
code: expiry.valueOf() < Date.now() ? 'EXPIRED' : 'VALID',
entitlements: decoded.entitlements,
expiry: expiry,
};
}
export default keygenValidateLicenseOffline;