You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android中使用Retrofit进行Multipart POST请求时的签名问题

Retrofit发送multipart/form-data请求的400/401异常排查与解决

问题现象

  • 仅发送包含文件的2个参数时,服务器返回400错误,提示缺少另外2个必填参数
  • 发送3个或4个参数时,服务器返回401错误,提示OAuth签名验证失败

相关代码

接口定义

@POST("api/{id}/edit")
@Multipart
fun postMultipart(
    @Path("id") id: Int,
    @Part commissioner: MultipartBody.Part,
    @Part commissioning: MultipartBody.Part,
    @Part file: MultipartBody.Part?,
    @Part commissioningDate: MultipartBody.Part
): Call<ResponseBody>

请求发起代码

val apiInterface = RetrofitSignedClient().getClient().create(Interface::class.java)

val f = getFile(context, fileUri)
val requestFile: RequestBody = f.asRequestBody()
val fileBody: MultipartBody.Part = MultipartBody.Part.createFormData("file", f.name, requestFile)

val commissionerIdPart =
    MultipartBody.Part.createFormData("commissioner", commissioner)

val commissioningPart =
    MultipartBody.Part.createFormData("commissioning", commissioning)

val datePart =
    MultipartBody.Part.createFormData("commissioningDate", date)

val response =
    apiInterface.postMultipart(
        id = id,
        commissioner = commissionerIdPart,
        commissioning = commissioningPart,
        file = fileBody,
        commissioningDate = datePart
    ).execute()

Retrofit客户端配置

val consumer = OkHttpOAuthConsumer(consumerKey, consumerSecret)
consumer.setTokenWithSecret(token, secret)

val client = OkHttpClient.Builder()
    .addInterceptor(SigningInterceptor(consumer)).build()

return Retrofit.Builder().baseUrl(WS_HOSTNAME_PROJECT)
    .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
    .client(client)
    .build()

错误日志

  • 客户端日志:401 Unauthorized {"message":"An authentication exception occurred."}
  • 服务器日志:
Verification of signature failed (signature base string was "POST&http%3A%2F%2Fmy.server.address%2Fapi%2FID%2Fedit&commissioner%3D110000000292%26commissioning%3D0%26oauth_consumer_key%3DXXXX%26oauth_nonce%3DXXXX%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3DXXX%26oauth_token%3DXXXX%26oauth_version%3D1.0"). with Array ( [0] => 7992a0f54cc2104fec647c75a9aa8317 [1] => 1b1fea099c727b89d9c0cb9d9a618608 [2] => access ) -- Signature verification failed (HMAC-SHA1)

排查与解决

1. 400错误原因:参数缺失

服务器明确提示缺少参数,说明该接口要求commissioner、commissioning、commissioningDate和file为必填项,必须全部传入;若file允许为空,需按接口定义传null,需提前确认服务器规则。

2. 401错误核心原因:OAuth签名未包含所有表单参数

从服务器日志的签名基串可见,仅包含commissioner和commissioning两个参数,缺少commissioningDate参数,导致客户端生成的签名与服务器验证逻辑不匹配。

默认的OkHttpOAuthConsumer和SigningInterceptor仅支持普通表单请求(application/x-www-form-urlencoded),无法自动解析multipart/form-data中的非文件表单字段,导致这些参数未被纳入签名计算。

解决方法:自定义签名拦截器,手动提取multipart表单参数

修改SigningInterceptor,在处理multipart/form-data请求时,遍历所有part,提取非文件的表单参数并加入签名计算:

class SigningInterceptor(private val consumer: OkHttpOAuthConsumer) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val modifiedRequest = if (originalRequest.body() is MultipartBody) {
            val multipartBody = originalRequest.body() as MultipartBody
            val formParams = mutableMapOf<String, String>()

            // 遍历所有part,提取非文件表单参数
            for (part in multipartBody.parts()) {
                val contentDisposition = part.headers()["Content-Disposition"] ?: continue
                // 解析表单字段名
                val nameRegex = Regex("name=\"([^\"]+)\"")
                val nameMatch = nameRegex.find(contentDisposition) ?: continue
                val paramName = nameMatch.groupValues[1]

                // 跳过文件类型的part(OAuth签名不包含文件内容)
                val body = part.body()
                if (body.contentType()?.type == "application" || body.contentType()?.subtype == "octet-stream") {
                    continue
                }

                // 读取非文件参数的内容
                val buffer = Buffer()
                body.writeTo(buffer)
                val paramValue = buffer.readUtf8()
                formParams[paramName] = paramValue
            }

            // 将提取的表单参数添加到OAuth consumer中,用于计算签名
            consumer.setAdditionalParameters(formParams)
            consumer.sign(originalRequest)
        } else {
            // 普通请求直接签名
            consumer.sign(originalRequest)
        }

        return chain.proceed(modifiedRequest)
    }
}

3. 额外验证点

  • 确认所有参数的编码格式与服务器一致,尤其是日期格式commissioningDate,避免因格式错误导致签名计算偏差
  • 检查OAuth的nonce和timestamp是否为每次请求唯一值(默认OkHttpOAuthConsumer已处理,但需确保未被固定)
  • 验证签名基串的参数排序:OAuth要求参数按字典序排序,确保客户端和服务器的排序逻辑一致

内容的提问来源于stack exchange,提问作者Romain Barbier

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.19 23:27:06