简介:这份示例项目面向需要在 Plotly Dash 应用中补齐用户身份验证的开发者,重点演示如何在 Dash 顶部集成 Flask-Login,实现登录、登出、会话保护与页面跳转。项目以 SQLite 数据库完成账号校验,默认提供测试账号,并给出通过配置文件切换自有数据库 URI 的做法;同时附带 Jupyter 用户增删笔记本与用户管理脚本,便于扩展账号体系。资源包共 23 个文件,以 9 个 Python 源文件为核心,辅以 CSS 样式、TXT 配置说明及依赖清单等,压缩包约 16KB,结构轻量、模块划分清楚。已有 929 人学习,适合希望快速跑通 Dash 登录流程、理解认证视图与配置组织方式,并在此基础上接入角色权限或自有用户库的读者参考。
1. dash-flask-login 不是装饰器:它解决的是 Dash 回调绕过登录的问题
dash-flask-login 这个资源要处理的场景很具体:你已经有 Dash 数据面板,想加用户名密码登录,却发现 Flask-Login 的@login_required贴到 Dash 布局上不生效,/_dash-update-component回调照样能被直接调用。它的价值不在某个神奇装饰器,而在把 Dash 底层的 Flask 实例、Flask-Login 的 User 模型、登录/登出路由和请求拦截器接到一起,让页面入口和 Dash 回调都走同一套会话判断。适合已经会写 Dash 回调、准备把内部工具从“裸奔”改成带账号权限的人,也适合想弄清 Dash 与 Flask 边界的老手。
2. 拆开 Dash 的 Flask 底座:会话、路由和 User 模型怎么接
Dash 应用不是悬空运行的,它内部持有一个 Flask 实例。很多人第一次接登录,会把@login_required加到app.layout函数上,结果页面看起来要登录,回调却还能被 POST 调用。问题不在 Flask-Login,而在保护点放错了位置。Dash 的页面壳、布局 JSON、依赖描述和回调 POST 是不同路由,登录态必须落在 Flask 的请求入口上,才能覆盖完整链路。
2.1 Dash 的 Flask 底座与路由边界
dash.Dash(__name__)创建应用时,会同时创建一个 Flask server。后面所有 Flask-Login 的初始化、登录路由、before_request拦截器,都应该挂到这个 server 上,而不是另起一个 Flask app。否则浏览器拿到的 session cookie 和 Dash 请求使用的 Flask 实例不是同一个,登录状态就会看起来“写了但没生效”。
import dash from flask import Flask app = dash.Dash(__name__) server = app.server # 这里才是 Flask 实例,Flask-Login 要挂到它上面 # 常见错误:又写了一个 flask_app = Flask(__name__) # 然后把 login_manager.init_app(flask_app) # Dash 请求不会经过这个新 app,登录态自然对不上这段代码只做一件事:把 Dash 的底层 Flask 实例取出来。关键参数是__name__,它会影响静态资源、模板查找和部分部署路径;如果你把应用拆到包目录里,传入的模块名要和启动入口一致。server不是副本,而是 Dash 内部真正处理 HTTP 请求的对象,后面login_manager.init_app(server)、@server.route、@server.before_request都用它。
理解路由边界比记代码更重要。Dash 常见请求大致如下:
| 路径 | 作用 | 登录策略 |
|---|---|---|
/或自定义页面路径 | 返回 Dash 页面壳 | 未登录重定向到/login |
/_dash-layout | 返回初始布局 JSON | 受保护,未登录返回 401 或重定向 |
/_dash-dependencies | 返回回调依赖描述 | 受保护 |
/_dash-update-component | 执行 Dash 回调的 POST 请求 | 受保护,建议返回 JSON 401 |
/assets/* | Dash 静态资源 | 放行,否则登录页样式和脚本会丢 |
/_dash-component-suites/* | Dash 组件前端资源 | 放行,否则组件加载失败 |
/login、/logout | 登录、登出 | 放行 |
这张表是后面写白名单的依据。只保护/不够,因为攻击者或误操作可以直接向/_dash-update-component发请求;把所有/_dash-*都放行也不行,那等于回调没有登录门槛。正确做法是页面请求重定向,数据请求返回 401,静态资源单独放行。
2.2 初始化 LoginManager:user_loader 和 session_protection 的选择
Flask-Login 的核心是LoginManager。它负责从 session 里读用户 ID,再通过user_loader把 ID 还原成用户对象。初始化时最容易错的是把init_app挂错对象,或者login_view写成了 URL 而不是端点名。
from flask_login import LoginManager login_manager = LoginManager() login_manager.init_app(app.server) # 必须挂到 Dash 的 Flask 实例 login_manager.login_view = "login" # 端点名,不是 "/login" login_manager.login_message = "请先登录" login_manager.session_protection = "basic" # 开发和多实例环境先用 basic这段代码的关键参数有三个。init_app(app.server)决定 Flask-Login 是否参与 Dash 请求;login_view必须是 Flask 路由函数名,也就是@server.route("/login")修饰的login,写成"/login"会在未登录跳转时报端点找不到;session_protection控制会话保护强度,strong会在客户端指纹变化时更积极地让会话失效,本地开发、容器重建、客户端网络切换时容易误伤,内部工具一般先用basic,确认部署环境后再调。
接着要让 Flask-Login 知道“用户 ID 怎么变成用户对象”。这一步通常查数据库:
from flask_login import UserMixin class User(UserMixin): def __init__(self, id, username, password_hash, role="viewer", active=True): self.id = str(id) # Flask-Login 的 session 里存字符串 self.username = username self.password_hash = password_hash self.role = role self._active = active @property def is_active(self): return self._active # 不要直接 self.is_active = True @login_manager.user_loader def load_user(user_id): row = db.execute( "SELECT id, username, password_hash, role, is_active FROM users WHERE id = ?", (user_id,), ).fetchone() if not row: return None return User(row["id"], row["username"], row["password_hash"], row["role"], bool(row["is_active"]))user_loader的入参user_id是字符串,返回None表示用户不存在或已删除。User继承UserMixin后自带is_authenticated、get_id等属性,但is_active在UserMixin里是 property,子类里不要用实例属性硬覆盖,否则可能触发“没有 setter”的错误。上面用_active保存状态,再用 property 暴露,是更稳妥的写法。
还需要一个固定的SECRET_KEY。Flask 的 session 默认是签名 cookie,密钥一变,旧 cookie 全部失效,表现就是“登录成功,刷新又未登录”。
import os app.server.config.update( SECRET_KEY=os.environ.get("SECRET_KEY", "dev-only-change-me"), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", )SECRET_KEY在多进程、多实例部署时必须一致,不要在每个 worker 启动时随机生成。SESSION_COOKIE_HTTPONLY减少前端脚本读取 cookie 的机会;SESSION_COOKIE_SAMESITE="Lax"对普通登录跳转够用,若你的登录流程涉及跨站 POST,再按实际场景调整。开发环境的默认值只用于本地,部署时必须用环境变量覆盖。
2.3 登录与登出路由:表单、哈希和 next 参数串起来
登录路由是 Flask 侧的事情,不要试图用 Dash 回调完成账号密码校验。常见做法是写一个普通 Flask 表单路由,校验成功后调用login_user(),再跳回 Dash 页面。
from flask import request, redirect, url_for, render_template_string from flask_login import login_user, logout_user, current_user from werkzeug.security import check_password_hash @server.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect("/") error = None if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "") user = find_user_by_name(username) # 按用户名查 users 表 if user and check_password_hash(user.password_hash, password): login_user(user, remember=request.form.get("remember") == "on") next_url = request.args.get("next") or request.form.get("next") if next_url and is_safe_url(next_url): return redirect(next_url) return redirect("/") error = "用户名或密码不正确" return render_template_string( LOGIN_HTML, error=error, next=request.args.get("next", ""), ) @server.route("/logout") def logout(): logout_user() return redirect(url_for("login"))这段代码里,login_user(user, remember=...)会把用户 ID 写进 Flask session,remember为真时额外下发记住登录的 cookie。check_password_hash用来比对数据库中保存的哈希,不要存明文密码。next参数用于登录后回到原页面,但必须经过is_safe_url校验,否则会变成开放重定向。url_for("login")依赖函数名login,所以login_manager.login_view也要写"login"。
find_user_by_name和LOGIN_HTML没有在片段里展开,前者就是一条按username查users表的查询,后者是普通 HTML 表单。登录路由不要返回 Dash 布局,也不要在 Dash 回调里做密码校验;Flask 表单负责认证,Dash 负责展示,这条边界越清楚,后面排查越省事。
3. 保护 Dash 页面与回调:before_request 白名单和 401 返回
登录路由只解决“怎么进来”,真正的保护在请求入口。Flask 的before_request适合做全局拦截:每个请求进来先判断当前用户是否已登录,再决定放行、重定向还是返回 401。这里的关键不是写不写拦截器,而是不同请求要不同处理。页面请求可以 302 到登录页,Dash 回调请求如果也 302,前端 fetch 拿到的是登录页 HTML,控制台就会报解析错误。
3.1 全局拦截器:页面重定向,回调返回 JSON 401
下面这段是保护 Dash 的核心。它先放行登录、登出、健康检查和静态资源,再判断current_user,最后区分普通页面请求和 Dash 数据请求。
from flask import request, redirect, url_for, jsonify from flask_login import current_user PUBLIC_PATHS = {"/login", "/logout", "/healthz"} PUBLIC_PREFIXES = ("/assets/", "/favicon.ico", "/_dash-component-suites/") def is_dash_data_request(path: str) -> bool: return path.startswith("/_dash-") @server.before_request def require_login(): path = request.path if path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES): return None if current_user.is_authenticated: return None if is_dash_data_request(path): return jsonify({"error": "auth_required"}), 401 next_url = request.full_path if request.query_string else request.path return redirect(url_for("login", next=next_url))这段代码的逻辑是“先白名单,再登录态,最后按请求类型分流”。PUBLIC_PATHS放行登录、登出和健康检查;PUBLIC_PREFIXES放行 Dash 静态资源、favicon 和组件套件,否则登录页和组件加载会被 302 打断。is_dash_data_request只匹配/_dash-开头的路径,/_dash-update-component会返回 401 JSON,而不是重定向到登录页。普通页面请求则带上next跳转到登录页,登录成功后再回来。
提示:
/_dash-layout和/_dash-dependencies也属于/_dash-前缀。未登录时页面入口已经重定向,正常不会走到这里;如果直接请求这些路径,返回 401 比返回登录页 HTML 更利于前端识别。
这里还有一个容易忽略的参数:request.full_path会带上查询字符串,末尾可能多个?。如果你介意,可以改成request.url或自己拼接request.path + "?" + request.query_string.decode()。不管用哪种,登录路由接收next后都要做安全校验,不能直接redirect(next)。
3.2 回调层兜底:PreventUpdate、no_update 和前端提示
全局拦截器是主防线,回调层再放一层检查,主要防两类问题:以后有人改了路由前缀导致before_request漏掉,或者回调内部逻辑被复用到别的入口。Dash 回调运行时仍然处于 Flask 请求上下文,current_user可以直接读。
from dash import Input, Output, no_update from dash.exceptions import PreventUpdate from flask_login import current_user @app.callback( Output("table", "data"), Input("refresh-btn", "n_clicks"), prevent_initial_call=True, ) def refresh_table(n_clicks): if not current_user.is_authenticated: raise PreventUpdate # 不更新组件,也不抛前端错误 rows = query_rows() return rowsPreventUpdate表示这次回调不产生更新,适合做兜底;no_update更适合多输出回调里只更新部分组件。prevent_initial_call=True避免页面加载时因为n_clicks为空触发一次不必要查询。需要注意,PreventUpdate不会自动跳转登录页,它只是让这次更新静默失败。真正让未登录用户回到登录页的,还是before_request的页面重定向;401 JSON 则是防止直接 POST 回调的兜底。
如果回调有多个输出,不能只返回一个no_update。常见写法是返回与输出数量一致的元组:
@app.callback( Output("table", "data"), Output("status", "children"), Input("refresh-btn", "n_clicks"), prevent_initial_call=True, ) def refresh_table(n_clicks): if not current_user.is_authenticated: return no_update, no_update return query_rows(), "已刷新"多输出回调的返回值顺序必须和Output声明顺序一致,少一个都会报错。登录态检查放在查询之前,避免未登录请求仍然打到数据库。
3.3 多页面与静态资源:哪些路径必须放行
Dash 多页面应用常把页面路由挂在/app、/dashboard这类路径下,保护范围不能只写/。更稳的做法是保护所有非白名单路径,白名单只留登录、登出、健康检查和静态资源。下面这张表可以直接对照自己的路由表:
| 路径模式 | 是否放行 | 原因 |
|---|---|---|
本文还有配套的精品资源,点击获取