.Net Core 3.1 SaaS 订阅到期后如何限制访问?
结论
更合适的做法是:订阅到期后,用户仍可完成身份认证并获得 JWT。系统把“订阅是否有效”作为授权条件,而不是登录条件。
具体来说:
- JWT 用来证明“用户是谁”。
- Authorization Policy 用来判断用户当前能否使用付费功能。
- 登录、续费、支付和查看账单等接口只要求用户已认证。
- 业务接口默认要求订阅有效。
- 支付回调不依赖用户 JWT,而是验证支付平台的签名。
这套设计接近方法 2,但不必在每个 endpoint 中手动判断订阅状态。可以通过 ASP.NET Core 的 Policy-based Authorization 统一处理,并采用“默认拒绝,少数接口例外”的方式,减少遗漏。
为什么不建议在订阅到期后拒绝签发 JWT
订阅状态和用户身份是两回事。
订阅到期只表示用户暂时不能使用付费功能,并不意味着:
- 用户身份失效;
- 密码错误;
- 账号被禁用;
- 用户不能查看账单或购买续费;
- 用户不能修改密码、退出登录或联系客服。
如果订阅到期后不再签发 JWT,那么后续所有需要确认用户身份的操作,都得依赖另一套临时认证机制,例如通过 query string 传递特殊 token。这会增加系统复杂度,也更容易引发 token 泄漏、重放或越权问题。
因此,只要账号本身没有被封禁或删除,就应正常完成身份认证。
推荐的接口分层
接口可以分为三类:
| 接口类型 | 访问条件 | 示例 |
|---|---|---|
| 公开接口 | 不需要认证 | 登录、忘记密码、支付平台 Webhook |
| 认证接口 | JWT 有效即可 | 查看订阅状态、创建续费订单、退出登录 |
| 付费业务接口 | JWT 有效且订阅有效 | 创建项目、调用核心 API、导出数据 |
重点在于:大多数业务接口默认要求有效订阅,只有续费等少量接口明确放宽限制。
这样一来,即使开发人员在新增业务接口时忘记添加订阅检查,也不会把接口意外开放给订阅已到期的用户。
使用 Authorization Policy 统一检查订阅
先定义订阅要求:
using Microsoft.AspNetCore.Authorization;
public sealed class ActiveSubscriptionRequirement
: IAuthorizationRequirement
{
}
再实现对应的授权处理器:
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
public sealed class ActiveSubscriptionHandler
: AuthorizationHandler<ActiveSubscriptionRequirement>
{
private readonly ISubscriptionService _subscriptionService;
public ActiveSubscriptionHandler(
ISubscriptionService subscriptionService)
{
_subscriptionService = subscriptionService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveSubscriptionRequirement requirement)
{
var tenantId = context.User.FindFirst("tenant_id")?.Value;
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
var isActive =
await _subscriptionService.IsSubscriptionActiveAsync(tenantId);
if (isActive)
{
context.Succeed(requirement);
}
}
}
如果订阅属于整个租户或公司,就应根据 tenant_id 检查,而不是只检查当前用户。同时还要确认当前用户确实属于该租户,不能直接相信客户端传入的租户编号。
在 Startup.ConfigureServices 中注册策略:
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddScoped<
IAuthorizationHandler,
ActiveSubscriptionHandler>();
services.AddAuthorization(options =>
{
var paidAccessPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new ActiveSubscriptionRequirement())
.Build();
options.DefaultPolicy = paidAccessPolicy;
options.FallbackPolicy = paidAccessPolicy;
options.AddPolicy(
"AuthenticatedOnly",
policy => policy.RequireAuthenticatedUser());
options.AddPolicy(
"BillingAccess",
policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("TenantOwner", "BillingAdmin");
});
});
}
FallbackPolicy 会让没有显式授权标记的 endpoint 也受到保护。启用这项配置前,需要检查现有的公开接口,并为它们明确添加 [AllowAnonymous],否则登录、密码重置等接口可能在上线后被意外拦截。
中间件顺序也要正确:
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
Controller 的使用方式
普通业务接口使用默认策略:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/projects")]
[Authorize]
public sealed class ProjectsController : ControllerBase
{
[HttpGet]
public IActionResult GetProjects()
{
return Ok();
}
[HttpPost]
public IActionResult CreateProject()
{
return Ok();
}
}
默认策略包含 ActiveSubscriptionRequirement,所以订阅已到期的用户无法访问这些接口。
续费接口只要求用户已经通过认证,还可以进一步限制为租户负责人或账单管理员:
[ApiController]
[Route("api/billing")]
[Authorize(Policy = "BillingAccess")]
public sealed class BillingController : ControllerBase
{
private readonly IBillingService _billingService;
public BillingController(IBillingService billingService)
{
_billingService = billingService;
}
[HttpGet("subscription")]
public async Task<IActionResult> GetSubscription()
{
var result =
await _billingService.GetCurrentSubscriptionAsync(User);
return Ok(result);
}
[HttpPost("checkout")]
public async Task<IActionResult> CreateCheckout()
{
var result =
await _billingService.CreateCheckoutSessionAsync(User);
return Ok(result);
}
}
BillingAccess 不要求订阅有效,因此用户在订阅到期后仍能创建续费订单。
不要完全依赖 JWT 中的订阅状态
JWT 可以包含订阅状态 claim,但不能把它作为唯一依据。
例如,JWT 的有效期是 60 分钟,而订阅在 JWT 签发后的第 5 分钟到期,那么其中的 subscription_active=true 仍可能继续有效 55 分钟。
更稳妥的处理方式是:
- JWT 保存相对稳定的信息,例如
user_id、tenant_id和角色; - 从数据库或缓存中查询订阅状态;
- 为缓存设置较短的过期时间;
- 收到支付成功、退款或订阅取消事件后,主动清理缓存;
- 访问令牌保持较短的有效期,长期会话通过受控的 refresh token 续签。
如果业务能够接受几分钟的状态延迟,也可以把订阅状态写入短期 JWT,以减少数据库查询,但需要明确接受这段时间窗口。
支付流程应如何保护
推荐的支付流程如下:
- 用户正常登录并获得 JWT。
- 客户端调用受
BillingAccess策略保护的续费接口。 - 服务端根据 JWT 中的用户和租户信息创建支付订单。
- 服务端返回支付平台提供的跳转地址。
- 支付平台通过 Webhook 通知支付结果。
- 服务端验证 Webhook 签名并更新订阅状态。
- 客户端重新查询订阅状态,恢复业务功能。
创建支付订单时,价格、商品、租户和订阅方案都应由服务端确定。客户端通过 query string 或请求体提交的金额和客户编号不能直接作为可信数据使用。
支付成功页面也不能作为确认付款的最终依据,因为用户可以自行访问或伪造跳转 URL。订阅状态应以通过签名验证的 Webhook,或服务端主动查询支付平台所得的结果为准。
Webhook 通常不会携带用户 JWT,因此可以使用 [AllowAnonymous],但必须验证支付平台的签名:
[ApiController]
[Route("api/payment-webhook")]
[AllowAnonymous]
public sealed class PaymentWebhookController : ControllerBase
{
private readonly IPaymentWebhookService _webhookService;
public PaymentWebhookController(
IPaymentWebhookService webhookService)
{
_webhookService = webhookService;
}
[HttpPost]
public async Task<IActionResult> Handle()
{
var processed =
await _webhookService.VerifyAndProcessAsync(Request);
if (!processed)
{
return Unauthorized();
}
return Ok();
}
}
签名验证的具体方式取决于支付平台,不能只检查请求中的订单号或成功标志。
密码重置应作为独立流程
密码重置本来就不能依赖现有 JWT,因为用户通常会在无法登录时使用这个功能。
重置 token 应具备以下属性:
- 随机且不可预测;
- 有较短的有效期;
- 只能使用一次;
- 与特定用户及操作绑定;
- 使用后立即失效;
- 不会在日志或跳转来源中泄漏。
密码重置和订阅续费不是同一种授权场景,不应复用支付 token 或普通 JWT。
状态码和前端处理
不同的失败情况应使用语义明确的状态码:
- JWT 缺失、过期或无效:返回
401 Unauthorized; - JWT 有效,但订阅无效:返回
403 Forbidden; - JWT 有效,但用户没有账单管理权限:返回
403 Forbidden。
如果前端收到订阅问题导致的 403,可以显示续费提示并跳转到支付页面。为了区分“订阅到期”和“角色权限不足”,API 可以在错误响应中返回稳定的业务错误码,例如:
{
"code": "subscription_required",
"message": "当前订阅已到期,请续费后继续使用。"
}
前端不应根据错误消息文本判断业务状态。
还需要注意的边界情况
订阅状态最好不要只用一个布尔值表示。实际系统通常需要区分:
TrialingActivePastDueGracePeriodSuspendedCanceledExpired
例如,用户取消自动续费后,通常仍可使用服务,直到当前计费周期结束。支付失败后是否立即停用,则取决于业务是否设置宽限期。
系统还需要考虑这些安全措施:
- Webhook 必须验证签名,并支持幂等处理;
- 同一支付事件重复到达时,不能重复增加订阅期限;
- 退款、拒付和订阅取消也要及时更新授权状态;
- 只有租户负责人或账单管理员可以创建或修改订阅;
- 服务端使用 UTC 时间判断到期时间;
- 订阅查询缓存不能长期保留已经过期的状态;
- 账号封禁应作为独立的授权条件处理,不能与订阅到期混为一谈。
最终方案是:**正常签发 JWT,通过统一的授权策略保护付费功能,默认要求订阅有效,并为续费、账单和账户管理接口设置明确的例外策略。**这样既保留了标准的认证流程,也不必在每个 endpoint 中手动判断订阅状态。
备注:内容仅供参考。
