资讯动态

Dash 集成 Flask-Login:会话、回调鉴权与部门分权落地

发布时间:2026/10/24 2:34:31 来源:尧图企业网站定制
简介面向需要在Plotly Dash应用中接入用户身份验证的Python开发者这份示例包以Flask-Login为核心在Dash页面顶部实现登录、登出与受保护视图的基础流程并借助sqlite3完成账号校验。读者可参考其配置方式替换数据库URI理解多页面与登录态结合的目录组织默认账号test/test1便于快速验证用户增删逻辑也留有可扩展入口。包内共23个文件以9个py脚本承载应用入口、服务端及登录/登出视图3个css与svg等静态资源负责界面样式另有txt配置说明、ipynb用户管理示例、db数据库及部署文件压缩包约16KB体量轻便适合作为小型鉴权原型研读。已有929人学习说明该示例在Dash登录场景中具备一定参考价值。对于想弄清Flask-Login会话机制、Dash路由保护与用户数据维护的读者可从中获得可运行骨架、配置拆分思路和用户管理脚本便于对照自身项目做二次开发。1. 内网看板按部门分权的麻烦卡在“没有会话”前阵子有人问我一个用 Dash 写的运营看板怎么让 A 部门看不到 B 部门的数据。他试过dash-auth的 Basic Auth——浏览器弹出原生对话框账号密码硬编码在.py里加一个人就得改代码重新发版而且弹窗只挡页面/_dash-update-component照样能被打。这是 flask plotly dash flask-login 这套组合被反复提起的真正原因Dash 本身有登录方案但缺少“会话”这一层。它自带的路由和回调协议是围绕“无状态图表渲染”设计的不认 cookie也没有current_user。项目做的事很朴素——先起一个真实的 Flask 应用作为主容器把 Dash 作为子应用挂到/console/下Flask-Login 负责user_loader、login_user、remember me 和会话签名Dash 只负责画图和跑回调。适合谁手上已经有一个能跑的 Dash 看板、现在要接内部账号体系、需要按角色裁剪导航和数据的那批人。2. 架构拆解Dash 是怎么被塞进 Flask 里的2.1 Dash 实例其实是在 Flask 上注册的一组路由很多人以为dash.Dash()会启动一个新服务其实不是。构造Dash(__name__, serverserver)的时候它拿到你传进来的 Flask 实例然后调用server.add_url_rule()注册了四个端点。这四个端点就是全部的秘密端点以/console/为前缀谁来请求作用/console/浏览器地址栏返回 index.html注入{%config%}和{%scripts%}/console/_dash-layoutdash-renderer拉取当前布局的 JSON 序列化结果/console/_dash-dependenciesdash-renderer拉取所有回调的签名前端据此决定发哪个 POST/console/_dash-update-componentdash-renderer唯一的回调执行入口所有app.callback都走它/console/_dash-component-suites/...浏览器Dash 内置的 JS 资源理解了这张表鉴权就有落点了不需要在 Dash 内部找钩子直接在 Flask 的before_request上挂一层按request.path判断就行。_dash-update-component是整个应用的唯一执行口守住它等于守住了所有回调。2.2 目录结构auth 和 dashapp 分成两棵树项目按“认证”和“视图”分家Flask 侧的东西不碰 DashDash 侧的东西只通过current_user这一个入口读身份dash-flask-login/ ├── app.py # 组装点建 server、装 auth、挂 dash、装 guard ├── config.py # 密钥、cookie 策略、Dash 挂载前缀 ├── auth/ │ ├── __init__.py # login_manager 单例 init_auth() │ ├── models.py # User(UserMixin) 与账号查询 │ ├── routes.py # /login /logout 蓝图 │ └── guard.py # before_request 鉴权 ├── dashapp/ │ ├── __init__.py # create_dash(server) 返回 Dash 实例 │ ├── layout.py # serve_layout()可调用形式 │ └── callbacks/ │ ├── __init__.py # 统一 import 触发注册 │ ├── kpi.py # 普通看板回调 │ └── admin.py # 管理员专属回调 ├── templates/login.html # Jinja 登录页复用 Flask 的 render_template ├── static/js/guard.js # 前端 401 兜底 ├── tools/probe_endpoints.py# 自检脚本匿名访问一圈看有没有漏 └── requirements.txtauth/里没有任何import dashdashapp/里也没import flask_login的login_user只 importcurrent_user。这条边界很关键——一旦在 Dash 的回调里调login_user()做登录就会出现“回调改了 session但页面上另一半组件还是未登录态”的错位玄学问题基本都从这冒出来。2.3 认证边界cookie 归 FlaskUI 归 Dash会话存在哪儿Flask 默认把 session 序列化后签进sessioncookieSECRET_KEY就是签名密钥。这意味着两件事第一多进程部署gunicorn -w 4时不需要 sticky session因为状态在客户端第二SECRET_KEY一旦泄露别人可以伪造任意用户身份所以它绝不能出现在仓库里。Dash 拿不到这个 cookie 吗拿得到。浏览器的每个请求都会带上它包括 renderer 发往_dash-layout和_dash-update-component的 fetch。所以 Flask-Login 的current_user在 Dash 的视图函数和回调里都是有效的——这不是投机取巧是因为 Dash 回调本来就在一个 Flask 请求上下文里执行。真正需要显式处理的是渲染时机。app.layout html.Div([...])是模块导入时求值一次的静态对象所有用户共享同一份而app.layout serve_layout传函数会在每次_dash-layout请求时调用那一刻current_user是有值的。按角色裁剪导航、隐藏按钮全部靠这个差异。依赖清单不长版本按下面这套配基本不会打架# requirements.txt Flask2.2,4 Flask-Login0.6.3 Werkzeug2.2 # check_password_hash 跟 Flask 同源 dash2.9,3 # 2.x 的 routes_pathname_prefix 行为稳定 plotly5.15 pandas2.0 # 看板数据处理按需 gunicorn21.23. 把 Flask-Login 接进 Dash 的完整落地流程3.1 起 server、装 LoginManager、定义 User入口文件做三件事创建 Flask 实例、初始化登录管理器、把 Dash 挂上去。顺序不能反——login_manager.init_app(server)必须在 Dash 挂载之前完成否则第一个_dash-layout请求进来时current_user还是个没绑定的桩对象。# config.py import os class Config: # 生产用环境变量注入缺失时给个明显的假值避免悄悄上线 SECRET_KEY os.environ.get(APP_SECRET_KEY, dev-only-do-not-ship) SESSION_COOKIE_HTTPONLY True # 禁 JS 读 cookie降低 XSS 后会话被劫持的风险 SESSION_COOKIE_SAMESITE Lax # 跨站 POST 不带 cookie抵消一部分 CSRF SESSION_COOKIE_SECURE os.environ.get(HTTPS_ONLY, 0) 1 PERMANENT_SESSION_LIFETIME 60 * 60 * 8 # 8 小时工作时段 REMEMBER_COOKIE_DURATION 60 * 60 * 24 * 7 DASH_BASE /console/ # Dash 的挂载前缀必须与 url_base_pathname 一致SESSION_COOKIE_SECURE用环境变量控制是有意为之。本地http://127.0.0.1:8050调试时如果它开着浏览器收到Secure的 cookie 会直接丢弃表现就是“登录成功、跳转、又回登录页”我第一次踩的时候以为是SECRET_KEY每次重启都变。User 模型只需要满足UserMixin的契约id是字符串主键剩下字段随便加# auth/models.py from flask_login import UserMixin from werkzeug.security import check_password_hash # 演示用内存表接 ORM 时把 find_user / verify_password 换成查询即可 _ACCOUNTS { ops01: {pw: pbkdf2:sha256:600000$xx$..., dept: 运维, roles: {viewer}}, lead01: {pw: pbkdf2:sha256:600000$yy$..., dept: 运营, roles: {viewer, admin}}, } class User(UserMixin): def __init__(self, username: str, dept: str, roles: set): self.id username # UserMixin 依赖 idget_id() 直接返回它 self.dept dept self.roles roles property def is_admin(self) - bool: return admin in self.roles def find_user(username: str): rec _ACCOUNTS.get(username) return User(username, rec[dept], rec[roles]) if rec else None def verify_password(username: str, plain: str) - bool: rec _ACCOUNTS.get(username) return bool(rec) and check_password_hash(rec[pw], plain)密码用werkzeug.security的 pbkdf2 哈希别自己写 md5。check_password_hash内部做了恒定时间比较顺带躲开时序侧信道这种不必要的麻烦。login_manager做成模块级单例user_loader只在init_auth里注册一次# auth/__init__.py from flask_login import LoginManager from .models import find_user login_manager LoginManager() def init_auth(server): login_manager.init_app(server) login_manager.login_view auth.login # 未登录时 redirect 到蓝图端点 login_manager.login_message 请先登录 login_manager.session_protection strong # 换 IP/UA 时主动失效会话 login_manager.user_loader def _load(uid: str): return find_user(uid)session_protectionstrong会校验会话绑定的客户端标识代价是用户在移动网络切换基站后可能被踢回登录页。内网看板可以接受公网 C 端产品就得权衡。3.2 登录与登出Jinja 模板 一个蓝图登录页不用 Dash 画用 Flask 的render_template。理由很实际Dash 页面要加载几百 KB 的 renderer bundle一个只有两个输入框的登录页没必要背这个包袱而且未登录用户根本不该触发 Dash 的任何请求。# auth/routes.py from urllib.parse import urlparse from flask import Blueprint, render_template, request, redirect, current_app from flask_login import login_user, logout_user, current_user from .models import find_user, verify_password bp Blueprint(auth, __name__) def _safe_next(target: str, fallback: str) - str: 只接受站内相对路径堵住开放重定向 if not target: return fallback parsed urlparse(target) if parsed.scheme or parsed.netloc or not target.startswith(/): return fallback return target bp.route(/login, methods[GET, POST]) def login(): base current_app.config[DASH_BASE] if current_user.is_authenticated: return redirect(base) error None if request.method POST: username (request.form.get(username) or ).strip() password request.form.get(password) or if verify_password(username, password): user find_user(username) # rememberTrue 会额外下发一个 remember_token cookie login_user(user, rememberbool(request.form.get(remember))) return redirect(_safe_next(request.args.get(next, ), base)) error 账号或密码不正确 return render_template(login.html, errorerror, nextrequest.args.get(next, )) bp.route(/logout) def logout(): logout_user() # 清 session 里的 _user_id 并作废 remember_token return redirect(/login)这里有个细节值得单独说next参数必须做白名单校验。只判断startswith(/)不够//evil.example.com在浏览器里会被当成协议相对 URL 直接跳走。urlparse后再看netloc才是稳的。登出为什么不写成 Dash 回调可以写——在回调里调logout_user()session 被标记为 modifiedFlask 在返回 JSON 时依然会带上Set-Cookie确实能生效。但它依赖“回调函数的返回值里还得有个dcc.Location把页面带走”两件事耦合在一起中间任何一步抛异常都会留下“前端以为退了、后端没退”的状态。用普通a href/logout做整页跳转虽然多一次页面加载但行为确定。登录模板保持极简不做花哨样式!-- templates/login.html -- !DOCTYPE html html langzh-CN head meta charsetutf-8 title登录 · 运营看板/title link relstylesheet href{{ url_for(static, filenamecss/login.css) }} /head body form methodpost action{{ url_for(auth.login, nextnext) }} h1运营看板/h1 {% if error %}p classerr{{ error }}/p{% endif %} label账号 input nameusername autocompleteusername required/label label密码 input namepassword typepassword autocompletecurrent-password required/label label classrowinput typecheckbox nameremember value1 记住我/label button typesubmit登录/button /form /body /html表单action上带着next是为了 POST 之后还能拿到原始目标页如果只放在查询串里浏览器提交表单会把?next...丢掉。3.3 before_request把 Dash 的内部端点全部圈进保护区这一层是整个方案的核心。策略选“默认全部拒绝白名单放行”而不是“列出要保护的路径”——后者只要 Dash 哪天新增一个端点你就漏了。# auth/guard.py from urllib.parse import quote from flask import request, redirect, jsonify, current_app from flask_login import current_user # 未登录也能访问的路径多一个都不给 PUBLIC_PATHS {/login, /healthz, /favicon.ico} PUBLIC_PREFIXES (/static/,) def install_guard(server): server.before_request def _guard(): path request.path if path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES): return None if current_user.is_authenticated: return None # 回调入口必须回 401。回 302 的话 renderer 会拿到一段 HTML # 当作 JSON 解析前端直接崩在 Unexpected token if path.endswith(_dash-update-component): return jsonify({detail: unauthenticated}), 401 # 其余是文档级请求地址栏、iframe、_dash-layout走 302 更自然 nxt quote(path (? request.query_string.decode() if request.query_string else )) return redirect(f/login?next{nxt})判断依据是endswith(_dash-update-component)而不是精确匹配因为前缀可配/console/、/dash/都行而且反向代理可能会改写。用endswith少一个配置项要同步。提示PUBLIC_PREFIXES里的/static/不能省。登录页本身要加载 CSS登录页的 CSS 被鉴权拦掉用户看到的就是一个裸表单你会以为是模板没渲染。放行之后_dash-layout和_dash-dependencies依然受保护——未登录用户拿不到你的回调签名也就不可能构造出合法的_dash-update-component请求体Dash 会先用_dash-dependencies校验输出结构。这是 Dash 协议自带的一点防护别浪费。3.4 layout 跟着 current_user 走终于说到 UI。serve_layout是个普通函数每次 Dash 渲染页面都会调用此时请求上下文里的current_user就是当前这个人# dashapp/layout.py from flask_login import current_user from dash import dcc, html def _nav_items(): items [dcc.Link(总览, href/console/, classNamenav-item)] if current_user.is_authenticated: items.append(dcc.Link(我的数据, href/console/mine, classNamenav-item)) if current_user.is_admin: items.append(dcc.Link(账号管理, href/console/admin, classNamenav-item)) return items def serve_layout(): # 防御性兜底正常流程走不到这里已被 guard 拦下 # 但如果会话在页面打开期间过期这句话能避免半截布局 if not current_user.is_authenticated: return html.Div(会话已失效请重新登录, idsession-expired) return html.Div([ html.Header([ html.Span(f{current_user.id} · {current_user.dept}, classNamewho), html.A(退出, href/logout, classNamelogout), ]), html.Nav(_nav_items()), dcc.Location(idurl), # 前端路由不触发整页刷新 html.Div(idpage-body), # 各页面内容的挂载点 dcc.Store(idclient-ctx, data{dept: current_user.dept, admin: current_user.is_admin}), ])dcc.Store那一行是把身份信息“投影”到前端让 clientside 回调也能做粗粒度的显隐控制。注意它是可篡改的——用户完全可以在 DevTools 里改掉admin字段。所以它只能用来做显示优化任何影响数据范围的判断都必须在服务端回调里重新读current_user。挂载 Dash 的时候url_base_pathname和配置里的DASH_BASE必须一致这俩一个字符串对不上静态资源就 404# app.py import flask import dash from config import Config from auth import init_auth from auth.guard import install_guard from auth.routes import bp as auth_bp from dashapp.layout import serve_layout server flask.Flask(__name__) server.config.from_object(Config) init_auth(server) # 先装登录管理器 server.register_blueprint(auth_bp) # 再注册登录路由 app dash.Dash( __name__, serverserver, url_base_pathnameserver.config[DASH_BASE], suppress_callback_exceptionsTrue, # 动态布局必须开否则校验期找不到组件就报错 title运营看板, ) app.layout serve_layout # 注意传函数不是传返回值 install_guard(server) # 最后装 guard覆盖所有已注册端点 import dashapp.callbacks # noqa: F401 导入即注册回调 if __name__ __main__: server.run(debugTrue, port8050)suppress_callback_exceptionsTrue在动态布局下不是可选项。因为回调里 Output 指向的组件在渲染前并不存在于布局树中Dash 启动时做的那次静态校验会直接抛异常。4. 回调层鉴权别只守住页面还要守住_dash-update-component4.1 回调里读current_user是可行的页面守住了不代表数据守住了。用户 A 登录后完全可以在 DevTools 里翻出 renderer 发出的 POST 体把某个Input的值改掉再重放。HTTP 层那一关他过了他有合法会话所以数据级的判断必须下沉到回调函数里。好消息是回调里能直接读current_user# dashapp/callbacks/kpi.py from dash import Input, Output, callback, no_update from flask_login import current_user from dashapp.services import query_kpi callback( Output(kpi-value, children), Output(kpi-scope, children), Input(dept-select, value), Input(url, pathname), ) def refresh_kpi(dept_from_ui, pathname): if not current_user.is_authenticated: # 会话在页面停留期间过期前端拦截器会接手跳登录页 return no_update, no_update # 数据权限非管理员无论前端传什么都只能看自己部门 dept dept_from_ui if current_user.is_admin else current_user.dept value query_kpi(dept) return f{value:,.0f}, f数据范围{dept}dept_from_ui是攻击面current_user.dept是事实来源。这行三元表达式是整个分权逻辑的真正实现前端那个下拉框对非管理员来说只是装饰。我见过不少项目把dept直接从dcc.Store里取等于把权限判断交给浏览器血泪经验。no_update和PreventUpdate的区别也要拎清no_update是“这个输出保持原样其他输出照常更新”PreventUpdate是“整个回调都不更新”。多输出回调里前者更合适否则会把同一次请求里其他正常的输出一起吞掉。4.2 一个role_required装饰器与输出兜底只有管理员能触发的回调导出全量数据、改账号状态光靠隐藏按钮不够——按钮藏了回调还在POST 还在。加一层装饰器# dashapp/callbacks/_guard.py from functools import wraps from flask_login import current_user from dash import no_update def role_required(role: str, deny_output: str): role 不满足时向 deny_output 写提示其余输出保持不动。 deny_output 是专门留的一个 html.Div(iddeny-hint) 避免用 PreventUpdate 让用户点了按钮毫无反应。 def deco(fn): wraps(fn) def wrapper(*args, **kwargs): if not current_user.is_authenticated or role not in current_user.roles: return (f当前账号无权执行该操作需要 {role}, *([no_update] * (fn._output_count - 1))) return fn(*args, **kwargs) return wrapper return decofn._output_count这种写法有点丑实际项目里我更倾向让被装饰的函数显式声明输出个数或者在装饰器外面再包一层生成器。但核心思路是拒绝时也要返回合法的输出结构否则 Dash 会报Callback returned a value with the wrong type前端表现为组件原地不动用户以为卡了。用起来是这样# dashapp/callbacks/admin.py from dash import Input, Output, callback, html from dashapp.callbacks._guard import role_required from dashapp.services import export_all callback( Output(deny-hint, children), Output(export-status, children), Input(export-btn, n_clicks), prevent_initial_callTrue, # 不加这句页面加载完就会跑一次导出 ) role_required(admin, deny_outputdeny-hint) def export_all_data(n_clicks): if not n_clicks: return , path export_all() return , html.Span(f已生成{path})prevent_initial_callTrue常被忽略。没有它n_clicks初值是None但回调依然会在页面加载时执行一次导出这种副作用直接就跑起来了。这个坑我在一个报表项目里踩过每次刷新页面都往共享目录丢一份文件。4.3 返回 401 而不是 302给 renderer 挂一个 fetch 拦截3.3 里我们让_dash-update-component在未登录时返回 401。但用户看到的是什么一个毫无反应的页面——因为 renderer 拿到 401 只会把状态塞进dash.callback的错误对象里不会自己跳转。补一个全局拦截面用index_string把自己的脚本塞到 Dash 的 bundle 之前# app.py 片段 app.index_string !DOCTYPE html html head {%metas%} title{%title%}/title {%favicon%} {%css%} script src/static/js/guard.js/script /head body {%app_entry%} footer {%config%} {%scripts%} {%renderer%} /footer /body /html // static/js/guard.js (function () { const rawFetch window.fetch; window.fetch function (...args) { return rawFetch.apply(this, args).then(function (resp) { if (resp.status 401) { const here encodeURIComponent(location.pathname location.search); location.replace(/login?next here); // replace 避免后退键又撞回来 // 返回一个永不 resolve 的 promise阻止 renderer 继续解析空 body return new Promise(function () {}); } return resp; }); }; })();为什么必须挂住返回的 promiserenderer 拿到 401 之后还会去resp.json()而我们返回的是空的 JSON 对象它随后就会在内部状态机里抛一个不容易定位的错控制台一片红。让它悬停页面已经在跳走了没人关心那个 promise。这套组合的最终效果是会话有效 → 一切正常会话过期 → 用户下一次点任何按钮时200ms 内跳回登录页且next参数带着当前路径登录后原地返回。5. 避坑记录五个让登录反复翻车的细节5.1 回调报Unexpected token 现象打开看板一切正常点某个按钮控制台报SyntaxError: Unexpected token , !DOCTYPE ... is not valid JSON页面卡死不动。原因before_request对所有未登录请求无差别redirect(/login)。renderer 发出的 fetch 跟着 302 走完拿到的是登录页的 HTML然后按 JSON 解析。解决按路径分流_dash-update-component回 401 JSON其余回 302。同时确认location.replace的拦截脚本已经生效——只改后端不改前端用户仍然只能看到“点了没反应”。5.2 换账号登录后导航栏还是上一个人的现象A 退出B 登录页面顶部的用户名变成了 B但“账号管理”入口还在B 不是管理员。原因app.layout html.Div([...])写成了静态赋值。这个列表在模块导入时求值一次之后所有请求共用同一个 Python 对象。你在里面写的current_user.is_admin只在启动那一刻算过一次。解决改成app.layout serve_layout传函数引用。验证方法是加一行日志在serve_layout里print(current_user.id)刷新页面应该每次都能看到输出。5.3 开启 CSRF 保护后所有回调 400现象登录正常页面正常但每次交互后端都返回 400日志里是The CSRF token is missing。原因CSRFProtect(app)是全局的会给所有 POST 校验csrf_token。而 Dash 的_dash-update-component是 renderer 自动发的 POST表单里根本没有这个字段。解决给这一条路径豁免别整个蓝图豁免。csrf.exempt(server.view_functions[/console/_dash-update-component])。豁免之后的补偿手段是SESSION_COOKIE_SAMESITELax加上校验Content-Type: application/json——跨站的简单表单提交带不了这个 content-type也就构造不出合法回调请求。5.4 本地调试登录成功却立刻掉线现象点登录跳到/console/一闪又回到/login。日志里current_user.is_authenticated始终是False。原因SESSION_COOKIE_SECURE True但本地是 http。浏览器收到带Secure的 cookie 直接丢弃会话压根没建立。解决用HTTPS_ONLY环境变量控制本地默认 0。另外顺手检查一下SECRET_KEY是不是每次重启随机生成——os.urandom(24)写在模块顶层的话--reload一开每次热重载都换密钥之前签发的所有 session 全部失效表现和上面一模一样但原因完全不同。5.5 多 worker 部署后会话随机失效现象gunicorn -w 4启动刷新几次页面有时是登录态有时被踢回登录页毫无规律。原因用了Flask-Session的文件或内存后端存服务端 session。4 个 worker 各自持有独立的内存字典负载均衡把请求分到哪个 worker 全看运气。解决内网看板这类规模直接用 Flask 默认的签名 cookie 就行别上服务端 session。如果确实需要服务端存储比如要支持强制下线那就必须换成 Redis 后端并且SESSION_REDIS指向同一个实例。顺带一提login_manager.session_protectionstrong在多 worker 下也要确认所有 worker 的SECRET_KEY一致否则同样表现为随机掉线。6. 二次开发把内存账号表换成企业账号体系代码跑通之后第一个要动的地方几乎都是user_loader。把auth/models.py里的find_user换成真实查询login_manager那部分一行都不用改——这就是 Flask-Login 设计得好的地方它只要求你回答一个问题给定id返回一个UserMixin实例。接统一身份认证时我的做法是“两段式”第一次校验走外部认证拿到唯一标识之后在本地 users 表里 upsert 一条记录id用本地主键而不是外部工号。这样外部系统换供应商、改字段名时你只需要改认证适配层# auth/adapters.py def upsert_from_identity(ext_id: str, display_name: str, dept: str): 外部身份 - 本地用户。角色只在首次创建时给默认值 之后的角色变更必须走本地审批避免外部系统随便给人提权。 user db.users.find_one(ext_idext_id) if user is None: user db.users.insert({ext_id: ext_id, name: display_name, dept: dept, roles: [viewer]}) return User(str(user[_id]), user[dept], set(user[roles]))角色不跟着外部系统走这一条是我吃过亏之后定下的规矩。外部目录里一个“部门管理员”的组在内部系统里可能对应完全不同的权限范围直接映射早晚出事。另一个值得加的机制是会话内的角色刷新。用户被降权之后他手上的旧 cookie 还能用 8 小时——因为user_loader只在会话建立时读了一次数据库。想更实时一点就在before_request里挂一个轻量的刷新加时间戳节流别每个请求都查库server.before_request def refresh_roles(): if not current_user.is_authenticated: return sess flask.session now time.time() if now - sess.get(roles_synced_at, 0) 300: # 5 分钟内不重复查 return fresh load_user(current_user.get_id()) if fresh is None: logout_user() # 账号被删直接下线 return redirect(/login) current_user.roles fresh.roles # user_loader 返回的是同一实例 sess[roles_synced_at] now改完一定要回头看有没有漏端点。裸眼审查靠不住写个脚本用匿名会话把所有能想到的路径扫一遍只看状态码# tools/probe_endpoints.py import requests BASE http://127.0.0.1:8050 PATHS [ /console/, /console/_dash-layout, /console/_dash-dependencies, /console/_dash-update-component, /api/export, # 你自己加的 Flask 接口 ] s requests.Session() # 不带任何 cookie 的匿名会话 for p in PATHS: r s.get(BASE p, allow_redirectsFalse) # 期望文档请求 302 到 /login回调 401。出现 200 就是漏了 verdict PASS if r.status_code in (302, 401) else LEAK print(f{verdict:4} {r.status_code} {p})预期输出对照表路径匿名期望登录后期望/console/302 →/login200/console/_dash-layout302 →/login200/console/_dash-dependencies302 →/login200/console/_dash-update-componentGET401405 或 400/api/export302 →/login200 / 按角色 403最后一列也要跑用 viewer 账号登录对管理员的接口再扫一遍/api/export应该回 403 而不是 200。只验匿名的扫描脚本会给你虚假的安全感。从那以后我每次给 Dash 应用加一个新接口都会先把tools/probe_endpoints.py的PATHS补一行再提交代码宁可多花两分钟也不想在某个下午接到“XX 部门看到了别人数据”的消息。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑