PAYATHON 2026

Android 使用 Retrofit 向 Django 发送 CSRF Token 和 JSON 为何返回 403?

数据小陈

可以同时发送 JSON 请求体和 X-CSRFToken。从给出的代码看,应先检查 POST 是否带回了 GET 响应设置的 CSRF Cookie:代码只提取了 Token 并设置请求头,没有配置 Cookie 的保存和回传。在默认 Cookie 模式下,即使请求头正确,也需要 Cookie 才能通过 Django 的 CSRF 校验。

403 也可能有其他原因,例如 HTTPS 请求的来源校验失败,需要结合 Django 的 CSRF 日志确认。

为什么 Token 正确仍可能返回 403?

在默认的 CSRF_USE_SESSIONS = False 配置下,请求需要同时提供:

  • Cookie 中的 CSRF secret,默认 Cookie 名为 csrftoken。
  • 与该 secret 对应的 Token,可以通过 X-CSRFToken 请求头提交。

如果使用 Cookie 值作为请求头 Token,请求如下,尖括号中的内容需要替换为实际值:

POST /phone/login/ HTTP/1.1
Cookie: csrftoken=<csrf-cookie-value>
X-CSRFToken: <csrf-cookie-value>
Content-Type: application/json

{"username":"example","password":"example"}

Set-Cookie 是服务器要求客户端保存 Cookie 的响应头。后续请求还要通过 Cookie 请求头发送 Cookie,只把 Cookie 值复制到 X-CSRFToken 并不够。@ensure_csrf_cookie 促使服务端设置 Cookie,客户端仍需保存并发送它。Django CSRF 使用指南

为 Retrofit 配置共享 CookieJar

给出的 Retrofit.Builder() 没有指定带 Cookie 管理功能的 OkHttpClient。OkHttp 默认不接收 Cookie,因此需要显式配置 CookieJar。OkHttpClient 源码

下面是一个用于该登录流程的内存示例。它按 Cookie 的名称、域和路径更新记录,请求时筛选出未过期且匹配目标 URL 的 Cookie:

import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient

class MemoryCookieJar : CookieJar {
    private val store = mutableListOf<Cookie>()

    @Synchronized
    override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
        val now = System.currentTimeMillis()

        for (cookie in cookies) {
            store.removeAll {
                it.name == cookie.name &&
                    it.domain == cookie.domain &&
                    it.path == cookie.path
            }

            if (cookie.expiresAt > now) {
                store.add(cookie)
            }
        }
    }

    @Synchronized
    override fun loadForRequest(url: HttpUrl): List<Cookie> {
        val now = System.currentTimeMillis()
        store.removeAll { it.expiresAt <= now }
        return store.filter { it.matches(url) }
    }
}

这两个方法分别保存响应中的 Cookie 和加载请求需要的 Cookie,符合 CookieJar 的接口职责。OkHttp CookieJar 接口

将同一个实例接入 Retrofit,保留现有的接口定义和转换器配置:

val cookieJar = MemoryCookieJar()

val httpClient = OkHttpClient.Builder()
    .cookieJar(cookieJar)
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .client(httpClient)
    .addConverterFactory(ScalarsConverterFactory.create())
    .addConverterFactory(GsonConverterFactory.create())
    .build()

val service = retrofit.create(CheckInApiService::class.java)

GET 和 POST 必须使用这套共享配置,切换登录页面时不要重新创建空的 CookieJar。

从 CookieJar 获取 Token,继续用 @Body 提交 JSON

以下片段在协程中执行,沿用问题中的 LoginRequestBody,适用于默认 Cookie 名和 CSRF_USE_SESSIONS = False:

import okhttp3.HttpUrl.Companion.toHttpUrl

val response = service.getLoggedIn()
check(response.isSuccessful) {
    "GET login failed: ${response.code()}"
}

val loginUrl = requireNotNull(
    BASE_URL.toHttpUrl().resolve("phone/login/")
)

val csrfToken = cookieJar.loadForRequest(loginUrl)
    .firstOrNull { it.name == "csrftoken" }
    ?.value
    ?: error("No matching CSRF cookie received")

val result = service.requestLogin(
    csrfToken,
    LoginRequestBody(username, password)
)

实际页面流程中,可以在初始页面完成 GET,等用户点击登录时,从共享 CookieJar 读取最新 Token 再执行 POST。username 和 password 使用当前输入值。

接口可以继续保持原样:

@POST("phone/login/")
suspend fun requestLogin(
    @Header("X-CSRFToken") token: String,
    @Body loginBody: LoginRequestBody
): Response<String>

Cookie 由 OkHttp 自动附加,JSON 仍由 Gson 转换,不需要为了 CSRF 改成表单请求。

删除原来的固定位置截取:

val csrfCookieHeader = cookies?.substring(10,42) ?: "No cookie"

这种截取依赖 Cookie 名称、值长度和响应头排列,无法可靠处理多个 Set-Cookie。缺少 Cookie 时,应停止提交并排查,不要把 "No cookie" 当作 Token 发送。

JSON 中加入 Token 为什么没有解决问题?

Django 的标准 CSRF 中间件会检查表单字段 csrfmiddlewaretoken 或配置的 CSRF 请求头,不会把 JSON 请求体中的同名字段当作表单字段读取。因此,把 Token 加入 LoginRequestBody 通常不会改变校验结果。

客户端发送的头名称是 X-CSRFToken,Django 配置中的默认值则是:

CSRF_HEADER_NAME = "HTTP_X_CSRFTOKEN"

后者是请求头进入 request.META 后的名称。Django 请求头配置

视图处理 JSON 登录信息时,应从 request.body 解析,而不是从 request.POST 读取。例如,在 POST 分支中:

import json
from django.http import JsonResponse

try:
    payload = json.loads(request.body)
except (ValueError, UnicodeDecodeError):
    return JsonResponse({"error": "Invalid JSON"}, status=400)

if not isinstance(payload, dict):
    return JsonResponse({"error": "Expected a JSON object"}, status=400)

username = payload.get("username")
password = payload.get("password")

这只是请求体解析片段,参数校验和登录处理仍需实现。CSRF 校验通常发生在视图执行之前,JSON 解析代码不能替代 CSRF 修复。Django CSRF 中间件实现

查看 django.security.csrf 日志中的具体拒绝原因,再对应处理:

  1. 缺少 Cookie:确认 GET 是否设置了 Cookie,以及 POST 的协议、域名和路径是否与 Cookie 匹配。带 Secure 属性的 Cookie 不应通过 HTTP 发送。
  2. Token 缺失或不匹配:确认发送的是完整 Token,且它对应当前 Cookie。登录后 Token 会轮换,后续请求应读取更新后的值。
  3. 来源校验失败:Django 会校验已提供的 Origin;HTTPS 请求没有 Origin 时,还会检查 Referer。原生客户端可能没有自动附加这些头,所以 Cookie 和 Token 正确也可能被拒绝。Django CSRF 校验规则

如果日志确认问题出在 HTTPS 来源校验,可以让原生客户端显式发送与目标服务匹配的 Origin。例如,服务地址为 https://api.example.com/phone/login/ 时:

Origin: https://api.example.com

可通过 Retrofit 的 @Header("Origin") 参数传入。示例域名应替换为实际服务的协议、主机及必要端口,并将认证请求限定到该服务。只有确有跨源需求时,才配置相应的 CSRF_TRUSTED_ORIGINS。

如果启用了 CSRF_USE_SESSIONS = True,则不要依赖 csrftoken Cookie。此时应保留会话 Cookie,由服务端通过响应正文等方式提供 get_token(request) 的结果,再用 X-CSRFToken 提交。Django 会话模式配置

是否应该使用 @csrf_exempt?

当前端点同时服务于浏览器和应用登录,建议保留 CSRF 防护,先修复 Cookie、Token 和来源信息的传递。登录接口也可能涉及 login CSRF,不能仅因用户尚未登录就直接豁免校验。Django 对 login CSRF 的说明

原代码还有两处独立问题需要处理:已登录分支返回纯文本 "LOGGED IN",但客户端按 LoginResponse 读取,应统一响应格式;POST 分支只有 pass,通过 CSRF 后仍需返回实际响应。这些问题不会解释当前所有 403,但会影响后续登录流程。

上面的 CookieJar 仅保存在内存中,应用进程结束后会丢失。若需要跨进程保留登录状态,还需补充 Cookie 持久化、过期清理和退出登录时的清理逻辑。

备注:内容仅供参考。