找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2902

积分

0

好友

376

主题
发表于 8 小时前 | 查看: 2| 回复: 0

Streamlit 以其简洁的 API 成为快速构建数据应用的利器。然而,当开发者试图构建需要保持登录状态的多页面应用时,一个棘手的问题便浮现出来:Streamlit 原生不支持设置浏览器 Cookie。这导致我们无法像开发传统 Web 应用那样,利用 Cookie 来管理持久的用户认证状态。本文将分享我在解决这个痛点过程中的探索与最终的突破性方案,希望能为遇到同样困境的开发者提供清晰的路径。

问题根源:为何Cookie在Streamlit中如此难搞?

传统 Web 应用可以轻松使用 Cookie,但 Streamlit 呢?其设计初衷是快速原型和内部工具,因此在会话状态管理上更侧重于单次运行时(runtime)内的 session_state。这就引出了两个核心矛盾:

  1. 原生能力的缺失
    虽然 Streamlit 提供了 st.context.cookies 来读取浏览器发送过来的 Cookie,但它没有公开任何用于设置 Cookie 的 API。这意味着登录成功后,你无法将认证令牌“种”到用户浏览器里。一旦页面刷新或浏览器重启,基于 session_statequery_params 的登录状态就会消失,用户体验大打折扣。

  2. 现有社区方案的局限性
    面对这个需求,社区中诞生了一些插件,例如 streamlit_cookies_manager_ext。但在实际的多页面应用场景中,它们往往力不从心:

    • 多页面冲突:这些组件通常设计为单例,不支持设置唯一的 key,导致在多个页面同时使用时产生组件键冲突。
    • 就绪状态问题:常见的 cookie.ready() 方法可能永远返回 False,使得 Cookie 读取失败。
    • 维护滞后:随着 Streamlit 版本快速迭代,许多插件更新不及时,存在兼容性风险。

深入 Streamlit 源码,你会发现它自身在启动时也会设置一个名为 _streamlit_xsrf 的 Cookie。它是通过底层框架(如 Tornado)注入 JavaScript 来实现的。但这种内部方法仅适用于主页面初始化,且与 st.rerun() 的配合存在时序冲突,直接模仿并不可靠。

探索之路:为何AI给出的方案也频频失效?

在自主摸索未果后,我转而求助于包括 DeepSeek、GPT 在内的主流 AI 对话工具。它们给出的方案看起来逻辑自洽,但实际测试却一一“翻车”:

  • 方案一:用 st.query_params 模拟 Cookie
    将认证信息作为参数附加在 URL 中。缺点显而易见:无法持久化(关闭标签页即失效),且敏感信息暴露在地址栏,安全性存疑。
  • 方案二:使用第三方 Cookie 管理库
    这正是上文提到的社区插件路径,其多页面兼容性问题无法规避。
  • 方案三:常规的 JS 注入
    AI 给出的代码通常如下,其问题在于 st.rerun() 可能在 JS 执行完毕前就触发了,导致 Cookie 并未被成功写入。

    def set_cookie(name, value, days=7):
        expires = ""
        if days:
            date = datetime.now() + timedelta(days=days)
            expires = "; expires=" + date.strftime("%a, %d %b %Y %H:%M:%S GMT")
    
        js_code = f"""
        <script>
        document.cookie = "{name}={value}{expires}; path=/";
        </script>
        """
        st.components.v1.html(js_code, height=0)
    
    def render_login_page():
        """
        login mechanism
        """
        set_cookie("cookie_name", "cookie_value")
        st.rerun() # 问题所在!可能过早执行

破局关键:Debug Agent 如何找到最终方案?

在多次尝试失败后,我利用 Cursor 的 Debug Agent 进行了多轮交互式调试。我提供了包含登录和验证的完整代码,Agent 经过几轮分析后,指出了一个关键症结:必须在正确的上下文(父窗口)中设置 Cookie,并确保页面重载以使其生效。这催生了下面的核心解决方案。

核心解决方案:可靠的 set_cookie 函数

import streamlit.components.v1 as components

def set_cookie(name, value, hours=24):
    """A function to set cookie in parent window (main page context).

    Args:
      name: The name of the cookie.
      value: The value of the cookie.
      hours: The number of hours to set the cookie for.

    Returns:
      None
    """
    js_code = f"""
    <script>
    (function() {{
      // Set cookie in parent window (main page context)
      const parentWindow = window.parent || window;
      const d = new Date();
      d.setTime(d.getTime() + ({hours} * 60 * 60 * 1000));
      const expires = "expires=" + d.toUTCString();
      parentWindow.document.cookie = "{name}={value};" + expires + ";path=/;SameSite=Lax";
      // Force a full page reload to ensure cookie is sent with next request
      setTimeout(function() {{
        parentWindow.location.reload();
      }}, 100);
    }})();
    </script>
    """
    components.html(js_code, height=0)

为何这个方案能成功?

  1. window.parent 上下文:Streamlit 组件运行在 iframe 中,直接使用 document.cookie 作用域有限。通过 window.parent 访问主窗口的 document,确保了 Cookie 在整个应用域名下有效。
  2. path=/:设置 Cookie 的作用路径为根目录,使得该 Cookie 在应用的所有页面(/page1/page2)下都可被读取。
  3. SameSite=Lax:平衡安全性与可用性,允许在页面导航时携带 Cookie。
  4. 强制重载setTimeout 触发父窗口的 location.reload() 是点睛之笔。它解决了 JS 执行与 Streamlit 状态更新的时序竞争问题,确保浏览器在下次请求时一定会带上新设置的 Cookie,使认证状态立刻生效。

完整实现:一个可运行的多页面认证 Demo

下面是一个利用此方案构建的、包含完整登录-验证-退出流程的多页面应用示例。其架构思想是:客户端仅存储一个用户ID Cookie,完整的认证状态(令牌、过期时间)保存在服务端

import streamlit as st
import streamlit.components.v1 as components
import json
from datetime import datetime, timedelta

## 虚拟用户
USERNAME = "tester"
PASSWORD = "123456"

## 设置cookie的关键函数
def set_cookie(name, value, hours=24):
    """A function to set cookie in parent window (main page context).

    Args:
      name: The name of the cookie.
      value: The value of the cookie.
      hours: The number of hours to set the cookie for.

    Returns:
      None
    """

    js_code = f"""
    <script>
    (function() {{
      // Set cookie in parent window (main page context)
      const parentWindow = window.parent || window;
      const d = new Date();
      d.setTime(d.getTime() + ({hours} * 60 * 60 * 1000));
      const expires = "expires=" + d.toUTCString();
      parentWindow.document.cookie = "{name}={value};" + expires + ";path=/;SameSite=Lax";
      // Force a full page reload to ensure cookie is sent with next request
      setTimeout(function() {{
        parentWindow.location.reload();
      }}, 100);
    }})();
    </script>
    """
    components.html(js_code, height=0)

## 在服务器将数据保存为JSON的工具。在上线运行中,可以使用MongoDB。
def load_data_from_id(data_id: str, path: str="") -> dict:
    file_path = f"{path}/{data_id}.json"
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except FileNotFoundError:
        data = {}
    return data

## 在服务器读取JSON数据的工具。在上线运行中,可以使用MongoDB。
def write_data_to_id(data: dict, data_id: str=None, path: str="") -> None:
    if data_id is None:
        data_id = data.get("id", None)
    if data_id is None:
        raise ValueError("data_id must be provided either as argument or in data['id'].")
    file_path = f"{path}/{data_id}.json"
    with open(file_path, "w", encoding="utf-8", errors="strict") as f:
        json.dump(data, f, ensure_ascii=False, indent=4)

## 虚拟的登录过程,实际应该是调用另一个后端的API。
def login_user(username, password):
    ''' Call the actual login API to get the user information. '''
    # This is a placeholder for the actual login API.
    if username.lower()==USERNAME and password==PASSWORD:
        return {"id": "user_123456789", "username": username, "token": "token_123456789"}
    else:
        return {}

## 虚拟的验证用户token的有效性,实际应该是调用另一个后端的API。
def verify_token(token):
    ''' Call the actual API to verify the authorization status of the token.'''
    return {"id": "user_123456789", "username": USERNAME, "token": "token_123456789"}

## 简易的退出设置,仅在服务器修改认证状态。
def logout():
    del st.session_state.authentication
    user = st.session_state.user_info["user"]
    cookie = load_data_from_id(data_id=user["id"], path="cookie_dir")
    cookie["authentication"] = f'''invalid:{datetime.now()}:{user["token"]}'''
    write_data_to_id(cookie, data_id=user["id"], path="cookie_dir")
    st.rerun()

## 登录页面
def render_login_page():
    st.set_page_config(
      page_title="Login Page",
      layout="centered",
      initial_sidebar_state="collapsed"
    )

    with st.container(horizontal_alignment="center", height=500, gap="large"):
        st.header("用户登录")
        with st.form("login_form", width=400, border=False):
            username = st.text_input("用户名", placeholder="请输入用户名")
            password = st.text_input("密码", type="password", placeholder="请输入密码")
            col1, col2, col3 = st.columns([1, 1, 1])
            with col2:
                submit_button = st.form_submit_button("登录", use_container_width=True)

    # Handle login submission
    if submit_button:
        if not username or not password:
            st.error("请输入用户名和密码!")
            return

        # Show loading spinner
        with st.spinner("登录中..."):
            # Call login API
            user = login_user(username, password)
            if user:
                # Successful login
                st.session_state.user_info = {"user": user}
                # 设置cookie失效时间,建议从环境变量中获取
                session_expiration = datetime.now()+timedelta(hours=24)
                # 设置cookie数据,保存在服务器端
                cookie_data = f'''authenticated:{session_expiration.timestamp()}:{user["token"]}'''
                # 将cookie数据写入服务器端
                auth_cookie = {"user": user["id"], "authentication": f"{cookie_data}"}
                write_data_to_id(auth_cookie, data_id=user["id"], path="cookie_dir")
                # 设置session状态,避免streamlit刷新时重复使用cookie进行验证
                st.session_state.authentication = {"status": True, "expiration": session_expiration.timestamp()}
                # 将cookie写入浏览器并刷新。
                set_cookie(name="app_cookie", value=user["id"], hours=24)
            else:
                st.error("用户名或密码错误!")

## 简易的认证状态验证,先从sesstion_state寻找认证记录,然后再从cookie寻找。
def check_authentication():
    """
    Check if user is authenticated and token is valid based on cookies
    """
    authentication = st.session_state.get("authentication", {"status": False, "expiration": 0})
    authenticated = authentication.get("status", False)
    expiration = authentication.get("expiration", 0)
    if authenticated and (datetime.now().timestamp() < expiration):
        return True
    else:
        cookie_id = st.context.cookies.get("app_cookie", "")
        cookie = load_data_from_id(data_id=cookie_id, path="cookie_dir")
        cookie_value = cookie.get("authentication", None)
        if cookie_value is None:
            return False
        else:
            try:
                auth_status, exp_timestamp, token = cookie_value.split(":")
                if (auth_status == "authenticated") and (datetime.now().timestamp() < float(exp_timestamp)):
                    user = verify_token(token)
                    if user is None:
                        st.session_state.authentication = {"status": False, "expiration": 0}
                        return False
                    else:
                        st.session_state.authentication = {"status": True, "expiration": float(exp_timestamp)}
                        st.session_state.user_info = {"user": user}
                        return True
                else:
                    st.error("认证已过期,请重新登录。")
                    return False
            except Exception as e:
                st.error("无效的认证 cookie。")
                return False

## 模拟页面一
def page1():
    st.set_page_config(
      page_title="Page 1",
      layout="centered",
      initial_sidebar_state="collapsed"
    )
    st.header("Page 1")
    if check_authentication():
        user = st.session_state.get("user_info", {}).get("user", {}).get("username", "")
        if user:
            st.write(f"{user} is authenticated.")
        st.write("Current browser cookies:")
        st.write(st.context.cookies)
        if st.button("退出登录", key="logout_1"):
            logout()
    else:
        render_login_page()

## 模拟页面二
def page2():
    st.set_page_config(
      page_title="Page 2",
      layout="centered",
      initial_sidebar_state="collapsed"
    )
    st.header("Page 2")
    if check_authentication():
        user = st.session_state.get("user_info", {}).get("user", {}).get("username", "")
        if user:
            st.write(f"{user} is authenticated.")
        st.write("Current browser cookies:")
        st.write(st.context.cookies)
        if st.button("退出登录", key="logout_2"):
            logout()
    else:
        render_login_page()

## 主应用
def main():
    # st.components.v1.html(GLOBAL_JS, height=0)
    pg = st.navigation([st.Page(page1, title="Page 1", url_path="/page1"),
          st.Page(page2, title="Page 2", url_path="/page2")
          ], position="top")
    pg.run()

if __name__ == "__main__":
    main()

方案要点解析:

  1. 安全分离:浏览器 Cookie 只存储不敏感的 user_id,真正的认证令牌和过期时间加密后存于服务端(Demo 中用 JSON 文件模拟,生产环境应使用 数据库或Redis)。
  2. 状态同步:通过 st.session_state 在单次会话内缓存认证结果,避免每次交互都读取 Cookie 和后台数据。
  3. 统一验证:每个页面都调用 check_authentication() 函数,它优先检查 session_state,失败则尝试从 Cookie 恢复,实现了多页面间认证状态的无缝衔接。

结语

通过剖析 Streamlit 的运行机制,并借助 Debug Agent 的迭代调试,我们找到了一个稳定可靠的 Cookie 设置方案。该方案成功绕过了 Streamlit 的原生限制,解决了多页面应用的认证状态持久化难题。它不仅适用于用户登录,也可扩展用于存储其他需要跨会话的客户端偏好设置。

希望这篇在 云栈社区 分享的深度实践,能帮助你彻底解决 Streamlit 应用中的认证顽疾,让你的应用体验更加专业、流畅。

注:本文方案已在 Streamlit 1.28+ 版本环境中验证。实际部署时,请务必根据安全规范调整令牌生成、存储与验证逻辑。




上一篇:Tomcat 线程池原理核心机制解析:从Java基础到定制化高性能调优
下一篇:清华特奖游凯超与vLLM:从开源贡献到8亿美元创业的技术远征
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-26 18:43 , Processed in 0.507488 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表