ASP.NET MVC 5 支付回跳后 Session 丢失问题
结论
这通常不是支付网关“销毁”了 Session 对象,而是浏览器从支付网关跨站 POST 回跳时,没有携带 ASP.NET 的 Session Cookie,通常是 ASP.NET_SessionId。服务器找不到原来的 Session ID,只能创建一个新 Session,所以读取:
Session["TstConfSESSION"]
会得到 null。
Chrome、Firefox 和 Edge 的表现不同,应先检查 Cookie 的 SameSite、Secure、域名及 HTTPS 配置。近年来,浏览器逐步收紧了跨站 Cookie 策略。即使旧系统没有改过代码,也可能在浏览器升级后突然出现问题。
延长 Session 超时时间通常不起作用。问题很可能不是 Session 已经过期,而是回跳请求根本没有带上原来的 Session Cookie。
最可能的原因:SameSite 限制
支付过程通常包含以下步骤:
- 用户访问会议系统,例如
https://conference.example.com。 - 系统创建 Session,并向浏览器写入
ASP.NET_SessionId。 - 用户跳转到支付网关域名。
- 支付网关通过表单
POST返回会议系统。 - 浏览器将其判断为跨站
POST请求。 - 如果 Session Cookie 被视为
SameSite=Lax,浏览器可能不会发送这个 Cookie。
SameSite=Lax 通常允许顶层跨站 GET 导航携带 Cookie,却往往不会在跨站 POST 中携带。支付网关通过自动提交表单返回商户网站时,恰好会触发这项限制。
不同浏览器、版本和隐私设置可能有不同表现。因此,即使某个 Edge 环境运行正常,也不能据此排除 Cookie 配置问题。
先确认 Cookie 是否发送
不要只检查 Session["TstConfSESSION"]。应使用 Chrome 或 Firefox 的开发者工具,完整记录支付前后的网络请求。
重点检查:
- 支付前的响应是否写入了
ASP.NET_SessionId。 - 浏览器是否保存了这个 Cookie。
- Cookie 的
Domain、Path、SameSite、Secure和过期时间。 - 支付网关回跳请求的
Cookie请求头是否包含ASP.NET_SessionId。 - 回跳响应是否重新下发了另一个
ASP.NET_SessionId。 - 支付前后的 Session ID 是否一致。
- 回跳地址是否从 HTTPS 降级为 HTTP。
- 支付前后是否使用了不同的主机名,例如:
example.comwww.example.compayment.example.com
- 请求中是否出现额外的 301、302、307 或 308 重定向。
也可以临时记录请求信息:
var sessionId = Session.SessionID;
var cookieHeader = Request.Headers["Cookie"];
var host = Request.Url.Host;
var scheme = Request.Url.Scheme;
var method = Request.HttpMethod;
日志中不要记录支付密码、银行卡信息、完整身份凭证或其他敏感字段。
如果浏览器仍保存着 ASP.NET_SessionId,但回跳请求没有携带它,基本可以判断问题出在浏览器的 Cookie 发送策略、域名或协议不匹配,而不是 Session 超时。
配置 SameSite=None
如果跨站 POST 必须携带 Session Cookie,应将 Cookie 设置为:
SameSite=None; Secure
SameSite=None 允许浏览器跨站发送 Cookie。现代浏览器同时要求此类 Cookie 带有 Secure,所以站点必须全程使用 HTTPS。
在支持相应配置项的 ASP.NET MVC 5 / .NET Framework 环境中,可以尝试:
<configuration>
<system.web>
<httpCookies sameSite="None" requireSSL="true" />
<sessionState
mode="InProc"
timeout="30"
cookieSameSite="None"
cookieless="UseCookies" />
</system.web>
</configuration>
cookieSameSite 及相关 SameSite 行为取决于服务器实际安装的 .NET Framework、System.Web 更新和补丁。如果部署环境无法识别该属性,不要直接照搬配置。应先根据服务器当前版本确认支持方式,再在测试环境中检查最终响应里的 Set-Cookie。
最终需要确认的是,浏览器收到的响应头是否类似:
Set-Cookie: ASP.NET_SessionId=...; Path=/; Secure; HttpOnly; SameSite=None
如果应用还使用 Forms Authentication 或 OWIN Cookie Authentication,也要分别检查它们的认证 Cookie。修改 Session Cookie 不会自动改变认证 Cookie。
强制使用 HTTPS 并统一域名
即使设置了 SameSite=None,以下情况仍可能导致 Cookie 无法发送:
- 回跳 URL 是
http://,而 Cookie 带有Secure。 - 支付前使用
www.example.com,回跳时却使用example.com。 - Cookie 的
Path不包含回跳路径。 - 反向代理终止了 HTTPS,但应用误以为请求使用的是 HTTP。
- 支付网关后台仍保存着旧域名或旧协议。
应将支付网关的返回地址固定为统一的规范地址,例如:
https://conference.example.com/payment/payment
还要检查负载均衡器、CDN、WAF 和反向代理是否改写了协议、Host 或 Cookie 属性。
不要通过关闭 Secure 或退回 HTTP 来绕过问题,这会降低会话和支付流程的安全性。
不要让支付结果处理依赖浏览器 Session
即使解决了 SameSite 问题,支付回调也不应只依靠 Session 识别订单,原因包括:
- 用户可能清理 Cookie。
- Session 可能自然过期。
- 应用池可能重启。
- 支付返回可能延迟。
- 用户可能关闭页面后重新打开。
- 网关可能重复发送通知。
- 支付结果可能由网关服务器直接通知,而不是通过用户浏览器返回。
创建支付请求时,应将订单、用户和支付状态保存到数据库,并生成不可猜测的关联标识:
public ActionResult StartPayment()
{
var paymentAttempt = new PaymentAttempt
{
Id = Guid.NewGuid(),
UserId = GetCurrentUserId(),
OrderNo = CreateOrderNumber(),
Status = "Pending",
CreatedAtUtc = DateTime.UtcNow
};
db.PaymentAttempts.Add(paymentAttempt);
db.SaveChanges();
var callbackToken = CreateSignedCallbackToken(paymentAttempt.Id);
return Redirect(BuildGatewayUrl(
paymentAttempt.OrderNo,
callbackToken));
}
支付返回后,根据订单号或签名令牌读取数据库记录,并验证支付网关签名:
[AllowAnonymous]
[HttpPost]
public ActionResult PaymentCallback(FormCollection form)
{
if (!VerifyGatewaySignature(form))
{
return new HttpStatusCodeResult(400, "Invalid signature");
}
var orderNo = form["order_no"];
var transactionId = form["transaction_id"];
var payment = db.PaymentAttempts
.SingleOrDefault(x => x.OrderNo == orderNo);
if (payment == null)
{
return HttpNotFound();
}
if (payment.Status != "Paid")
{
payment.Status = "Paid";
payment.GatewayTransactionId = transactionId;
payment.PaidAtUtc = DateTime.UtcNow;
db.SaveChanges();
}
return RedirectToAction("Result", new { id = payment.Id });
}
字段名称和签名算法必须以实际支付网关协议为准。不能只凭浏览器提交的 order_no 或 transaction_id 确认支付成功。
更稳妥的结构如下:
- 服务器通知地址接收网关的异步通知,验证签名并更新支付状态。
- 浏览器返回地址只负责展示结果。
- 数据库中的订单记录是支付状态的可信来源。
- Session 只用于改善用户体验,不是确认付款的必要条件。
调整过滤器位置
当前过滤器会在控制器 Action 执行前检查 Session:
[CustomFilter]
[HttpPost]
public ActionResult payment(string id, FormCollection frm)
{
}
如果跨站回跳没有携带 Cookie,过滤器会立即把请求重定向到首页,支付响应代码根本没有机会执行。这既会掩盖真正的问题,也可能导致合法的支付通知得不到处理。
支付回调通常不应使用要求已有 Session 的过滤器:
[AllowAnonymous]
[HttpPost]
public ActionResult PaymentCallback(FormCollection form)
{
// 验证网关签名,根据订单号处理支付结果
}
正常的登录或 Session 检查可以保留在支付完成页:
[CustomFilter]
[HttpGet]
public ActionResult PaymentResult(Guid id)
{
// 查询数据库并展示支付结果
return View();
}
AllowAnonymous 只表示回调不依赖用户的登录状态,并不意味着可以跳过网关签名验证。
如果 Cookie 正常发送,再检查服务器 Session
如果开发者工具确认回跳请求携带了相同的 ASP.NET_SessionId,但 Session 数据仍然丢失,就要继续排查服务器端问题。
应用池回收
InProc 模式将 Session 保存在 IIS 工作进程的内存中。以下操作会导致所有 Session 丢失:
- 应用池回收。
- IIS 或服务器重启。
- 修改
web.config。 - 更新
bin目录。 - 杀毒软件或发布程序触发文件变更。
- 应用因长时间空闲而停止。
- 内存限制触发回收。
检查 IIS 应用池的回收设置、空闲超时、事件查看器和部署日志。即使系统每年只运行几个月,也要确认服务器、IIS、.NET Framework、浏览器和代理设施是否在停用期间升级过。
多实例或 Web Garden
如果应用运行在多台服务器或多个工作进程中,使用 InProc Session 时,请求可能被分配到另一个进程,因而无法读取原来的 Session 数据。
可以采用以下办法:
- 在负载均衡器上启用可靠的会话粘滞。
- 改用共享的 Session 存储。
- 将支付流程需要的状态持久化到数据库。
对于支付业务,将数据持久化到数据库通常比依赖粘滞会话更可靠。
Session 对象序列化
如果改用 StateServer 或 SQL Server Session,存入 Session 的自定义对象通常必须支持序列化,例如:
[Serializable]
public class ConferenceSession
{
public string username { get; set; }
public int cid { get; set; }
}
实际字段类型应以项目模型为准。切换 Session 模式前,还要确认对象图中的所有成员是否都支持序列化。
Session timeout 为什么没有解决问题
下面的配置只能延长服务器端 Session 的空闲时间:
<sessionState timeout="30" />
它不能解决以下问题:
- 跨站请求不发送 Cookie。
- Cookie 的域名或路径配置错误。
- HTTP 与 HTTPS 不一致。
- 应用池回收。
- 多个实例之间不共享 Session。
- 浏览器阻止第三方或跨站 Cookie。
建议按以下顺序排查:
- 确认回跳方式是
GET还是跨站POST。 - 对比支付前后的
ASP.NET_SessionId。 - 检查回跳请求是否携带 Session Cookie。
- 检查
SameSite=None; Secure和全站 HTTPS。 - 检查域名、路径及重定向链。
- 如果 Cookie 相同,再检查 IIS 回收、多实例和 Session 存储。
- 将支付状态从 Session 迁移到数据库,并通过网关签名确认支付结果。
最终要解决的并不只是让某个浏览器“不再掉 Session”,而是让支付回调在没有浏览器 Session 的情况下,也能安全、幂等地完成处理。
备注:内容仅供参考。
