PAYATHON 2026

.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,以减少数据库查询,但需要明确接受这段时间窗口。

支付流程应如何保护

推荐的支付流程如下:

  1. 用户正常登录并获得 JWT。
  2. 客户端调用受 BillingAccess 策略保护的续费接口。
  3. 服务端根据 JWT 中的用户和租户信息创建支付订单。
  4. 服务端返回支付平台提供的跳转地址。
  5. 支付平台通过 Webhook 通知支付结果。
  6. 服务端验证 Webhook 签名并更新订阅状态。
  7. 客户端重新查询订阅状态,恢复业务功能。

创建支付订单时,价格、商品、租户和订阅方案都应由服务端确定。客户端通过 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": "当前订阅已到期,请续费后继续使用。"
}

前端不应根据错误消息文本判断业务状态。

还需要注意的边界情况

订阅状态最好不要只用一个布尔值表示。实际系统通常需要区分:

  • Trialing
  • Active
  • PastDue
  • GracePeriod
  • Suspended
  • Canceled
  • Expired

例如,用户取消自动续费后,通常仍可使用服务,直到当前计费周期结束。支付失败后是否立即停用,则取决于业务是否设置宽限期。

系统还需要考虑这些安全措施:

  • Webhook 必须验证签名,并支持幂等处理;
  • 同一支付事件重复到达时,不能重复增加订阅期限;
  • 退款、拒付和订阅取消也要及时更新授权状态;
  • 只有租户负责人或账单管理员可以创建或修改订阅;
  • 服务端使用 UTC 时间判断到期时间;
  • 订阅查询缓存不能长期保留已经过期的状态;
  • 账号封禁应作为独立的授权条件处理,不能与订阅到期混为一谈。

最终方案是:**正常签发 JWT,通过统一的授权策略保护付费功能,默认要求订阅有效,并为续费、账单和账户管理接口设置明确的例外策略。**这样既保留了标准的认证流程,也不必在每个 endpoint 中手动判断订阅状态。

备注:内容仅供参考。