返回

修复 Custom Tabs Cookie 不生效:推荐服务端 Token 方案

Android

解决 Android Custom Tabs 中 Cookie 不生效的问题:跨应用认证方案

不少安卓开发者都遇到过这样的场景:在 App 里点击一个按钮,需要用浏览器打开一个网页(比如用户个人资料页),并且希望用户在这个网页里保持登录状态。自然而然地,大家会想到传递 Cookie。但尝试使用 CookieManager 设置 Cookie 后,再通过 CustomTabsIntent 打开链接,却发现网页还是跳转到了登录页——Cookie 似乎没生效。

就像下面这段常见的代码,逻辑看起来没问题,但实际效果却不尽人意:

// 尝试在启动 Custom Tabs 前设置 Cookie(通常无效)
fun openInAppBrowserWithCookiesAttempt(context: Context, url: String) {

    val cookieManager = CookieManager.getInstance()

    // 假设 TokenManager 能获取到所需认证信息
    val token = TokenManager.getToken(context)
    val sessionId = TokenManager.getSessionId(context)
    // ... 其他可能需要的 Cookie 值

    cookieManager.setAcceptCookie(true) // 允许 Cookie

    // 尝试为目标 URL 设置 Cookie
    // 注意:这里的 Cookie 字符串格式需要符合规范 "key=value; ..."
    val cookieString = "token=$token; session_id=$sessionId; path=/; domain=yourdomain.com;"
    cookieManager.setCookie(url, cookieString)

    // 确保 Cookie 变更被写入存储
    // 虽然调用了 flush,但这并不能保证 Custom Tabs 会使用这些 Cookie
    cookieManager.flush()

    // 构建并启动 Custom Tabs
    val customTabsIntent = CustomTabsIntent.Builder().build()
    customTabsIntent.launchUrl(context, Uri.parse(url))
}

点击按钮后,用户看到的不是已登录的个人资料页,而是冰冷的登录框。这究竟是为什么呢?

问题分析:Cookie 隔离的真相

要弄明白这个问题,得先了解 CookieManagerCustomTabsIntent 的工作机制。

  1. CookieManager 的主场:WebView
    CookieManager 是安卓系统提供的用于管理 WebView Cookie 的单例。你在 App 内部创建和使用的 WebView 加载网页时,会遵循 CookieManager 设置的规则,并且共享同一个 Cookie 存储。所以,如果在 WebView 加载 URL 前用 CookieManager.setCookie() 设置了对应的 Cookie,WebView 发起请求时确实会带上这些 Cookie。

  2. CustomTabsIntent 的背后:独立的浏览器进程
    CustomTabsIntent (通常称为 Chrome Custom Tabs 或简称 CCT)并非一个简单的 WebView 封装。它实际上是启动了用户设备上默认浏览器 的一个“轻量级”实例或者一个高度定制化的 Activity。关键在于,这个浏览器实例通常运行在独立的进程 中,拥有自己的 Cookie 存储,和你的 App 以及 App 内部的 WebView 的 Cookie 存储是隔离 的。

    简单说,你在 App 进程里调用 CookieManager.setCookie(),修改的是 App 自己的 WebView Cookie 罐子。而 CustomTabsIntent 启动的那个浏览器实例,并不会(或者说,通常不保证会)去翻看你 App 的 Cookie 罐子。它们是两个不同的世界。

所以,上面代码中的 cookieManager.setCookie(url, cookieString)cookieManager.flush(),仅仅影响了 App 内部的 WebView 环境(如果 App 里有 WebView 的话)。对于即将通过 CustomTabsIntent 启动的外部浏览器实例,这些操作大概率是无效的。

可行的解决方案

既然直接在 App 端设置 Cookie 然后传递给 Custom Tabs 这条路基本走不通,我们需要换个思路来实现跨应用(App 到浏览器)的认证。

方案一:服务端 Token 交换(推荐)

这是目前最标准、最安全、最可靠的方案。核心思想是:不在客户端尝试传递敏感的、长效的认证凭证(如 Session ID 或主 Token),而是生成一个临时的、一次性的授权 Token,通过 URL 参数传递给 Web 端,由 Web 端验证这个临时 Token 后,再负责设置真正的 Session Cookie。

原理与流程:

  1. App 端准备:

    • 用户点击按钮时,App 向自己的后端服务发起一个请求,告知“我要为当前用户生成一个用于跳转到 Web 端的一次性登录凭证”。
    • App 后端验证当前用户的身份(基于 App 本身的有效 Token/Session),生成一个短效、一次性使用one_time_token(或者叫 auth_code 之类的名字)。这个 Token 最好与用户身份绑定,并设置一个较短的过期时间(比如 60 秒)。
    • App 后端将这个 one_time_token 返回给 App。
  2. App 端启动 Custom Tabs:

    • App 拿到 one_time_token 后,将其作为 URL 查询参数拼接到目标网页 URL 后面。例如,目标 URL 是 https://yourdomain.com/profile,那么拼接后的 URL 就是 https://yourdomain.com/profile?ott=GENERATED_ONE_TIME_TOKEN
    • App 使用这个拼接后的 URL 通过 CustomTabsIntent 启动浏览器。
  3. Web 端处理:

    • 用户设备上的浏览器加载 https://yourdomain.com/profile?ott=GENERATED_ONE_TIME_TOKEN
    • Web 应用的后端 (比如处理 /profile 路由的控制器或中间件)检查 URL 中是否存在 ott 参数。
    • 如果存在 ott 参数,Web 后端拿着这个 ott 值去验证其有效性(是否存在、是否过期、是否已被使用、是否与某个用户匹配)。这通常需要查询生成该 Token 时记录的数据。
    • 验证通过后,Web 后端明确了是哪个用户通过 App 跳转过来的。此时,Web 后端主动 为这个用户的浏览器会话设置标准的 Session Cookie (或者其他用于 Web 端认证的 Cookie),就像用户正常登录成功后那样。这通过在 HTTP 响应头中添加 Set-Cookie 指令完成。
    • 设置完 Cookie 后,Web 后端可以重定向到原始的目标页面(https://yourdomain.com/profile,不带 ott 参数),或者直接渲染该页面的内容。
    • 浏览器收到带有 Set-Cookie 头和页面内容的响应。它会自动存储这些 Cookie。
    • 当页面加载完成,或者后续用户在该域名下进行其他操作时,浏览器会自动带上刚刚由服务器设置的 Session Cookie,用户表现为已登录状态。

App 端 Kotlin 代码示例:

import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// 假设你有一个 API 服务类 ApiService 来与你的后端交互
// 假设你的 ApiService 有一个方法 getOneTimeToken(): String

suspend fun generateAndOpenWithOneTimeToken(context: Context, targetUrl: String) {
    try {
        // 1. 从你的后端获取一次性 Token (这是耗时操作,应在后台线程)
        val oneTimeToken = withContext(Dispatchers.IO) {
            // Replace with your actual API call
            YourApiService.instance.getOneTimeTokenForWebAuth()
        }

        if (oneTimeToken.isNotEmpty()) {
            // 2. 拼接带有一次性 Token 的 URL
            val urlWithToken = Uri.parse(targetUrl)
                .buildUpon()
                .appendQueryParameter("ott", oneTimeToken) // 'ott' 是你和后端约定的参数名
                .build()
                .toString()

            // 3. 使用 CustomTabsIntent 打开拼接后的 URL
            val customTabsIntent = CustomTabsIntent.Builder().build()
            customTabsIntent.launchUrl(context, Uri.parse(urlWithToken))

        } else {
            // 处理获取 Token 失败的情况,例如提示用户或尝试重新登录
            Log.e("Auth", "Failed to get one-time token.")
        }

    } catch (e: Exception) {
        // 处理网络请求或其他异常
        Log.e("Auth", "Error during one-time token process", e)
    }
}

// 在你的按钮点击事件或其他触发点调用 (需要在一个 CoroutineScope 内)
fun onProfileButtonClick(context: Context, profileUrl: String) {
    GlobalScope.launch { // 注意:实际项目中建议使用 ViewModelScope 或 lifecycleScope
        generateAndOpenWithOneTimeToken(context, profileUrl)
    }
}

Web 端 (Node.js/Express 示例概念):

// 假设这是处理 '/profile' 路由的中间件或控制器
app.get('/profile', async (req, res, next) => {
    const oneTimeToken = req.query.ott;

    if (oneTimeToken) {
        try {
            // 1. 验证一次性 Token (查询数据库/缓存,检查是否有效、未过期、未使用)
            const user = await validateOneTimeToken(oneTimeToken); // 返回用户信息或 null

            if (user) {
                // 2. Token 有效,为该用户设置 Web Session Cookie
                req.session.userId = user.id; // 使用 express-session 或类似库
                // 或者手动设置其他 Cookie
                // res.cookie('session_id', generateSessionId(user.id), { httpOnly: true, secure: true, maxAge: ... });

                // (可选) 标记 Token 已使用
                await markTokenAsUsed(oneTimeToken);

                // 3. 重定向到干净的 URL 或直接渲染页面
                // 重定向方式:
                return res.redirect('/profile');
                // 直接渲染方式(确保渲染前已设置好 Cookie):
                // return res.render('profile', { user: user });

            } else {
                // Token 无效或已过期
                console.warn("Invalid or expired one-time token:", oneTimeToken);
                // 可以重定向到登录页,或显示错误信息
                return res.redirect('/login?error=invalid_ott');
            }
        } catch (error) {
            console.error("Error validating one-time token:", error);
            return res.status(500).send("Internal server error");
        }
    }

    // 如果没有 'ott' 参数,或者 'ott' 处理后需要继续执行:
    // 检查现有 Session 是否有效
    if (req.session.userId) {
        // 用户已登录 (可能是通过这次 ott 流程刚登录,或之前已登录)
        const user = await getUserById(req.session.userId);
        res.render('profile', { user: user });
    } else {
        // 用户未登录,重定向到登录页
        res.redirect('/login');
    }
});

安全建议:

  • HTTPS 必须的: 整个流程,包括 App 请求 Token、跳转 URL、Web 端处理,都必须使用 HTTPS,防止 Token 在传输过程中被截获。
  • Token 短效性: 一次性 Token 的有效期应该非常短,比如 30-60 秒,足够完成跳转和验证即可。
  • Token 一次性: 后端必须确保每个 Token 只能成功使用一次。验证通过后立即标记为已用或删除。
  • Token 强度: Token 应使用安全的随机字符串生成,不易被猜测。长度至少 32 个字符以上。
  • 绑定用户/设备: 生成 Token 时可以考虑与请求的 App 实例或设备信息进行某种形式的绑定,增加安全性(可选,增加复杂度)。
  • Web 端 Session Cookie 安全: Web 端设置的 Session Cookie 务必使用 HttpOnlySecure (仅限 HTTPS)、SameSite (通常设为 LaxStrict) 等安全属性。

进阶技巧:

  • 错误处理: App 端和 Web 端都需要有健壮的错误处理机制,比如 Token 获取失败、Token 验证失败、网络异常等情况,给用户明确的反馈。
  • 用户体验: App 获取 Token 的过程应该是异步的,不阻塞 UI。可以显示加载指示器。

方案二:使用 WebView 代替 Custom Tabs

如果你的需求场景并不强制要求使用用户完整的浏览器环境(比如不需要浏览器的密码管理器、翻译、分享等原生功能),并且你希望对加载环境有更强的控制力,可以考虑直接在 App 内部使用 WebView 来加载网页。

原理:

因为 WebViewCookieManager 共享同一个 Cookie 存储,所以你在加载 WebView 前通过 CookieManager 设置的 Cookie,WebView 发起请求时会自然带上。

代码示例:

import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity

class WebViewActivity : AppCompatActivity() {

    @SuppressLint("SetJavaScriptEnabled") // 注意: 仅在确实需要 JS 时开启,并注意安全风险
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val webView = WebView(this)
        setContentView(webView)

        val url = intent.getStringExtra("URL_TO_LOAD") ?: "https://yourdomain.com/default"
        val token = intent.getStringExtra("AUTH_TOKEN") ?: ""
        val sessionId = intent.getStringExtra("SESSION_ID") ?: ""
        // ... 获取其他需要的 Cookie 值

        // 1. 设置 Cookie (在加载 URL 之前!)
        val cookieManager = CookieManager.getInstance()
        cookieManager.setAcceptCookie(true)

        // 示例: 构造 Cookie 字符串 (domain 和 path 很重要)
        // 确保 domain 与你的 URL 匹配,或者设为其父域
        // path 通常设为 '/'
        val domain = ".yourdomain.com" // 使用主域名通常更通用
        cookieManager.setCookie(url, "token=$token; path=/; domain=$domain;")
        cookieManager.setCookie(url, "session_id=$sessionId; path=/; domain=$domain;")
        // ... 设置其他 Cookie

        // 立即同步 Cookie (对某些 Android 版本可能重要)
        cookieManager.flush()

        // 2. 配置 WebView
        webView.settings.javaScriptEnabled = true // 如果页面需要 JS
        webView.webViewClient = WebViewClient() // 处理页面导航等

        // 3. 加载 URL
        webView.loadUrl(url)
    }

    companion object {
        // 启动这个 Activity 的辅助方法
        fun start(context: Context, url: String, token: String, sessionId: String) {
            val intent = Intent(context, WebViewActivity::class.java).apply {
                putExtra("URL_TO_LOAD", url)
                putExtra("AUTH_TOKEN", token)
                putExtra("SESSION_ID", sessionId)
                // ... 传递其他 Cookie 值
            }
            context.startActivity(intent)
        }
    }
}

// 在按钮点击事件中调用:
fun onProfileButtonClickWithWebView(context: Context, profileUrl: String) {
    val token = TokenManager.getToken(context)
    val sessionId = TokenManager.getSessionId(context)
    WebViewActivity.start(context, profileUrl, token, sessionId)
}

优缺点:

  • 优点:
    • Cookie 控制直接有效。
    • 对加载环境有完全控制(可以注入 JS,拦截请求等)。
    • UI 可以与 App 更深度集成。
  • 缺点:
    • 用户体验不如 Custom Tabs(没有地址栏、分享按钮、密码管理等浏览器特性)。
    • 潜在的安全风险比 Custom Tabs 高(如 JS 注入、不安全的 addJavascriptInterface 使用)。
    • 需要自己处理加载状态、错误页、导航逻辑等。
    • 无法利用用户在主浏览器中可能已经存在的登录状态。

安全建议:

  • 最小化 JS 权限: 如果不需要 JavaScript,务必通过 webView.settings.javaScriptEnabled = false 关闭。
  • 谨慎使用 addJavascriptInterface: 这个接口风险很高,如果必须使用,严格限制暴露的方法和参数。
  • 处理 SSL 错误: 不要覆盖 onReceivedSslError 方法来盲目接受所有证书,这会使中间人攻击成为可能。
  • 内容限制: 考虑限制 WebView 只能加载特定域名的内容。

方案三:关于 CustomTabsSession 预热(非直接解决方案)

你可能看到过 CustomTabsSession 相关的 API,比如 CustomTabsClient.bindCustomTabsService, warmup(), newSession(), mayLaunchUrl()。这些 API 主要用于性能优化 ,通过预先连接到浏览器服务、预热浏览器进程、甚至预加载目标 URL,来加快 Custom Tabs 的启动速度。

重要提示: CustomTabsSession 并不能 用来直接向 Custom Tabs 实例注入 Cookie。它解决的是启动速度问题,而不是跨应用认证问题。

虽然它不是直接的解决方案,但在使用了上述方案一(服务端 Token 交换)后,结合 CustomTabsSession 的预热,可以提供更流畅的用户体验。

代码示例(仅展示预热流程,需配合方案一使用):

import androidx.browser.customtabs.*
// ... 其他 imports

class CustomTabHelper(private val context: Context) : CustomTabsServiceConnection() {

    private var customTabsClient: CustomTabsClient? = null
    private var customTabsSession: CustomTabsSession? = null

    fun bindCustomTabsService() {
        val packageName = CustomTabsClient.getPackageName(context, null) // 获取默认浏览器包名
        if (packageName != null) {
            CustomTabsClient.bindCustomTabsService(context, packageName, this)
        }
    }

    fun unbindCustomTabsService() {
        context.unbindService(this)
        customTabsClient = null
        customTabsSession = null
    }

    override fun onCustomTabsServiceConnected(name: android.content.ComponentName, client: CustomTabsClient) {
        customTabsClient = client
        customTabsClient?.warmup(0L) // 预热浏览器进程
        customTabsSession = customTabsClient?.newSession(null) // 创建会话
    }

    override fun onServiceDisconnected(name: android.content.ComponentName) {
        customTabsClient = null
        customTabsSession = null
    }

    // 可以在用户可能触发跳转前调用,如 Activity/Fragment 创建时
    fun mayLaunchUrl(url: Uri) {
        customTabsSession?.mayLaunchUrl(url, null, null)
    }

    // 启动 Custom Tab 时,可以使用预热好的 Session (可选)
    fun launchUrl(url: Uri) {
        val builder = CustomTabsIntent.Builder(customTabsSession) // 使用 session
        val customTabsIntent = builder.build()
        customTabsIntent.launchUrl(context, url)
    }
}

// 在你的 Activity 或 Application 生命周期中管理 CustomTabHelper 实例的绑定和解绑
// ...
// val customTabHelper = CustomTabHelper(this)
// override fun onStart() {
//     super.onStart()
//     customTabHelper.bindCustomTabsService()
// }
// override fun onStop() {
//     super.onStop()
//     customTabHelper.unbindCustomTabsService()
// }

// 调用时(结合方案一,先获取带 token 的 URL)
// GlobalScope.launch {
//     val ott = ... // 获取 one-time token
//     val urlWithToken = ... // 拼接 URL
//     val uri = Uri.parse(urlWithToken)
//     withContext(Dispatchers.Main) {
//          customTabHelper.mayLaunchUrl(uri) // 预加载(可选)
//          delay(100) // 短暂延迟,给预加载一点时间(可选)
//          customTabHelper.launchUrl(uri)
//     }
// }

总结: 对于需要在 Custom Tabs 中保持登录状态的需求,服务端 Token 交换是目前最推荐、最可靠的方法 。它将认证状态的管理责任交还给了 Web 服务器,避免了客户端直接操作敏感 Cookie 的困难和风险。如果场景允许,使用 WebView 是一个备选项,但需要牺牲一些浏览器特性并承担更多安全责任。而 CustomTabsSession 的预热功能,则是提升用户体验的辅助手段,不能解决 Cookie 传递的核心问题。