Android 中如何处理 3d-secure redirect response?
结论
不要完全依赖 WebView 接收“验证完成通知”。建议采用以下方案:
- 优先使用支付服务商提供的 Android SDK 或 3DS2 SDK。
- 需要跳转网页时,通过 Chrome Custom Tabs 或系统浏览器完成验证。
- 将支付回调地址配置为 HTTPS App Link,或者由服务端跳转到应用的自定义 URI。
- 应用收到回跳后,只提取交易标识,再向服务端查询支付结果。
- 如果用户从 BankID 手动返回应用,但没有触发回跳,可在
onResume()中查询状态或进行短时轮询。
如果业务上必须使用 WebView,可以通过 WebViewClient.shouldOverrideUrlLoading() 拦截回调 URL,同时处理 BankID 等外部应用的 URI。不过,这种方式无法覆盖所有 3D Secure 流程。
为什么 WebView 可能收不到回调
3D Secure 验证通常涉及多个参与方:
- Android 应用
- 支付服务商
- 发卡行的 ACS 页面
- BankID 等外部身份验证应用
- 商户服务端
用户离开 WebView 并打开 BankID 后,验证结果通常会先发送给支付平台或商户服务端。后续是否重新导航到原来的 WebView,取决于支付服务商、ACS 页面和回调地址的配置。
所以,BankID 显示“验证成功”,不代表原来的 WebView 一定会收到新的页面请求。实际流程中还可能出现以下情况:
- 验证完成后只更新服务端状态,并不跳回应用。
- 回调在系统浏览器中打开,而不是原来的
WebView。 - 回调使用自定义 scheme,但
WebView没有拦截。 - 外部应用返回后,页面仍需依靠 JavaScript、Cookie 或重定向链继续执行。
- 用户在另一台设备上完成 BankID 验证,当前 Android 设备不会收到应用切换事件。
- 3DS 页面禁止嵌入,或者依赖第三方 Cookie、弹窗和多窗口。
- Android 进程在外部验证期间被系统回收。
因此,支付结果必须由服务端确认,不能只根据某个网页 URL 判断是否成功。
推荐方案:浏览器验证加 App Link 回跳
假设支付服务商允许设置以下返回地址:
https://pay.example.com/3ds/return?payment_id=abc123
服务端处理该地址后,可以显示完成页面,也可以通过 Android App Link 将用户带回应用。应用收到链接后,再向自己的服务端查询支付状态。
在 Manifest 中声明 App Link
<activity
android:name=".PaymentResultActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="pay.example.com"
android:pathPrefix="/3ds/return" />
</intent-filter>
</activity>
使用 android:autoVerify="true" 时,还需要在对应域名部署正确的文件:
https://pay.example.com/.well-known/assetlinks.json
如果暂时无法配置 App Link,也可以使用自定义 scheme,例如:
myapp://payment/3ds-return?payment_id=abc123
不过,其他应用也可能声明同一个自定义 scheme,其安全性和归属验证能力都不如 HTTPS App Link。
启动 Custom Tabs
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
fun openThreeDSecurePage(activity: Activity, verificationUrl: String) {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(activity, Uri.parse(verificationUrl))
}
Custom Tabs 使用真实的浏览器环境,对 Cookie、JavaScript、外部应用跳转和安全策略的兼容性通常优于 WebView,更适合银行认证页面。
接收回跳链接
class PaymentResultActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleCallback(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleCallback(intent)
}
private fun handleCallback(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme == "https" &&
uri.host == "pay.example.com" &&
uri.path == "/3ds/return"
) {
val paymentId = uri.getQueryParameter("payment_id") ?: return
// 不要仅根据 URL 中的 status 判断支付成功。
// 使用 paymentId 向自己的服务端查询最终支付状态。
queryPaymentStatus(paymentId)
}
}
private fun queryPaymentStatus(paymentId: String) {
// 调用商户服务端 API。
// 服务端再通过支付服务商 API 或 webhook 结果确认支付状态。
}
}
回调参数是不可信输入。应用需要严格检查 scheme、host 和 path,并确认支付会话与当前用户匹配。
必须使用 WebView 时的处理方式
如果支付服务商明确支持在嵌入式浏览器中运行 3DS,可以设置 WebViewClient,在每次导航时检查页面是否到达预先约定的回调地址。
@SuppressLint("SetJavaScriptEnabled")
fun configurePaymentWebView(
webView: WebView,
expectedPaymentId: String
) {
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
return handleUrl(view.context, request.url, expectedPaymentId)
}
@Deprecated("Used for older Android WebView implementations")
override fun shouldOverrideUrlLoading(
view: WebView,
url: String
): Boolean {
return handleUrl(view.context, Uri.parse(url), expectedPaymentId)
}
}
}
private fun handleUrl(
context: Context,
uri: Uri,
expectedPaymentId: String
): Boolean {
val isReturnUrl =
uri.scheme == "https" &&
uri.host == "pay.example.com" &&
uri.path == "/3ds/return"
if (isReturnUrl) {
val paymentId = uri.getQueryParameter("payment_id")
if (paymentId == expectedPaymentId) {
// 关闭或隐藏 WebView,然后向服务端查询支付状态。
queryPaymentStatusFromServer(paymentId)
}
return true
}
if (uri.scheme != "http" && uri.scheme != "https") {
return openExternalApplication(context, uri)
}
// 返回 false,让 WebView 正常加载 HTTP/HTTPS 页面。
return false
}
外部应用 URI 可以交给 Android 处理:
private fun openExternalApplication(context: Context, uri: Uri): Boolean {
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
}
return try {
context.startActivity(intent)
true
} catch (e: ActivityNotFoundException) {
false
}
}
不要无条件执行所有非 HTTP URL。应用只应允许支付流程明确需要的 scheme,并验证来源和目标,否则恶意页面可能借此启动非预期的应用或 Intent。
处理用户从 BankID 返回但没有 URL 回调
在同一设备上完成验证时,应用通常会经历 onPause() 和 onResume()。应用恢复到前台后,可以重新查询尚未完成的支付:
class CheckoutActivity : AppCompatActivity() {
private var pendingPaymentId: String? = null
private var externalVerificationStarted = false
override fun onResume() {
super.onResume()
val paymentId = pendingPaymentId ?: return
if (!externalVerificationStarted) return
refreshPaymentStatus(paymentId)
}
private fun refreshPaymentStatus(paymentId: String) {
// 向商户服务端查询:
// pending、authorized、failed、cancelled、expired 等状态。
}
}
onResume() 只表示应用重新进入前台,并不代表验证成功,因此不能在这里直接把订单标记为已支付。
如果用户在另一台设备上完成 BankID 验证,当前设备通常不会发生生命周期变化,也收不到深链回跳。这类场景需要采用以下方式之一:
- 支付服务商向商户服务端发送 webhook。
- 客户端定期查询商户服务端。
- 使用服务端推送机制通知客户端重新查询。
轮询需要设置查询间隔、超时和终止条件。例如,只在支付状态为 pending 时继续查询,并在成功、失败、取消或过期后立即停止。
服务端应当作为最终状态来源
完整流程可以这样设计:
- 应用向商户服务端创建支付。
- 服务端返回
paymentId和 3DS 验证地址。 - 应用通过支付 SDK 或 Custom Tabs 打开验证页面。
- 用户在 BankID 中完成操作。
- 支付服务商通过 API 状态或 webhook 更新服务端记录。
- 浏览器通过 App Link 返回应用。如果没有回跳,应用恢复前台后主动查询。
- 应用向商户服务端查询
paymentId的最终状态。 - 只有服务端确认状态为
authorized、captured或支付平台定义的等价成功状态,应用才更新订单。
以下信息不能单独用于判定支付成功:
- 回调 URL 中的
success=true - WebView 显示的“支付成功”文字
- JavaScript 回调
- 用户从 BankID 返回应用
- 客户端自行保存的支付状态
这些信号只能用来触发一次服务端状态查询。
其他注意事项
- 优先遵循支付服务商针对 Android 和 3DS2 的集成文档。部分服务商明确不支持嵌入式
WebView。 - 不要使用
addJavascriptInterface()接收不受信任支付页面的数据,以免扩大攻击面。 - 不要关闭 TLS 校验,也不要在
onReceivedSslError()中无条件调用handler.proceed()。 - 为支付会话设置一次性随机
state或具有同等作用的关联值,避免其他深链冒充当前支付回调。 - 保存待处理的
paymentId,以便在进程被系统回收后恢复支付流程,但不要在本地保存卡片信息或敏感认证数据。 - 对返回、取消、网络中断、重复回调和超时进行幂等处理。
- 如果验证流程依赖弹窗或新窗口,
WebView可能还需要配置WebChromeClient并处理多窗口。不过,这通常说明 Custom Tabs 更适合该流程。 - BankID 的具体 URI、启动参数和返回行为应以接入的 BankID 或支付服务商协议为准,不能假设所有验证流程都会返回同一种 callback。
回跳只负责把用户带回应用,支付结果则由服务端查询确认。将这两个环节分开,才能覆盖 WebView 没有收到导航、用户在另一台设备上完成验证,以及 Android 进程被系统回收等情况。
备注:内容仅供参考。
