#tauri-plugin #licensing #key-gen #response #cache #machine #timed

tauri-plugin-keygen

Keygen.sh 许可证的 Tauri 插件

1 个不稳定版本

0.1.0 2024 年 6 月 18 日

#497 in 加密学

MIT 许可证

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 自托管 时很有用。

目录路径的尾部 / 很重要

  • https://www.my-app.com/api/
  • https://www.my-app.com/api

version_header None

Keygen 将您使用的 API 版本 锁定 到您的账户。

此配置在您在 Keygen 仪表板更改账户的 API 版本(例如,从 1.3 更改为 1.7)之前测试一切是否仍然正常很有用。

不要添加版本字符串前缀

  • .version_header("v1.7")
  • .version_header("1.7")

缓存存活时间 240

缓存验证响应允许的存活时间,以分钟为单位。

最小 60 分钟,最大 1440 分钟(24 小时)。

ℹ️ 缓存以今天日期("YYYY-MM-DD")和许可证密钥的哈希作为键。

因此,最大存活时间实际上不会是完整的 1440 分钟,因为今天的缓存不会在午夜(第二天)加载。

为了获得更长的离线许可功能,您应该使用 validateCheckoutKey(),而不是依赖验证响应缓存。


🌐 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 获取当前许可证。

返回 KeygenLicensenull

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

获取缓存的许可证密钥。

返回 stringnull

许可证密钥与离线许可证分开缓存,因此当离线许可证过期且 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

机器文件在秒内的生存时间。

最小值 3600 最大值 31_556_952(1年)。

发送到机器检查出请求的ttl参数将是定义的ttlSeconds和当前许可证计算的secondsToExpiry之间的最小值。

ttlForever 布尔值 false

如果设置为true,此插件将下载一个永远不会过期的机器文件。

⚠️ 这仅在当前许可证处于MAINTAIN_ACCESS状态(已过期但仍然有效)时才有效。

如果设置为true,但当前许可证不在维护访问状态,此插件将下载一个带有定义的ttlSeconds(默认为86400)的机器文件。

当您实现带有回退的永久许可证时,这可能会很有用。

返回 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