快轉到主要內容
  1. 教學文章/

Streamlit Auth 實戰:session_state、登入狀態與權限頁面

·7 分鐘· loading · loading · ·
Python Streamlit Authentication Session-State OIDC Security
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 71: 本文

featured

一. 前言:內部工具長大以後,第一個問題是「誰可以看?」
#

Streamlit 很適合做內部工具。 資料查詢、模型 demo、營運 dashboard、標註介面,幾十行 Python 就能跑起來。 然後問題就來了。 這個頁面能不能只給某些人看? debug 面板是不是不該公開? 刪除資料的按鈕是不是只有 admin 可以按? 這篇要做的不是把 Streamlit 變成大型 Web framework。 我們要做比較務實的三件事:

  1. st.login()st.userst.logout() 做登入。
  2. st.session_state 管理登入後的 UI 狀態。
  3. 用簡單角色規則控制頁面與操作。 如果你還不熟 session_state,可以先看:
  • Streamlit 進階:session_state、cache 與多頁 Dashboard 這篇的核心觀念很簡單: 身份、狀態、權限,不要混在一起。 st.user 回答「你是誰」。 st.session_state 回答「這個瀏覽器 session 現在做到哪一步」。 role / permission 回答「你可以做什麼」。 分開以後,程式會乾淨很多。

二. 安裝與範例目標
#

先建立專案:

uv init streamlit-auth-demo
cd streamlit-auth-demo
uv add streamlit

不用 uv 也可以:

python -m venv .venv
source .venv/bin/activate
pip install streamlit

這篇會做一個小型團隊 dashboard。 它有三個頁面:

  • Home:登入後所有人可看。
  • Reports:viewereditoradmin 可看。
  • Admin:只有 admin 可看。 專案結構:
streamlit-auth-demo/
├── .streamlit/
│   └── secrets.toml
├── app.py
├── auth.py
└── pages_app/
    ├── home.py
    ├── reports.py
    └── admin.py

這裡刻意用 pages_app/,不是傳統的 pages/。 因為我們要用 st.navigation() 依權限動態決定頁面。

三. session_state 不是登入系統
#

很多 Streamlit 登入範例會長這樣:

import streamlit as st
password = st.text_input("Password", type="password")
if password == "pypy-secret":
    st.session_state["logged_in"] = True
if not st.session_state.get("logged_in"):
    st.stop()
st.write("Welcome!")

本機 demo 可以。 正式內部工具就不太夠。 問題是:

  • 密碼可能寫死在程式碼。
  • 沒有 identity provider。
  • 沒有可靠使用者資訊。
  • 沒有標準登出流程。
  • 權限判斷容易散在各頁。 st.session_state 很適合放互動狀態:
  • 篩選器目前值。
  • 表單是否展開。
  • wizard 走到第幾步。
  • 暫存的 UI 選項。 但它不該是身份系統本身。 比較好的分工是:
身份驗證:st.login / st.user / st.logout
角色資料:allowlist、資料庫、群組同步結果
畫面狀態:st.session_state

四. 設定 OIDC:用 secrets.toml 放登入設定
#

Streamlit 內建 auth 走的是 OpenID Connect,也就是 OIDC。 常見 provider 包含 Google Identity、Microsoft Entra ID、Okta、Auth0。 先建立 .streamlit/secrets.toml

[auth]
redirect_uri = "http://localhost:8501/oauth2callback"
cookie_secret = "replace-with-a-long-random-string"
client_id = "your-client-id"
client_secret = "your-client-secret"
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"

正式值要到你的 identity provider 後台建立 app 後取得。 幾個重點:

[auth]
redirect_uri = "https://your-app.example.com/oauth2callback"
cookie_secret = "replace-with-production-secret"
client_id = "your-production-client-id"
client_secret = "your-production-client-secret"
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"

不要把範例 secret 原封不動拿去用。 拍拍君會皺眉。

五. 第一個登入牆:st.login、st.user、st.logout
#

先寫最小版 app.py

import streamlit as st
st.set_page_config(page_title="Team Dashboard", layout="wide")
if not st.user.is_logged_in:
    st.title("Team Dashboard")
    st.write("請先登入,才能使用內部工具。")
    if st.button("Log in"):
        st.login()
    st.stop()
st.sidebar.write(f"Hi, {st.user.get('name', 'teammate')}")
if st.sidebar.button("Log out"):
    st.logout()
st.title("Welcome")
st.write("你已經登入。")
st.json(dict(st.user))

跑起來:

uv run streamlit run app.py

流程是:

  1. 未登入時顯示登入按鈕。
  2. 按下後 st.login() 導向 identity provider。
  3. 登入完成後回到 app。
  4. st.user 會包含使用者資訊。
  5. st.logout() 清掉這個 app 的 identity cookie。 登入後你可能會看到類似資料:
{
    "is_logged_in": True,
    "email": "pypy@example.com",
    "name": "Pypy User",
    "sub": "provider-specific-user-id",
}

不同 provider 給的 claim 可能不同。 正式程式請用 .get(),不要假設每個欄位都存在。

六. 把 auth 邏輯集中到 auth.py
#

接著建立 auth.py

from __future__ import annotations
from dataclasses import dataclass
import streamlit as st
@dataclass(frozen=True)
class CurrentUser:
    email: str
    name: str
    roles: set[str]
ROLE_BY_EMAIL = {
    "admin@example.com": {"admin", "editor", "viewer"},
    "editor@example.com": {"editor", "viewer"},
    "viewer@example.com": {"viewer"},
}
def require_login() -> None:
    if st.user.is_logged_in:
        return
    st.title("Team Dashboard")
    st.write("請先登入。")
    if st.button("Log in"):
        st.login()
    st.stop()

先集中登入牆。 再把 st.user 轉成 app 自己需要的資料:

def current_user() -> CurrentUser:
    require_login()
    email = st.user.get("email", "")
    name = st.user.get("name", email or "teammate")
    roles = ROLE_BY_EMAIL.get(email, set())
    return CurrentUser(email=email, name=name, roles=roles)

接著寫角色檢查:

def require_role(role: str) -> CurrentUser:
    user = current_user()
    if role not in user.roles:
        st.error("你沒有權限查看這個頁面。")
        st.stop()
    return user

最後加上側邊欄使用者選單:

def render_user_menu(user: CurrentUser) -> None:
    st.sidebar.caption("Signed in as")
    st.sidebar.write(user.name)
    if user.roles:
        st.sidebar.caption("Roles")
        st.sidebar.write(", ".join(sorted(user.roles)))
    if st.sidebar.button("Log out"):
        st.logout()

目前 role 是硬寫在 ROLE_BY_EMAIL。 小團隊工具常常夠用。 規模變大後,可以換成資料庫、Google Group 同步結果,或公司內部權限服務。 頁面不需要知道 role 從哪裡來。 頁面只需要問:

user = require_role("admin")

這樣之後才好維護。

七. 用動態 navigation 控制頁面顯示
#

改寫 app.py

import streamlit as st
from auth import current_user, render_user_menu
st.set_page_config(page_title="Team Dashboard", layout="wide")
user = current_user()
render_user_menu(user)
if not user.roles:
    st.warning("你的帳號已登入,但尚未被授權使用這個工具。")
    st.stop()
pages = [
    st.Page("pages_app/home.py", title="Home", icon=":material/home:"),
]
if user.roles & {"viewer", "editor", "admin"}:
    pages.append(
        st.Page("pages_app/reports.py", title="Reports", icon=":material/analytics:")
    )
if "admin" in user.roles:
    pages.append(
        st.Page("pages_app/admin.py", title="Admin", icon=":material/admin_panel_settings:")
    )
selected_page = st.navigation(pages)
selected_page.run()

這樣使用者只會看到自己能用的頁面。 但要注意: 不要把「頁面沒顯示」當成唯一安全檢查。 UI 導覽只是第一層。 敏感頁面本身還是要檢查權限。 pages_app/admin.py

import streamlit as st
from auth import require_role
user = require_role("admin")
st.title("Admin")
st.write(f"Hello, {user.name}.")
st.warning("這裡放管理功能。")

pages_app/reports.py

import streamlit as st
from auth import require_role
user = require_role("viewer")
st.title("Reports")
st.write("這是團隊報表。")
st.caption(f"Loaded for {user.email}")

pages_app/home.py

import streamlit as st
from auth import current_user
user = current_user()
st.title("Home")
st.write(f"歡迎,{user.name}。")
st.write("請從左側選擇工具。")

這樣有兩層保護:

  • navigation 只顯示可用頁面。
  • page entry 再檢查一次權限。

八. session_state 放登入後的 UI 狀態
#

登入後,session_state 還是很好用。 只是它應該管理 UI 狀態,而不是身份本身。 例如報表篩選條件:

import streamlit as st
from auth import require_role
user = require_role("viewer")
st.title("Reports")
if "report_filters" not in st.session_state:
    st.session_state.report_filters = {
        "team": "all",
        "days": 7,
        "show_internal": False,
    }
filters = st.session_state.report_filters
team = st.selectbox(
    "Team",
    ["all", "product", "data", "ops"],
    index=["all", "product", "data", "ops"].index(filters["team"]),
)
days = st.slider("Days", 1, 90, filters["days"])
show_internal = False
if "admin" in user.roles:
    show_internal = st.checkbox("Show internal metrics", filters["show_internal"])
if st.button("Apply"):
    st.session_state.report_filters = {
        "team": team,
        "days": days,
        "show_internal": show_internal,
    }
    st.rerun()
st.write("Current filters:", st.session_state.report_filters)

這裡的 session_state 很合理。 它記的是這個使用者目前選的篩選條件。 但真正查資料時,仍然要再檢查權限:

def load_report(*, include_internal: bool, roles: set[str]):
    if include_internal and "admin" not in roles:
        raise PermissionError("internal metrics require admin role")
    return []

前端藏按鈕是一回事。 操作執行前檢查權限是另一回事。

九. 常見錯誤
#

第一個錯誤:把密碼寫在程式碼。

if password == "company-password-2026":
    st.session_state.logged_in = True

不要。 它很容易被 commit、截圖、貼到 issue。 第二個錯誤:只用 email domain 當權限。

if st.user.email.endswith("@example.com"):
    show_admin_page()

domain check 只能說「大概是公司帳號」。 它不代表這個人應該是 admin。 第三個錯誤:只藏頁面,不檢查操作。

if "admin" in user.roles:
    st.button("Delete all data")

按鈕只對 admin 顯示很好。 但刪除 function 本身也要檢查:

def delete_all_data(user):
    if "admin" not in user.roles:
        raise PermissionError("admin only")

第四個錯誤:把身份 claim 印在正式頁面。

st.json(dict(st.user))

本機 debug 可以。 上線前請刪掉,或放在 admin-only debug 頁。 第五個錯誤:unknown user 預設開放。 建議預設沒有權限:

roles = ROLE_BY_EMAIL.get(email, set())

然後顯示清楚訊息:

if not user.roles:
    st.warning("你的帳號尚未被授權,請聯絡管理員。")
    st.stop()

預設關閉比較不刺激。

十. 什麼時候不要只靠 Streamlit?
#

Streamlit auth 很適合:

  • 小團隊內部 dashboard。
  • 少數角色的資料工具。
  • prototype 要給真實使用者試用。
  • OIDC provider 已經由公司處理。 但如果你有這些需求,就該考慮完整後端:
  • 多租戶 SaaS。
  • 複雜資料列權限。
  • 高風險寫入操作。
  • 詳細 audit log。
  • API token 或 service account。
  • 使用者要代表自己呼叫第三方 API。 Streamlit 很適合 data app。 但不要把它硬凹成所有系統的安全核心。 先用 Streamlit 快速驗證流程。 如果工具變成正式業務系統,再把權限與敏感操作往後端移。 這樣速度和安全性比較不會互相吵架。

結語:登入不是一個按鈕,是一條邊界
#

這篇我們完成了 Streamlit auth 的基本架構:

  • st.login() 導向 OIDC provider。
  • st.user 讀取登入後身份。
  • st.logout() 登出目前 app。
  • CurrentUser 包裝 app 需要的資料。
  • 用 role 控制 st.navigation() 顯示頁面。
  • require_role() 在敏感頁面再次檢查。
  • st.session_state 管理 UI 狀態。 最重要的一句話: 身份、狀態、權限,請分開想。 分開以後,程式會比較好讀,debug 會比較不痛,之後改成資料庫或正式後端也比較順。 Streamlit 的魅力是快。 但快不是亂。 把登入邊界整理好,你的小工具就能從「自己用很方便」往「團隊用也放心」前進一大步。 拍拍君今天就先把門鎖裝好。 剩下的 dashboard,要漂亮也要守規矩。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 71: 本文

相關文章

Python dotenv 實戰:安全管理 .env、環境變數與部署設定
·7 分鐘· loading · loading
Python Dotenv Environment Variables Configuration Security
Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python Fastapi Streamlit Api Frontend Developer-Tools
Python secrets 實戰:安全產生 Token、密碼與一次性連結
·7 分鐘· loading · loading
Python Secrets Security Token Standard-Library Web
本地 AI App 架構:Streamlit、Ollama、MLX 怎麼分工
·10 分鐘· loading · loading
LLM Local AI Streamlit Ollama Mlx Python Architecture
Streamlit + Ollama:打造本地 LLM Chatbot App
·11 分鐘· loading · loading
LLM Ollama Streamlit Python Local AI Chatbot