1 个不稳定版本
0.1.0 | 2024 年 6 月 18 日 |
---|
#497 in 加密学
87KB
1.5K SLoC
Tauri 插件 keygen
此插件帮助您使用 Keygen 许可证 API 在您的 Tauri 桌面应用程序中实现计时许可证(带试用版)和基于功能的许可证。
它处理许可证验证请求,验证响应签名,缓存有效响应,并管理离线许可证的机器文件。
许可证状态由 Tauri 应用程序状态(Rust 后端)管理,并且可以通过前端中的 JavaScript Guest 绑定进行访问。
📖 目录
📺 视频教程
⬇️ 安装
此插件是为 Tauri v1 制作的。但是,它将在 Tauri v2 稳定版发布时更新。
🦀 将以下行添加到 src-tauri/cargo.toml
以安装核心插件
[dependencies]
tauri-plugin-keygen = { git = "https://github.com/bagindo/tauri-plugin-keygen", branch = "main" }
👾 安装 JavaScript Guest 绑定
npm add https://github.com/bagindo/tauri-plugin-keygen#main
🔌 设置
首先,注册免费账户 并获取您的 Keygen 账户 ID 和 Keygen Verify Key。
然后,将它们添加到 src-tauri/src/main.rs
中的插件构建器。
fn main() {
tauri::Builder::default()
// register plugin
.plugin(
tauri_plugin_keygen::Builder::new(
"17905469-e476-49c1-eeee-3d60e99dc590", // 👈 Keygen Account ID
"1e1e411ee29eee8e85ee460ee268921ee6283ee625eee20f5e6e6113e4ee2739", // 👈 Keygen (Public) Verify Key
)
.build(),
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
⚙️ 自定义配置
您可以选择将自定义配置指定给插件构建器。
fn main() {
tauri::Builder::default()
// register plugin
.plugin(
tauri_plugin_keygen::Builder::new(
"17905469-e476-49c1-eeee-3d60e99dc590", // 👈 Keygen Account ID
"1e1e411ee29eee8e85ee460ee268921ee6283ee625eee20f5e6e6113e4ee2739", // 👈 Keygen (Public) Verify Key
)
// chain custom config as needed
.api_url("https:://licensing.myapp.com") // 👈 Self-hosted Keygen API url
.version_header("1.7") // 👈 add Keygen-Version on request header
.cache_lifetime(1440) // 👈 Response cache lifetime in minutes
.build(),
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
配置 | 默认 | 描述 |
---|---|---|
api_url | https://api.keygen.sh |
Keygen API 基础 URL。 此配置在您使用 Keygen 自托管 时很有用。 目录路径的尾部
|
version_header | None |
Keygen 将您使用的 API 版本 锁定 到您的账户。
此配置在您在 Keygen 仪表板更改账户的 API 版本(例如,从 1.3 更改为 1.7)之前测试一切是否仍然正常很有用。 不要添加版本字符串前缀
|
缓存存活时间 | 240 |
缓存验证响应允许的存活时间,以分钟为单位。 最小 60 分钟,最大 1440 分钟(24 小时)。 ℹ️ 缓存以今天日期( 因此,最大存活时间实际上不会是完整的 1440 分钟,因为今天的缓存不会在午夜(第二天)加载。 为了获得更长的离线许可功能,您应该使用 |
🌐 with_custom_domain
如果您使用 Keygen 自定义域名,则无需指定您的 Keygen 账户 ID。
fn main() {
tauri::Builder::default()
// register plugin
.plugin(
tauri_plugin_keygen::Builder::with_custom_domain(
"https://licensing.myapp.com", // 👈 Your Keygen Custom Domain
"1e1e411ee29eee8e85ee460ee268921ee6283ee625eee20f5e6e6113e4ee2739", // 👈 Keygen (Public) Verify Key
)
// chain custom config as needed
.version_header("1.7") // 👈 add Keygen-Version on request header
.cache_lifetime(1440) // 👈 Response cache lifetime in minutes
.build(),
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
[!NOTE] 连接
api_url
配置在这里不会产生影响。
⚡ 用法
⏱️ 定时许可证 - 带试用版
在这个 示例中,应用程序的主页由一个布局路由 _licensed.tsx
保护,如果用户没有有效许可证,它将重定向用户到验证页面。
观看 视频教程 以获取逐步实施。
主要代码片段
🗒️ 路由守卫
_licensed.tsx
// Tauri
import { getLicense, getLicenseKey } from "tauri-plugin-keygen-api";
// React
import { createFileRoute, redirect, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_licensed")({
// Before this layout route loads
beforeLoad: async ({ location }) => {
const license = await getLicense();
const licenseKey = await getLicenseKey();
// no valid licenses
if (license === null) {
// re-direct to license validation page
throw redirect({
to: "/validate", // the equivalent of a Login page in an Auth based system
search: {
redirect: location.href,
cachedKey: licenseKey || "",
},
});
}
},
component: () => <Outlet />,
});
🗒️ 验证页面
validate.tsx
// Tauri
import {
type KeygenError,
type KeygenLicense,
validateKey,
} from "tauri-plugin-keygen-api";
// React
import { useState } from "react";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { z } from "zod";
// Route Definition
export const Route = createFileRoute("/validate")({
validateSearch: z.object({
redirect: z.string().optional().catch(""),
cachedKey: z.string(),
}),
component: () => <Validate />,
});
// License Validation Page
function Validate() {
// routes
const router = useRouter();
const navigate = Route.useNavigate();
const { redirect, cachedKey } = Route.useSearch();
// local states
const [key, setKey] = useState(cachedKey);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const validate = async () => {
setErr("");
setLoading(true);
let license: KeygenLicense;
// validate license key
try {
license = await validateKey({ key });
} catch (e) {
const { code, detail } = e as KeygenError;
setErr(`${code}: ${detail}`);
setLoading(false);
return;
}
// check license
if (license.valid) {
await router.invalidate();
await navigate({ to: redirect || "/" });
} else {
setErr(`${license.code}: ${license.detail}`);
}
setLoading(false);
};
return (
<div>
...
{/* License Key Input */}
<div>
<label htmlFor="license-key">License Key</label>
<input
autoFocus
id="license-key"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</div>
{/* Validate Button */}
<button onClick={validate}>Validate</button>
{/* Help Text */}
{loading && <div>validating license...</div>}
{err !== "" && <div>{err}</div>}
</div>
);
}
🎖️ 功能基础许可证
在这个 示例中,用户可以不拥有许可证访问应用程序,除非他们想要向 ESP 项目添加图片。
观看 视频教程 以获取逐步实施。
主要代码片段
🗒️ 主页
esp.tsx
// Tauri
import { getLicense } from "tauri-plugin-keygen-api";
// React
...
import { useAtom } from "jotai";
// App
import { proLicenseModalAtom } from "../atoms";
import ProLicenseModal from "../components/ProLicenseModal";
// Main Page
function ESP() {
return (
<div>
...
<div>
...
<ChooseImageButton />
<div>
<ProLicenseModal />
</div>
);
}
// PRO Feature Component
function ChooseImageButton({
onImageChosen,
}: {
onImageChosen: (file: string) => void;
}) {
// there's always a delay when opening the native file dialog with Tauri
// this lets the users know that "dialogOpen" is in progress by showing a Spinner
const [isOpeningFile, setIsOpeningFile] = useState(false);
const [_, setProModalOpened] = useAtom(proLicenseModalAtom);
const chooseImage = async () => {
setIsOpeningFile(true);
// get license
const license = await getLicense();
// check license and its entitlements
if (license === null || !license.valid || !license.entitlements.includes("ADD_IMAGE")) {
setProModalOpened(true); // Show Modal: "This feature requires a PRO License"
setIsOpeningFile(false);
return;
}
const file = await openFileDialog({
multiple: false,
title: "Choose Image",
filters: [
{
name: "Image",
extensions: ["png", "webp", "avif", "jpg", "jpeg"],
},
],
});
if (!Array.isArray(file) && file !== null) {
onImageChosen(file);
}
setIsOpeningFile(false);
};
return (
<button onClick={chooseImage}>
{isOpeningFile ? <Spinner /> : <PhotoIcon className="size-3.5" />}
</button>
);
}
🗒️ 专业许可证模态框
ProLicenseModal.tsx
// Tauri
import { open as openLink } from "@tauri-apps/api/shell";
// React
import * as Dialog from "@radix-ui/react-dialog";
import { Link, useLocation } from "@tanstack/react-router";
import { useAtom } from "jotai";
// App
import { proLicenseModalAtom } from "../atoms";
export default function ProLicenseModal() {
const location = useLocation();
const [modalOpened, setModalOpened] = useAtom(proLicenseModalAtom);
return (
<Dialog.Root open={modalOpened} onOpenChange={setModalOpened}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Pro Feature</Dialog.Title>
<div>This is a pro feature</div>
<div>
{/* Go to License Validation Page */}
<Link to="/validate" search={{ redirect: location.href }}>
Enter License
</Link>
{/* Buy License */}
<button
onClick={() => {
openLink("https://www.stripe.com"); // open link to your payment processor in user's default browser
}}
>
Buy Pro
</button>
</div>
<Dialog.Close asChild>
<button>
<XMarkIcon className="size-4" />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
🗒️ 验证页面
validate.tsx
// Tauri
import {
type KeygenError,
type KeygenLicense,
validateCheckoutKey,
getLicenseKey,
} from "tauri-plugin-keygen-api";
// React
import { useState } from "react";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { z } from "zod";
// App
import { getLicenseErrMessage } from "../utils";
// Route Definition
export const Route = createFileRoute("/validate")({
validateSearch: z.object({
redirect: z.string().optional().catch(""),
}),
component: () => <Validate />,
});
// License Validation Page
function Validate() {
// routes
const router = useRouter();
const navigate = Route.useNavigate();
const { redirect } = Route.useSearch();
// local states
const [key, setKey] = useState("");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(errParam || "");
const validate = async () => {
setErr("");
setLoading(true);
let license: KeygenLicense;
// validate and checkout machine file
try {
license = await validateCheckoutKey({
key,
entitlements: ["ADD_IMAGE"],
ttlSeconds: 25200 /* 1 week */,
});
} catch (e) {
const { code, detail } = e as KeygenError;
setErr(getLicenseErrMessage({ code, detail }));
setLoading(false);
return;
}
if (license.valid) {
await router.invalidate();
await navigate({ to: redirect || "/" });
} else {
setErr(
getLicenseErrMessage({
code: license.code,
detail: license.detail,
policyId: license.policyId,
})
);
}
setLoading(false);
};
return (
<div>
...
{/* License Key Input */}
<div>
<label htmlFor="license-key">License Key</label>
<input
autoFocus
id="license-key"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</div>
{/* Validate Button */}
<button onClick={validate} disabled={loading}>
Validate
</button>
{/* Help Text */}
{loading && <div>validating license...</div>}
{err !== "" && <div>{err}</div>}
</div>
);
}
👾 JavaScript 客户端绑定
可用的 JavaScript API
🎫 getLicense()
从 Tauri 应用程序状态中的 LicensedState
获取当前许可证。
返回 KeygenLicense
或 null
。
import { getLicense } from "tauri-plugin-keygen-api";
const beforeLoad = async function () => {
let license = await getLicense();
if (license !== null) {
// {
// key: "55D303-EEA5CA-C59792-65D3BF-54836E-V3",
// entitlements: [],
// valid: true,
// expiry: "2024-06-22T02:04:09.028Z",
// code: "VALID",
// detail: "is valid",
// metadata: {},
// policyId: "9d930fd2-c1ef-4fdc-a55c-5cb8c571fc34",
// }
...
}
}
此插件如何管理 LicensedState
?
🔍 应用加载时:查找离线许可证
当您的 Tauri 应用程序加载时,此插件将在 [APP_DATA]/keygen/
目录中查找任何离线许可证。
如果找到一个机器文件(📄 machine.lic
),它将 验证和解密机器文件,将其解析为 License
对象,并将其加载到 Tauri 应用程序状态中。
如果没有找到机器文件,它将在 📂 validation_cache
中查找缓存,验证其签名,将缓存解析为 License
对象,并将其加载到 Tauri 应用程序状态中。
🚫 无有效许可证
如果没有找到离线许可证,或者如果找到的任何离线许可证由于以下任何原因无效:
- 无法解密机器文件
- 机器文件
ttl
已过期 - 无法验证响应缓存签名
- 缓存的年龄已超过允许的
cache_lifetime
- 解析的许可证对象已过期
在 Tauri 应用状态中的 LicensedState
将被设置为 None
(在前端序列化为 null
)。
🔄 状态更新
您不能直接更新 LicensedState
。
除了在应用加载时从离线许可证中初始化状态外,此插件还会使用来自 validateKey()
或 validateCheckoutKey()
的验证响应来更新许可证状态,并在您调用 resetLicense()
时将其重置为 None
。
🗝️ getLicenseKey()
获取缓存的许可证密钥。
返回 string
或 null
。
许可证密钥与离线许可证分开缓存,因此当离线许可证过期且 getLicense()
返回 null
时,您可以重新验证,而无需要求用户重新输入密钥。
import { getLicense, getLicenseKey } from "tauri-plugin-keygen-api";
const beforeLoad = async function () => {
let license = await getLicense();
let licenseKey = await getLicenseKey();
if (license === null) {
throw redirect({
to: "/validate", // the equivalent of a Login page in an Auth based system
search: {
cachedKey: licenseKey || "", // pass the cached licenseKey
},
});
}
}
[!TIP] 您可以重新验证缓存的
licenseKey
,而不是重新定向到validate
页面。查看 视频教程 了解如何操作。
🚀 validateKey()
参数 | 类型 | 必需 | 默认 | 描述 |
---|---|---|---|---|
key | 字符串 |
✅ | - | 要验证的许可证密钥 |
entitlements | 字符串[] |
[] |
要验证的权益代码列表 | |
cacheValidResponse | 布尔值 |
true |
是否缓存有效响应 |
返回 KeygenLicense
。抛出 KeygenError
。
import {
type KeygenLicense,
type KeygenError,
validateKey,
} from "tauri-plugin-keygen-api";
const validate = async (key: string, entitlements: string[] = []) => {
let license: KeygenLicense;
try {
license = await validateKey({
key,
entitlements,
});
} catch (e) {
const { code, detail } = e as KeygenError;
console.log(`Err: ${code}: ${detail}`);
return;
}
if (license.valid) {
...
} else {
const { code, detail } = license;
console.log(`Invalid: ${code}: ${detail}`);
}
};
当您调用 validateKey()
时,内部发生了什么?
🆔 解析机器指纹
此插件解析用户的机器 fingerprint
并将其包含在 许可证验证 和 机器激活 请求中。
[!TIP] 您可以利用机器指纹来防止用户使用多个试用许可证(而不是购买一个)。为此,在您的试用策略中将
machineUniquenessStrategy
属性设置为UNIQUE_PER_POLICY
。查看 视频教程 了解更多详情。
🔏 验证 响应签名
恶意行为者可能将请求重定向到他们控制的本地许可证服务器,该服务器默认发送“有效”响应。这被称为欺骗攻击。
为了确保接收到的响应确实来自 Keygen 的服务器且未被篡改,此插件会检查响应的签名并使用您在插件构建器中提供的 验证密钥 进行验证。
恶意行为者也可能“记录”Keygen和您的桌面应用程序之间的网络流量,然后“回放”有效的响应。例如,他们可能会回放试用期许可证到期之前发生的响应,试图使用已过期的许可证使用您的软件。这被称为重放攻击。
为了防止这种情况,此插件将拒绝任何超过5分钟的响应,即使签名有效。
🔄 更新状态和缓存
一旦响应得到验证,此插件将使用从响应中解析的License
对象更新Tauri应用程序状态中的LicensedState
。
如果License
有效且cacheValidResponse
为真,则验证过的响应将被缓存以供以后作为离线许可证使用。
🚀 💻 validateCheckoutKey()
调用validateKey()
,然后下载机器文件以进行离线许可。
参数 | 类型 | 必需 | 默认 | 描述 |
---|---|---|---|---|
key | 字符串 |
✅ | - | 要验证的许可证密钥 |
entitlements | 字符串[] |
[] |
要验证的权益代码列表 | |
ttlSeconds | 数字 |
86400 |
机器文件在秒内的生存时间。 最小值 发送到机器检查出请求的 |
|
ttlForever | 布尔值 |
false |
如果设置为true,此插件将下载一个永远不会过期的机器文件。 ⚠️ 这仅在当前许可证处于 如果设置为true,但当前许可证不在维护访问状态,此插件将下载一个带有定义的 当您实现带有回退的永久许可证时,这可能会很有用。 |
返回 KeygenLicense
。抛出 KeygenError
。
import {
type KeygenLicense,
type KeygenError,
validateCheckoutKey,
} from "tauri-plugin-keygen-api";
const validate = async (key: string, entitlements: string[] = []) => {
let license: KeygenLicense;
try {
license = await validateCheckoutKey({
key,
entitlements,
ttlSeconds: 604800 /* 1 week*/,
});
} catch (e) {
const { code, detail } = e as KeygenError;
console.log(`Err: ${code}: ${detail}`);
return;
}
if (license.valid) {
...
} else {
const { code, detail } = license;
console.log(`Invalid: ${code}: ${detail}`);
}
};
与validateKey()
一样,它也会解析机器指纹,验证响应签名,并更新Tauri应用程序状态。
唯一的区别是,当收到有效许可证时,此插件不会缓存响应,而是会下载用于离线许可的machine.lic
文件。
🔃 resetLicense()
删除在[APP_DATA/keygen/]
中的所有离线许可证(验证缓存和机器文件),并将Tauri应用程序状态中的LicensedState
设置为None
。
🔃 resetLicenseKey()
删除在[APP_DATA]/keygen/
上的缓存许可证密钥。
依赖项
~25–67MB
~1M SLoC