Django Oscar 支付网关外部重定向后 Session 丢失
结论
HttpResponseRedirect() 不会清空 Session,也不会注销用户。问题通常出在支付网关回调与 ThankYou 页面之间:浏览器没有携带原来的 sessionid Cookie,或者回调代码创建、覆盖或删除了 Session。
常见原因有三种:
- 支付网关发送的是服务器到服务器的 webhook。这类请求不包含用户浏览器的 Session Cookie,因此
request.user为 anonymous 是正常现象。 - 支付网关通过跨站表单 POST 将浏览器送回网站。受 Cookie 的
SameSite、Secure和域名等属性影响,浏览器可能不会发送sessionid。 get_success_url()跳转到了不同的协议、域名或子域名,ThankYou 请求因而无法使用原来的 Cookie。
不要依赖支付回调请求中的 Django Session 来识别用户或确认订单。应通过订单号、支付流水号和签名把支付结果关联到订单,并将浏览器返回页面与 webhook 分开处理。
先确认回调到底是哪一种
支付网关通常提供两类通知:
- 浏览器返回地址:支付完成后,将用户浏览器送回网站。
- 服务端通知地址:支付网关服务器主动向网站发送 webhook。
如果收到的是 webhook,请求来自支付网关服务器,而非用户浏览器,所以不会携带用户的 sessionid:
def payment_webhook(request):
print(request.user) # AnonymousUser 是正常现象
即使用户仍在另一个浏览器标签页中保持登录,服务器也无法根据 webhook 找到该用户的浏览器 Session。
应当先验证回调签名,再从回调数据中取得商户订单号并查询本地订单:
from django.http import HttpResponse, HttpResponseBadRequest
from oscar.core.loading import get_model
Order = get_model("order", "Order")
def payment_webhook(request):
payload = request.POST
if not verify_gateway_signature(payload):
return HttpResponseBadRequest("Invalid signature")
order_number = payload.get("merchant_order_number")
transaction_id = payload.get("transaction_id")
try:
order = Order.objects.get(number=order_number)
except Order.DoesNotExist:
return HttpResponseBadRequest("Unknown order")
# 更新支付状态时必须考虑重复通知,保证幂等。
record_successful_payment(order, transaction_id, payload)
return HttpResponse("OK")
不要使用以下值识别 webhook 对应的订单:
request.user
request.session["basket_id"]
request.session["order_number"]
这些值可能不存在,也不能用来证明支付结果真实有效。
检查跳转前后的 sessionid
如果回调确实由用户浏览器发起,可以在浏览器开发者工具的 Network 面板中依次检查:
- 离开本站前的请求是否携带
sessionid。 - 支付网关返回自定义 URL 时,Request Headers 中是否包含
Cookie: sessionid=...。 - 回调响应是否包含意外的
Set-Cookie,例如覆盖或删除sessionid。 HttpResponseRedirect返回的Location是否指向预期的协议和域名。- ThankYou 请求携带的
sessionid是否与结账前相同。
也可以临时记录 Session 标识,但不要把完整 Cookie 或支付数据写入生产日志:
import logging
logger = logging.getLogger(__name__)
def payment_return(request):
logger.info(
"payment return host=%s secure=%s user=%s session_key=%s",
request.get_host(),
request.is_secure(),
request.user.pk if request.user.is_authenticated else None,
request.session.session_key,
)
response = HttpResponseRedirect(get_success_url())
logger.info("redirect location=%s", response["Location"])
return response
在 ThankYou view 中记录同样的信息并进行对照。如果两个请求的 Session key 不同,说明浏览器没有发送旧 Cookie,或者响应创建了新的 Session。
检查 Cookie 的跨站策略
现代 Django 版本通常将 Session Cookie 的 SameSite 配置为 Lax。多数跨站顶层 GET 导航可以携带这类 Cookie,但跨站 POST 可能不会携带。
如果支付网关通过浏览器表单 POST 返回网站,这项配置尤其值得检查。确实需要让跨站 POST 携带 Session Cookie 时,可以在 HTTPS 环境中配置:
SESSION_COOKIE_SAMESITE = "None"
SESSION_COOKIE_SECURE = True
如果 CSRF Cookie 也有相同的跨站需求,可能还需要:
CSRF_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SECURE = True
需要注意:
SameSite=None通常必须与Secure配合使用。SecureCookie 只会通过 HTTPS 发送。- 放宽
SameSite会让更多跨站请求携带 Cookie,不能替代支付签名验证和 CSRF 防护。 - webhook 通常使用网关签名、共享密钥或证书验证,而不是 CSRF token。
- 如果网关支持,浏览器返回地址最好使用顶层 GET,服务端支付通知则使用单独的 POST webhook。
如果网关先通过跨站 POST 请求回调地址,本站再返回 302 跳转到 ThankYou,那么第一次 POST 可能没有 Session,随后的同站 GET 则可能重新携带原 Cookie。如果 ThankYou 页面仍显示匿名用户,应重点检查回调响应是否覆盖了 Cookie,以及跳转目标是否更换了域名。
检查协议、域名和代理配置
以下地址对应的 Cookie 作用域可能不同:
https://example.com
https://www.example.com
https://checkout.example.com
http://example.com
支付网关的返回地址和 get_success_url() 应使用统一的 HTTPS 主机名。最好返回站内相对路径,以免根据错误的 Host 或代理协议拼出外部 URL:
from django.urls import reverse
def get_success_url(self):
return reverse("checkout:thank-you")
如果确实需要跨子域共享 Session Cookie,可以配置:
SESSION_COOKIE_DOMAIN = ".example.com"
只有需要跨子域共享 Session 时才应这样设置。同时还要确认相同域名下的其他应用不会写入同名 sessionid。
如果网站位于反向代理之后,还要确认 Django 能正确识别 HTTPS,例如:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
只有当代理会可靠地设置并清理对应请求头时,这项配置才是安全的,否则可能导致错误的安全判断。
检查是否主动清除了 Session
检查支付回调、Oscar 扩展代码和自定义中间件,看其中是否调用了:
request.session.flush()
request.session.clear()
request.session.delete()
logout(request)
还要检查代码是否直接删除了认证字段:
del request.session["_auth_user_id"]
以下情况也可能让 Django 的登录状态失效:
- Session 记录已经过期或被删除。
- 使用缓存型 Session backend,而缓存发生了重启或淘汰。
- 多实例部署中的
SECRET_KEY不一致。 - 使用签名 Cookie Session,但部署期间更换了
SECRET_KEY。 - 自定义认证后端发生变化,导致 Session 中记录的 backend 无法加载。
- 回调响应设置了新的同名 Cookie,覆盖了原来的
sessionid。
可以检查这些配置:
SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_COOKIE_NAME = "sessionid"
使用数据库 Session backend 时,还应确认 django_session 中的对应记录仍然存在,并排查异常的定时清理或数据库连接问题。
Django Oscar 中的处理方式
Oscar 的 BasketMiddleware 不负责恢复登录用户。Django 的 Session 和 Authentication 中间件处理完当前请求后,它才会根据请求管理 basket。下面的核心顺序基本合理:
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'oscar.apps.basket.middleware.BasketMiddleware',
因此,只调整 BasketMiddleware 的位置,通常无法解决跨站 Cookie 或 webhook 没有 Session 的问题。
更稳妥的支付流程可以这样设计:
- 用户离站前,在数据库中创建订单或待支付记录。
- 生成不可猜测的支付尝试标识,并将其关联到订单和用户。
- 将商户订单号传给支付网关。
- webhook 验证签名后,根据商户订单号更新支付状态。
- 浏览器返回地址只展示结果,不负责最终确认支付。
- 已登录用户可以根据订单所属用户查询。未登录用户则使用短期有效且经过签名的返回令牌查询展示结果。
例如,浏览器返回地址可以携带签名令牌,不再依赖 Session:
from django.core import signing
from django.shortcuts import get_object_or_404, redirect
from oscar.core.loading import get_model
Order = get_model("order", "Order")
def create_return_token(order):
return signing.dumps(
{"order_number": order.number},
salt="payment-return",
)
def payment_return(request):
token = request.GET.get("token")
try:
data = signing.loads(
token,
salt="payment-return",
max_age=30 * 60,
)
except signing.BadSignature:
return redirect("checkout:payment-error")
order = get_object_or_404(Order, number=data["order_number"])
return redirect(
"checkout:thank-you",
order_number=order.number,
)
该令牌只能用于定位需要展示的对象。支付成功状态仍须以验证过的网关通知为准,同时要防止令牌泄露,并限制令牌的有效期和可访问数据。
一个容易误判的现象
在执行 HttpResponseRedirect() 前,request.user 仍显示为已登录,并不代表下一次请求一定能恢复同一个 Session。request.user 是 AuthenticationMiddleware 在当前请求开始时解析出的对象。返回 302 响应后,浏览器会发起一个全新的请求。
新请求能否保持登录,取决于浏览器是否向 Location 指向的地址发送有效的 sessionid Cookie。因此,排查时应关注:
- 回调是 webhook 还是浏览器请求。
- 两次请求的 Session Cookie 是否相同。
- 302 响应是否覆盖了 Cookie。
Location是否改变了协议、域名或子域名。- Session 记录是否仍然有效。
将 webhook 与浏览器返回流程分开,再通过订单号和网关签名关联支付结果,就能避免支付流程依赖跨站 Session。
备注:内容仅供参考。
