是否有支持 SaaS 计费、注册和用户身份验证的服务?
结论
有这类服务,但对 Windows 桌面客户端来说,很少有产品能包办注册、身份验证、订阅计费和权限控制。
更稳妥的方案是组合两类成熟服务:
- 身份服务:Auth0、Microsoft Entra External ID、Amazon Cognito、Firebase Authentication、Clerk 等。
- 计费服务:Stripe Billing、Paddle、Chargebee 等。
应用后端负责关联身份与计费数据,再向客户端返回用户当前可以使用的功能。密码存储、邮箱验证、支付信息、续费、账单和发票等复杂工作,仍然交给第三方处理。
如果想减少集成工作,也可以考虑 Outseta、Frontegg、Keygen 这类更接近“一体化”的产品。选择前,需要确认它们是否支持桌面应用登录、目标支付地区、税务处理、所需订阅模型和细粒度权限。相关能力和商业条款可能变化,不能只看产品是否标注“支持 SaaS”。
推荐的系统结构
整个流程可以设计成:
Windows 客户端
│
├── 登录 ──────────> 身份服务(OIDC / OAuth 2.0)
│ │
│ └── 返回 access token 和稳定的 user ID
│
├── 购买或管理订阅 ──> SaaS 后端 ──> 计费服务
│
└── 查询可用功能 ───> SaaS 后端 ──> 权限数据库
↑
计费 Webhook 更新
身份服务生成的用户标识通常位于令牌的 sub claim 中。后端以此为主键,将用户关联到计费平台中的 customer_id,并根据订阅状态生成相应权限。
例如:
{
"identity_subject": "auth0|abc123",
"billing_customer_id": "cus_xxxxxxxxx",
"plan": "pro",
"entitlements": [
"project.create",
"export.pdf",
"sync.cloud"
]
}
不建议另外向客户端发放永久有效的“应用密钥”作为登录凭据。稳定的用户 ID 可以用来关联数据,身份验证则应依赖短期 access token。
实现步骤
1. 注册和登录
用户在 Windows 应用中输入邮箱后,可以选择以下方式之一:
- 邮箱验证码或 magic link。
- OAuth 2.0 Device Authorization Grant。
- 通过系统浏览器完成 Authorization Code Flow with PKCE。
桌面应用属于 public client,无法安全保存 client_secret。不要把 OAuth client secret、支付密钥或后台管理 API key 写进安装包。
如果通过系统浏览器登录,身份服务完成验证后会返回类似下面的令牌信息:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJ..."
}
客户端随后使用 access_token 调用自己的后端:
using System.Net.Http.Headers;
using System.Text.Json;
var http = new HttpClient
{
BaseAddress = new Uri("https://api.example.com")
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.GetAsync("/v1/me/entitlements");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var entitlements = JsonSerializer.Deserialize<EntitlementResponse>(json);
public sealed class EntitlementResponse
{
public string Plan { get; set; } = "";
public string[] Entitlements { get; set; } = [];
}
这里的 accessToken 必须来自经过验证的 OIDC/OAuth 流程,不能由应用自行拼装。
2. 创建计费账户
用户首次购买服务时,后端先从已验证的令牌中取得 sub,然后创建或查找计费平台中的 customer。
下面使用 Stripe 的概念举例,具体 SDK 接口应以当前使用版本的官方文档为准:
const customer = await stripe.customers.create({
email: verifiedEmail,
metadata: {
identity_subject: authenticatedUser.sub
}
});
await users.update(authenticatedUser.sub, {
billingCustomerId: customer.id
});
这项关联必须保存在服务端。不能根据客户端提交的邮箱判断支付账户归属,因为邮箱可能被修改,也可能存在大小写、别名或重复注册等问题。
3. 添加服务或订阅
用户选择套餐或附加服务后,由后端创建结账会话,客户端只负责打开服务端返回的结账地址:
app.post("/v1/billing/checkout", requireUser, async (req, res) => {
const user = await users.findBySubject(req.user.sub);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: user.billingCustomerId,
line_items: [
{
price: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1
}
],
success_url: "https://example.com/billing/success",
cancel_url: "https://example.com/billing/cancel"
});
res.json({ url: session.url });
});
price、金额和套餐类型都应由服务端决定,不能采用客户端传来的价格。
4. 根据 Webhook 更新权限
订阅状态变化后,计费平台会向后端发送 Webhook。后端验证签名,再更新用户权限。
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return res.sendStatus(400);
}
if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const subscription = event.data.object;
await synchronizeEntitlements({
billingCustomerId: subscription.customer,
subscriptionId: subscription.id,
status: subscription.status,
items: subscription.items.data
});
}
res.sendStatus(200);
}
);
Webhook 可能重复发送,也可能乱序到达,因此 synchronizeEntitlements 必须具备幂等性。不能因为收到一次“创建”事件就永久开放功能。更稳妥的方式是根据订阅 ID、状态和事件时间重新计算权限。
5. 为客户端提供权限接口
Windows 应用不应直接调用计费平台查询订阅,只需访问自己的权限接口:
GET /v1/me/entitlements
Authorization: Bearer eyJ...
响应可以是:
{
"plan": "pro",
"subscriptionStatus": "active",
"entitlements": {
"cloudSync": true,
"maxProjects": 100,
"pdfExport": true
}
}
后端处理请求时,需要分别完成两项检查:
- 验证令牌签名、
iss、aud、有效期等身份信息。 - 从服务端数据库读取当前订阅和权限。
只在客户端界面中隐藏按钮并不构成权限控制。涉及云端数据、付费资源或高成本操作时,服务端必须再次授权。
Freemium 模式如何处理
免费用户也应拥有正式账户,只是没有付费订阅:
{
"plan": "free",
"entitlements": {
"cloudSync": false,
"maxProjects": 3,
"pdfExport": false
}
}
“套餐”和“权限”最好分开建模。例如,可以把 pro 套餐映射到多个 entitlement,不要在代码各处直接判断:
if (user.plan === "pro") {
// ...
}
更容易维护的写法是:
if (user.entitlements.includes("export.pdf")) {
// ...
}
以后调整套餐内容、增加附加服务,或者为特定客户临时授权时,就不必修改客户端的业务逻辑。
选择服务时需要确认的事项
桌面客户端支持
确认身份平台支持 native application、PKCE、device flow 或 loopback redirect URI。只支持浏览器 Cookie 会话的会员系统,通常不适合 Windows 客户端。
支付和税务责任
Stripe Billing 主要提供支付与订阅基础设施。税务、退款、争议处理和销售主体责任,需要结合具体产品配置和经营地区确认。
Paddle 等 Merchant of Record 模式可能承担更多支付和税务事务,但各家的费率、国家覆盖范围、产品审核要求和退款政策不同,应以签约时的条款为准。
离线使用
如果客户端必须离线运行,仅靠在线查询权限还不够。后端可以签发短期授权文件,并通过非对称签名防止篡改:
{
"sub": "user_123",
"entitlements": ["export.pdf"],
"exp": 1788200000
}
客户端只内置用于验签的公钥,私钥必须保留在服务端。离线授权还要明确宽限期、设备数量、撤销方式,以及客户端时钟被修改时的处理策略。
账户安全
至少需要做到以下几点:
- 不在自己的数据库中保存明文密码。
- 不在客户端保存支付平台 secret key。
- 使用系统安全存储保存 refresh token。
- 为 access token 设置较短的有效期,并实现安全续期。
- 校验 JWT 的签名、issuer、audience 和过期时间。
- 找回账户、修改邮箱或合并账户时重新验证身份。
- 通过 Webhook 处理退款、取消、支付失败和订阅到期。
实际建议
如果目标是尽快上线,可以先采用“托管身份服务 + 托管结账页面 + 自有权限接口”的组合。通常只需要自行编写以下核心部分:
- 身份用户与计费 customer 的映射。
- Webhook 处理。
- 套餐到 entitlement 的映射。
/me/entitlements权限查询接口。
与寻找一个包办所有流程的平台相比,这套方案更容易控制,也能避免日后更换支付服务时迁移全部用户身份。对 Windows SaaS 而言,它在工作量、可靠性和扩展能力之间取得了更实际的平衡。
备注:内容仅供参考。
