PAYATHON 2026

如何让 login.aspx 等待另一页面触发后继续运行

支付阿杰

结论

不能让 login.aspx 的某次 HTTP 请求一直“暂停”,等 logedin.aspx 或 failedlogin.aspx 被访问后再继续执行。

认证或支付应设计为带状态的异步流程:

  1. login.aspx 向本站服务器创建一笔认证事务。
  2. 在新窗口中打开第三方认证页面。
  3. 第三方完成认证后,重定向到本站的回调地址,或通过服务器回调通知结果。
  4. 本站保存认证结果。
  5. 原页面通过轮询、SSE、WebSocket 或 window.postMessage() 获取结果并更新界面。

需要等待的是“事务结果”,而不是某个 ASP.NET 页面对象。

为什么不能直接等待另一个页面

每个 HTTP 请求都是独立的。login.aspx 的请求完成后,服务器会返回响应,对应的页面对象也会结束生命周期。

之后访问:

logedin.aspx

会发起一次全新的 HTTP 请求。这个请求可能由另一条线程处理。在负载均衡环境中,它甚至可能被分配到另一台服务器,因此无法可靠地“释放”先前页面中的某个对象。

也不应通过以下方式阻塞请求:

waitHandle.WaitOne();

这种做法会长期占用服务器线程,还可能引发超时、资源耗尽,以及应用重启后状态丢失等问题。把等待对象放进 static、Session 或内存字典,也无法可靠应对多服务器部署和进程回收。

推荐的整体流程

以第三方登录或银行验证为例:

原页面
  │
  ├─ 1. 创建认证事务,获得 transactionId
  │
  ├─ 2. 打开第三方认证窗口
  │
  ├─ 3. 定期查询本站的事务状态
  │
第三方页面
  │
  ├─ 4. 用户完成验证
  │
  └─ 5. 重定向或服务器回调到本站
             │
             └─ 6. 本站验证结果并更新数据库
  │
原页面
  └─ 7. 查询到成功或失败,更新界面

数据库中可以保存类似的记录:

TransactionId
UserId
Status            // Pending、Succeeded、Failed、Expired
ProviderReference
CreatedAt
ExpiresAt

事务的初始状态是 Pending。收到可信的认证结果后,再将其改为 Succeeded 或 Failed。

原页面打开认证窗口并轮询

下面是一个简化的浏览器端示例:

<button type="button" id="loginButton">使用第三方账户登录</button>
<div id="result"></div>

<script>
document.getElementById("loginButton").addEventListener("click", async () => {
    const resultElement = document.getElementById("result");
    resultElement.textContent = "正在创建认证请求……";

    const startResponse = await fetch("/api/auth/start", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        credentials: "same-origin"
    });

    if (!startResponse.ok) {
        resultElement.textContent = "无法启动认证";
        return;
    }

    const { transactionId, authorizationUrl } =
        await startResponse.json();

    const popup = window.open(
        authorizationUrl,
        "thirdPartyLogin",
        "width=600,height=750"
    );

    if (!popup) {
        resultElement.textContent = "浏览器阻止了弹出窗口,请允许弹窗后重试";
        return;
    }

    resultElement.textContent = "等待认证完成……";

    const timer = setInterval(async () => {
        try {
            const statusResponse = await fetch(
                `/api/auth/status?id=${encodeURIComponent(transactionId)}`,
                {
                    credentials: "same-origin",
                    cache: "no-store"
                }
            );

            if (!statusResponse.ok) {
                return;
            }

            const data = await statusResponse.json();

            if (data.status === "Succeeded") {
                clearInterval(timer);
                popup.close();
                resultElement.textContent = "认证成功";
            } else if (
                data.status === "Failed" ||
                data.status === "Expired"
            ) {
                clearInterval(timer);
                popup.close();
                resultElement.textContent =
                    data.status === "Failed"
                        ? "认证失败"
                        : "认证已超时";
            }
        } catch {
            // 临时网络错误时继续轮询,不立即判定认证失败。
        }
    }, 2000);

    setTimeout(() => {
        clearInterval(timer);
        resultElement.textContent = "等待认证结果超时";
    }, 5 * 60 * 1000);
});
</script>

轮询接口只负责查询已经保存的事务状态,不会让某个 ASP.NET 请求一直等待。

服务器端接口示例

以下代码使用接近 ASP.NET Web API 的写法展示核心逻辑。具体路由和数据库代码需要根据项目使用的 ASP.NET 版本调整。

创建事务

[HttpPost]
[Route("api/auth/start")]
public async Task<IHttpActionResult> Start()
{
    string transactionId = Guid.NewGuid().ToString("N");
    string state = CreateCryptographicallySecureState();

    var transaction = new AuthTransaction
    {
        TransactionId = transactionId,
        StateHash = Hash(state),
        Status = "Pending",
        CreatedAt = DateTimeOffset.UtcNow,
        ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
    };

    await repository.InsertAsync(transaction);

    string callbackUrl =
        "https://example.com/auth/callback.aspx";

    string authorizationUrl =
        provider.BuildAuthorizationUrl(
            callbackUrl,
            state,
            transactionId
        );

    return Ok(new
    {
        transactionId,
        authorizationUrl
    });
}

在生产环境中,state 必须由密码学安全的随机数生成器产生,并与当前用户会话或事务绑定。

处理第三方回调

[HttpGet]
[Route("api/auth/callback")]
public async Task<IHttpActionResult> Callback(
    string code,
    string state)
{
    var transaction =
        await repository.FindByStateHashAsync(Hash(state));

    if (transaction == null ||
        transaction.ExpiresAt <= DateTimeOffset.UtcNow)
    {
        return BadRequest("Invalid or expired state.");
    }

    if (transaction.Status != "Pending")
    {
        return Ok();
    }

    var providerResult =
        await provider.ExchangeAndVerifyCodeAsync(code);

    transaction.Status = providerResult.IsSuccessful
        ? "Succeeded"
        : "Failed";

    transaction.ProviderReference =
        providerResult.Reference;

    await repository.UpdateAsync(transaction);

    return Ok();
}

第三方登录通常会返回一次性 code,本站服务器再用这个 code 向第三方服务器换取并验证令牌。不能仅凭浏览器传来的 success=true 判断认证成功。

查询状态

[HttpGet]
[Route("api/auth/status")]
public async Task<IHttpActionResult> Status(string id)
{
    var transaction = await repository.FindAsync(id);

    if (transaction == null)
    {
        return NotFound();
    }

    if (transaction.ExpiresAt <= DateTimeOffset.UtcNow &&
        transaction.Status == "Pending")
    {
        transaction.Status = "Expired";
        await repository.UpdateAsync(transaction);
    }

    return Ok(new
    {
        status = transaction.Status
    });
}

查询状态时,还必须确认该事务属于当前用户,不能让用户通过猜测 transactionId 查看其他人的认证状态。

使用 postMessage() 立即通知原窗口

如果第三方最终重定向到本站域名下的回调页面,该页面可以通知打开它的原窗口:

<script>
if (window.opener) {
    window.opener.postMessage(
        {
            type: "authentication-complete",
            transactionId: "服务器生成的事务编号"
        },
        "https://example.com"
    );

    window.close();
}
</script>

原页面监听消息:

window.addEventListener("message", async event => {
    if (event.origin !== "https://example.com") {
        return;
    }

    if (event.data?.type !== "authentication-complete") {
        return;
    }

    const response = await fetch(
        `/api/auth/status?id=${encodeURIComponent(
            event.data.transactionId
        )}`,
        {
            credentials: "same-origin",
            cache: "no-store"
        }
    );

    const data = await response.json();

    if (data.status === "Succeeded") {
        document.getElementById("result").textContent = "认证成功";
    }
});

postMessage() 只用于提醒原页面重新查询状态,消息本身不能作为可信的认证结果。原页面必须检查 event.origin,最终状态仍要由服务器确认。

建议同时保留轮询作为备用方案,因为用户可能刷新页面,浏览器也可能切断 window.opener。

为什么不能直接读取银行窗口

浏览器的同源策略禁止原页面读取不同域名窗口的地址和内容。例如:

shop.example.com
bank.example.net

这两个地址属于不同源。商城页面无法通过 JavaScript 判断银行窗口当前显示的是成功页还是失败页。

可靠的结果只能通过协议规定的渠道获取:

  • 第三方将浏览器重定向到本站注册的回调地址;
  • 第三方向本站发送服务器端 webhook;
  • 本站服务器根据交易编号主动查询第三方;
  • 使用 OAuth 2.0、OpenID Connect 或支付服务商规定的其他回调机制。

实际支付流程必须以支付机构提供的接口文档为准。不同机构可能使用浏览器返回、服务器通知、主动查询中的一种或多种机制。

避免用户重新填写表单

支付前可以先在本站创建订单,把商品、地址和必要的业务数据保存到数据库,再进入支付流程:

填写订单
→ 创建未支付订单
→ 获得 orderId
→ 发起支付
→ 银行验证
→ 更新订单支付状态
→ 返回订单结果页

即使支付失败,也可以通过 orderId 恢复订单信息,不必让用户重新填写全部内容。

敏感支付信息需要单独处理:

  • 不要自行把信用卡安全码等敏感信息存入数据库、Session、URL 或浏览器存储。
  • 尽量使用支付服务商托管的输入页面或令牌化组件。
  • 如果业务确实会接触银行卡数据,则需要满足适用的安全与合规要求,例如 PCI DSS。具体要求应由支付服务商和合规人员确认。

还需要注意的安全问题

  • 使用不可预测的 state、nonce 和事务编号,防止登录 CSRF 和事务劫持。
  • 回调地址必须使用 HTTPS。
  • 验证第三方服务器通知中的签名、证书和时间戳,并防止重放。具体方法以服务商协议为准。
  • 服务端应再次向第三方验证授权码、令牌或交易状态。
  • 回调处理必须具备幂等性。同一通知重复到达时,不能重复记账或创建订单。
  • 不要在 URL 中传递密码、银行卡号、访问令牌等敏感信息。
  • 为事务设置过期时间,并处理取消、关闭弹窗、网络中断和长时间没有结果等情况。
  • 支付结果不能只依赖前端跳转页面。用户可能关闭页面,攻击者也可能伪造浏览器请求。
  • 弹窗可能被浏览器拦截,因此应直接在用户点击事件中调用 window.open(),同时准备在当前窗口跳转的备用流程。

实现这类交互时,可以重点了解 OAuth 2.0 Authorization Code Flow、OpenID Connect、支付网关的 return URL 与 webhook、同源策略、window.postMessage()、轮询、SSE、WebSocket,以及 ASP.NET 中的持久化事务状态和幂等回调处理。

备注:内容仅供参考。