docs/docs-cn/5.1-superset-sso-oauth-config-guide.md
本文档面向 Superset 管理员,说明如何在 Superset 中配置 SSO 登录并与 Data Formulator(以下简称 DF)正确对接。
涵盖三部分内容:
client_id 和 client_secretDF 与 Superset 不要求接入同一个 SSO。DF 通过弹窗在 Superset 侧完成登录,再将 JWT 传回 DF。
DF 前端 Superset
│ │
│ ① 用户在 DF 数据面板点击"Login via Superset"
│ window.open 打开弹窗 │
│ ───────────────────────────────────> │ /df-sso-bridge/?df_origin=...
│ │
│ │ ② 未登录 → 重定向到 /login/ → SSO 认证
│ │ 已登录 → 直接跳到 ④
│ │
│ │ ③ SSO 认证成功 → Superset 创建 Session
│ │ 重定向回 /df-sso-bridge/
│ │
│ │ ④ 为当前用户签发 JWT
│ │ 通过 postMessage 发送给 DF
│ │ 弹窗自动关闭
│ ⑤ DF 收到 JWT │
│ <─────────────────────────────────── │
│ ⑥ Token 存入 TokenStore(session 级) │
│ ⑦ 后续 API 调用使用 JWT │
│ ───────────────────────────────────> │ Authorization: Bearer <jwt>
如果 DF 和 Superset 接入同一个 IdP,可部署 TokenExchangeView 实现后端静默换票(用户无感知)。此模式下 DF 用 SSO 的 access_token 直接向 Superset 的 /api/v1/df-token-exchange/ 换取 JWT,无需弹窗。
DF 后端 Superset
│ │
│ ① 用户在 DF 完成 SSO 登录 │
│ ② DF 后端自动发起换票请求 │
│ ───────────────────────────────────> │ POST /api/v1/df-token-exchange/
│ body: {sso_access_token: "..."} │ body: {"sso_access_token": "..."}
│ │
│ │ ③ 用 SSO token 调 IdP userinfo 验证
│ │ ④ 查找 Superset 用户 → 签发 JWT
│ │
│ ⑤ DF 收到 Superset JWT │
│ <─────────────────────────────────── │ {"access_token": "...", ...}
│ ⑥ Token 存入 TokenStore(自动) │
│ ⑦ 后续 API 调用使用 JWT │
│ ───────────────────────────────────> │ Authorization: Bearer <jwt>
前置条件: DF 和 Superset 必须接入同一个 IdP(同一个 OIDC_ISSUER_URL),且 IdP 颁发的 access_token 可用于调用 userinfo 端点。
部署步骤:
oauth_config.py 中包含第四部分 TokenExchangeView(df_exchange_bp)superset_config.py 的 FLASK_APP_MUTATOR 中注册 df_exchange_bp(参见配置示例)DF_EXCHANGE_SHARED_SECRET 环境变量限制调用方DF 端无需额外配置,SupersetLoader.auth_config() 已声明 mode: "sso_exchange" 和 exchange_url。DF 的 OIDC 回调会自动尝试换票。
验证方法:
# 用一个有效的 SSO access_token 测试换票端点
curl -X POST http://SUPERSET地址:8088/api/v1/df-token-exchange/ \
-H "Content-Type: application/json" \
-d '{"sso_access_token": "<your_sso_access_token>"}'
# 预期返回:
# {"access_token": "eyJ...", "refresh_token": "eyJ...", "expires_in": 3600, "user": {...}}
安全加固(推荐): 在 Superset 和 DF 两端设置相同的共享密钥:
# Superset 端
DF_EXCHANGE_SHARED_SECRET=your-random-secret-here
# DF 端 .env
PLG_SUPERSET_EXCHANGE_SECRET=your-random-secret-here
所有 Superset 侧配置集中在两个文件中,放在 Superset 的 PYTHONPATH 下:
superset_config.py ← Superset 主配置(导入 oauth_config)
oauth_config.py ← SSO 认证 + DF 桥接(独立文件,便于维护)
参考示例:完整的配置示例文件见
docs/docs-cn/config-examples/superset/,可作为起点按需修改。
此文件包含四部分:
SsoHandler)SSOBridgeView)TokenExchangeView)| 占位符 | 说明 | 示例 |
|---|---|---|
SsoHandler.userinfo_url | SSO 的 userinfo 端点 | https://sso.example.com/api/v1/oauth2/userinfo |
SsoHandler.role_mapping | SSO 角色 → Superset 角色映射表 | {'admin': 'Admin', 'viewer': 'Gamma'} |
<YOUR_CLIENT_ID> | Superset 在 IdP 上的 client_id | jp3zm0QtPN... |
<YOUR_CLIENT_SECRET> | Superset 在 IdP 上的 client_secret | JOFSyzoQvY... |
<YOUR_DISCOVERY_URL> | IdP 的 discovery 端点 | https://sso.example.com/.well-known/openid-configuration |
<YOUR_PROVIDER_NAME> | provider 名称(用于日志) | keycloak、okta |
import logging
import os
import secrets
import json
import requests
from urllib.parse import quote, urlparse
from flask_appbuilder.security.manager import AUTH_OAUTH
from flask_appbuilder import BaseView, expose
from flask import g, request, Response, redirect, session, render_template_string
from flask_login import current_user
from superset.security import SupersetSecurityManager
from superset import db
logger = logging.getLogger(__name__)
class SsoHandler:
"""从 SSO userinfo 端点解析用户详情。每接一个新 IdP 只需继承此类并覆盖两个字段。"""
handler_name = 'SSO系统'
userinfo_url = '<YOUR_SSO_USERINFO_URL>' # ← 替换为你的 SSO userinfo 端点
role_mapping = { # ← 替换为你的 SSO 角色 → Superset 角色映射
'admin': 'Admin',
'analyst': 'Alpha', # Alpha 有 SQL Lab 权限,可通过 DF 拉取数据
'viewer': 'Gamma', # ⚠️ Gamma 没有 SQL Lab 权限,见下方说明
}
@classmethod
def parse_user_details(cls, access_token):
"""使用 SSO access_token 调用 userinfo 端点获取用户信息。"""
resp = requests.get(
cls.userinfo_url,
headers={'Authorization': f'Bearer {access_token}'},
verify=False, # 如果 SSO 使用自签名证书则设为 False
timeout=10,
)
resp.raise_for_status()
data = resp.json()
logger.info(f"{cls.handler_name}-用户信息: {data}")
username = data.get('preferred_username') or data.get('sub')
if not username:
logger.error("用户名 username 为空!")
return None
full_name = data.get('name') or username
email = data.get('email') or f"{username}@example.com"
name_parts = full_name.split(' ', 1)
return {
'username': username,
'email': email,
'first_name': name_parts[0],
'last_name': name_parts[1] if len(name_parts) > 1 else '',
'sub': str(data.get('sub')) if data.get('sub') is not None else None,
'sso_roles': data.get('sso_roles', []),
'user_code': data.get('user_code', None),
}
⚠️ SQL Lab 权限要求: DF 通过 Superset 的 SQL Lab API(
POST /api/v1/sqllab/execute/)拉取数据集数据。 用户角色必须包含以下 FAB 权限,否则 DF 能看到数据集列表但无法加载数据:
所需权限(FAB Permission) 说明 can execute on SqlLab核心权限:允许通过 SQL Lab API 执行查询 all database access on all_database_access访问所有数据库;如不授予,则需为每个数据库单独添加 database access on [数据库名]Superset 内置角色对照:
Superset 内置角色 SQL Lab 权限 数据库访问 能否通过 DF 拉取数据 Admin ✅ 有 ✅ 全部 ✅ 可以 Alpha ✅ 有 ✅ 全部 ✅ 可以 Gamma ❌ 无 ❌ 仅授权的 ❌ 不可以 如需给 Gamma 角色用户开放 DF 数据拉取能力,有两种方式:
- 修改 Gamma 角色:在 Superset Settings → List Roles → Gamma → Edit 中添加
can execute on SqlLab权限,并按需添加对应数据库的访问权限。- 创建自定义角色(推荐):创建一个
DFViewer角色,复制 Gamma 的权限并额外添加can execute on SqlLab+ 所需的数据库访问权限。在role_mapping中将需要 DF 功能的用户映射到此角色。
如果有多个 SSO Provider(如测试环境和生产环境),可以通过继承 SsoHandler 创建多个子类,然后建立映射表:
class TestSsoHandler(SsoHandler):
handler_name = '测试系统'
userinfo_url = 'https://test-sso.example.com/api/v1/oauth2/userinfo'
# Provider → Handler 映射
PROVIDER_HANDLERS = {
'prod-sso': SsoHandler,
'test-sso': TestSsoHandler,
}
class CustomSsoSecurityManager(SupersetSecurityManager):
def oauth_user_info(self, provider, response=None):
"""从 OAuth 响应中解析用户信息。"""
access_token = response.get('access_token') if response else None
handler = PROVIDER_HANDLERS.get(provider)
if not handler or not access_token:
logger.error(f"缺少 provider handler 或 token: {provider}")
return super().oauth_user_info(provider, response)
try:
user_details = handler.parse_user_details(access_token)
user_details['active_provider'] = provider
return user_details
except Exception as e:
logger.error(f"OAuth 用户信息同步失败: {str(e)}", exc_info=True)
return None
def auth_user_oauth(self, userinfo):
"""创建或更新用户,并同步 SSO 角色。"""
username = userinfo.get('username')
provider = userinfo.get('active_provider')
handler = PROVIDER_HANDLERS.get(provider)
sso_roles = userinfo.get('sso_roles', [])
# 检查用户是否有任何已映射的角色
has_mapped_role = any(role in handler.role_mapping for role in sso_roles)
if not has_mapped_role:
from flask import flash
flash("登录失败:您的账号尚未被分配 Superset 访问权限,请联系管理员。", "danger")
return None
user = self.find_user(username=username)
if not user:
if self.auth_user_registration:
user = super().auth_user_oauth(userinfo)
else:
logger.warning(f"用户 {username} 不存在且未开启自动注册")
return None
else:
# 更新已有用户的基础信息
user.first_name = userinfo.get('first_name', user.first_name)
user.last_name = userinfo.get('last_name', user.last_name)
user.email = userinfo.get('email', user.email)
# 同步角色
if sso_roles:
self._sync_roles(user, sso_roles, handler.role_mapping)
return user
def _sync_roles(self, user, sso_roles, mapping):
"""将 SSO 角色映射为 Superset 角色。本地 Admin 身份始终保留。"""
target_names = set()
for sr in sso_roles:
mapped = mapping.get(sr)
if mapped:
target_names.add(mapped)
else:
logger.info(f"SSO 角色 '{sr}' 未在映射表中定义,跳过")
# 保护:如果用户已是 Admin,强制保留
if any(r.name == 'Admin' for r in user.roles):
target_names.add('Admin')
new_roles = []
for name in target_names:
role = self.find_role(name)
if role:
new_roles.append(role)
else:
logger.warning(f"Superset 中找不到角色: {name}")
if not new_roles:
return
try:
user.roles = new_roles
db.session.merge(user)
db.session.commit()
logger.info(f"用户 {user.username} 角色已同步: {[r.name for r in new_roles]}")
except Exception as e:
db.session.rollback()
logger.error(f"角色同步失败: {e}")
这是 DF 对接 Superset 的核心端点。完整实现如下:
_SSO_BRIDGE_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>SSO Bridge</title></head>
<body>
<p>正在同步登录状态...</p>
<script nonce="{{ csp_nonce }}">
(function() {
var payload = {{ payload | tojson }};
var targetOrigin = {{ target_origin | tojson }};
if (window.opener) {
window.opener.postMessage(payload, targetOrigin);
setTimeout(function() { window.close(); }, 500);
} else {
document.body.textContent = '登录成功,请关闭此窗口并返回 Data Formulator。';
}
})();
</script>
</body></html>"""
# 允许接收 JWT 的 DF 前端 origin 白名单。
# 可通过环境变量 DF_ALLOWED_ORIGINS 追加(逗号分隔)。
_DEFAULT_ALLOWED_ORIGINS = {
"http://localhost:5567",
"http://127.0.0.1:5567",
"http://localhost:5173",
"http://127.0.0.1:5173",
# 生产环境在此添加或通过环境变量设置
# "https://your-df-domain.com",
}
def _normalise_origin(raw):
"""规范化浏览器 origin;非法或非 origin 格式返回空字符串。"""
raw = (raw or "").strip()
parsed = urlparse(raw)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return ""
if parsed.username or parsed.password:
return ""
if parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
return ""
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def _get_allowed_origins():
origins = {_normalise_origin(o) for o in _DEFAULT_ALLOWED_ORIGINS}
env_origins = os.environ.get("DF_ALLOWED_ORIGINS", "")
for raw in env_origins.split(","):
origin = _normalise_origin(raw)
if origin:
origins.add(origin)
origins.discard("")
return origins
class SSOBridgeView(BaseView):
route_base = "/df-sso-bridge"
@staticmethod
def _is_real_logged_in_user():
"""
判断当前请求是否来自一个真正登录过的用户。
当 Superset 启用 PUBLIC_ROLE_LIKE 时,current_user.is_authenticated
对匿名用户也可能返回 True,必须检查 session._user_id。
"""
if not session.get("_user_id"):
return False
if getattr(current_user, "is_anonymous", True):
return False
if not getattr(current_user, "is_authenticated", False):
return False
if not getattr(current_user, "id", None):
return False
username = getattr(current_user, "username", "") or ""
if username.lower() in ("public", "anonymous", "guest", ""):
return False
return True
@staticmethod
def _validate_origin(raw_origin):
"""校验 df_origin 是否在白名单中。返回规范化的合法 origin 或空字符串。"""
origin = _normalise_origin(raw_origin)
return origin if origin in _get_allowed_origins() else ""
@staticmethod
def _safe_next_path(raw_path):
"""只允许站内相对路径进入 login next 参数,防止 open redirect。"""
next_path = (raw_path or "/").rstrip("?").replace("\\", "/")
parsed = urlparse(next_path)
if (
not next_path.startswith("/")
or next_path.startswith("//")
or parsed.scheme
or parsed.netloc
):
return "/df-sso-bridge/"
return next_path
@expose("/", methods=["GET"])
def df_sso_bridge(self):
logger.info(
"进入 df-sso-bridge. is_anonymous=%s, session._user_id=%s, username=%s",
getattr(current_user, "is_anonymous", "N/A"),
session.get("_user_id"),
getattr(current_user, "username", "N/A"),
)
# ── 未登录:重定向到 Superset 登录页 ──
if not self._is_real_logged_in_user():
next_url = self._safe_next_path(request.full_path)
login_redirect = f"/login/?next={quote(next_url)}"
logger.info("用户未真实登录,重定向到: %s", login_redirect)
return redirect(login_redirect)
logger.info("已验证真实用户: id=%s, username=%s", current_user.id, current_user.username)
# ── 校验 df_origin(白名单)──
df_origin = self._validate_origin(request.args.get("df_origin"))
if not df_origin:
logger.warning("df_origin 校验失败: %s", request.args.get("df_origin"))
return Response(
"Invalid df_origin. 请确认 DF 前端 origin 已加入白名单。",
status=400,
mimetype="text/plain",
)
# ── 签发 JWT ──
from flask_jwt_extended import create_access_token, create_refresh_token
user_id_str = str(current_user.id)
additional_claims = {
"user": {
"id": current_user.id,
"username": current_user.username,
"first_name": getattr(current_user, "first_name", "") or "",
"last_name": getattr(current_user, "last_name", "") or "",
}
}
access_token = create_access_token(
identity=user_id_str, fresh=True, additional_claims=additional_claims,
)
refresh_token = create_refresh_token(
identity=user_id_str, additional_claims=additional_claims,
)
logger.info("JWT 已为用户 %s (id=%s) 生成", current_user.username, current_user.id)
# ── 使用 Jinja2 模板渲染 postMessage 页面 ──
payload = {
"type": "df-sso-auth",
"access_token": access_token,
"refresh_token": refresh_token,
"user": additional_claims["user"],
}
csp_nonce = getattr(g, "csp_nonce", "") or secrets.token_urlsafe(16)
html = render_template_string(
_SSO_BRIDGE_TEMPLATE,
payload=payload,
target_origin=df_origin,
csp_nonce=csp_nonce,
)
return Response(html, mimetype="text/html")
关键设计说明:
| 要点 | 说明 |
|---|---|
_is_real_logged_in_user() | 当 Superset 启用 PUBLIC_ROLE_LIKE 时,匿名用户也拥有 is_authenticated=True,必须检查 session._user_id 和用户名 |
| 未登录重定向 | Bridge 自身负责把未登录用户送到 /login/?next=/df-sso-bridge/...,因此 DF 只需直接打开 /df-sso-bridge/ 即可 |
_safe_next_path() | 使用 urlparse 全面防御 open redirect(排除 //evil.com、\evil.com、带 scheme 的路径等) |
_validate_origin() | 用 urlparse 规范化后与白名单匹配(大小写不敏感),非法 origin 返回 400 |
DF_ALLOWED_ORIGINS 环境变量 | 生产部署时无需改代码,通过环境变量追加允许的 origin |
render_template_string | 使用 Jinja2 tojson 过滤器自动转义 HTML 特殊字符,比 f-string + json.dumps 更安全 |
window.opener 检查 | 如果非弹窗方式打开(如直接访问),则显示文字提示而不是尝试 postMessage |
| CSP nonce | 为内联 <script> 生成随机 nonce,配合 CSP 策略使用 |
OAUTH_CONFIG = {
'AUTH_TYPE': AUTH_OAUTH,
'AUTH_USER_REGISTRATION': True,
'AUTH_USER_REGISTRATION_ROLE': "Public",
'AUTH_OAUTH_ROLES_SYNC': True,
'AUTH_OAUTH_ROLES_UPDATE': True,
'CUSTOM_SECURITY_MANAGER': CustomSsoSecurityManager,
'OAUTH_PROVIDERS': [
{
'name': '<YOUR_PROVIDER_NAME>', # ← provider 名称,对应 PROVIDER_HANDLERS 的 key
'token_key': 'access_token',
'icon': 'fa-key',
'remote_app': {
'client_id': '<YOUR_CLIENT_ID>',
'client_secret': '<YOUR_CLIENT_SECRET>',
'server_metadata_url': '<YOUR_DISCOVERY_URL>',
'client_kwargs': {
'scope': 'openid profile email',
'verify': False, # 自签名证书时设为 False
},
},
}
]
}
桥接页发送的消息格式:
{
"type": "df-sso-auth",
"access_token": "eyJhbGci...",
"refresh_token": "eyJhbGci...",
"user": {
"id": 1,
"username": "zhangsan",
"first_name": "三",
"last_name": "张"
}
}
DF 前端只接受 type === 'df-sso-auth' 的消息,忽略其他 postMessage。
superset_config.py 是 Superset 的主配置文件,需要完成三件事:
oauth_config.py 中的 OAuth 配置TokenExchangeView 换票端点FLASK_APP_MUTATOR 注册所有视图和中间件try:
from oauth_config import OAUTH_CONFIG
globals().update(OAUTH_CONFIG)
print("OAuth 配置加载成功")
except ImportError as e:
print(f"OAuth 配置加载失败: {e}")
当 DF 和 Superset 接入同一个 SSO IdP 时,可启用此端点实现后端静默换票。DF 用 SSO 的 access_token 直接换取 Superset JWT,用户无需弹窗登录。
import os
import secrets
import requests
from flask_appbuilder import BaseView, expose
from flask import request, jsonify, current_app
from flask_jwt_extended import create_access_token, create_refresh_token
def _verify_exchange_secret():
"""校验共享密钥。未配置 DF_EXCHANGE_SHARED_SECRET 时放行(开发模式)。"""
expected = os.environ.get("DF_EXCHANGE_SHARED_SECRET", "").strip()
if not expected:
return True
provided = (request.headers.get("X-DF-Exchange-Secret") or "").strip()
return secrets.compare_digest(expected, provided)
class TokenExchangeView(BaseView):
route_base = "/api/v1/df-token-exchange"
@expose("/", methods=["POST"])
def exchange(self):
"""
接收 DF 后端传来的 SSO access_token,验证后签发 Superset JWT。
请求体: {"sso_access_token": "<IdP access token>"}
"""
# ⓪ 验证调用方密钥(生产环境必须设置 DF_EXCHANGE_SHARED_SECRET)
if not _verify_exchange_secret():
return jsonify({"error": "unauthorized", "message": "Invalid exchange secret"}), 403
data = request.get_json(force=True)
sso_token = data.get("sso_access_token")
if not sso_token:
return jsonify({"error": "missing_token"}), 400
# ① 用 SSO token 向 IdP userinfo 端点验证身份(复用 SsoHandler 的 URL)
try:
from oauth_config import ExampleSsoHandler # 或你的 SsoHandler 类名
resp = requests.get(
ExampleSsoHandler.userinfo_url,
headers={"Authorization": f"Bearer {sso_token}"},
verify=False,
timeout=5,
)
resp.raise_for_status()
user_info = resp.json()
except Exception:
return jsonify({"error": "sso_token_invalid"}), 401
# ② 查找 Superset 中对应的用户
username = user_info.get("preferred_username") or user_info.get("username")
if not username:
return jsonify({"error": "no_username"}), 401
sm = current_app.appbuilder.sm
user = sm.find_user(username=username)
if not user or not user.is_active:
return jsonify({"error": "user_not_in_superset"}), 403
# ③ 签发 Superset JWT(含用户信息和过期时间)
user_id_str = str(user.id)
additional_claims = {
"user": {
"id": user.id,
"username": user.username,
"first_name": getattr(user, "first_name", "") or "",
"last_name": getattr(user, "last_name", "") or "",
}
}
return jsonify({
"access_token": create_access_token(
identity=user_id_str, fresh=True, additional_claims=additional_claims,
),
"refresh_token": create_refresh_token(
identity=user_id_str, additional_claims=additional_claims,
),
"expires_in": 3600,
"user": additional_claims["user"],
})
如果不需要换票模式(DF 和 Superset 不共用 SSO),可以省略此部分。
安全加固: 生产环境务必设置共享密钥,防止未授权换票:
# Superset 端
DF_EXCHANGE_SHARED_SECRET=your-random-secret-here
# DF 端 .env
PLG_SUPERSET_EXCHANGE_SECRET=your-random-secret-here
FLASK_APP_MUTATOR 是 Superset 启动时自动调用的钩子函数。所有 DF 集成所需的视图和中间件都在此统一注册:
TALISMAN_ENABLED = False # Bridge 端点需要内联 <script>,必须关闭 Talisman
def FLASK_APP_MUTATOR(app):
import logging
_jwt_logger = logging.getLogger(__name__)
# ──── 1. 注册 SSO Bridge 视图 ────
try:
from oauth_config import SSOBridgeView
app.extensions["appbuilder"].add_view_no_menu(SSOBridgeView())
print("SSO Bridge View registered successfully!")
except Exception as e:
print(f"SSO Bridge View registration failed: {e}")
# ──── 2. 注册 Token Exchange 视图(可选,不需要可删除此段)────
try:
app.extensions["appbuilder"].add_view_no_menu(TokenExchangeView())
print("Token Exchange View registered successfully!")
except Exception as e:
print(f"Token Exchange View registration failed: {e}")
# ──── 3. JWT → g.user 同步中间件 ────
@app.before_request
def _ensure_user_from_jwt():
from flask import g, request as req
from flask_login import current_user, login_user
# 如果已经有有效的 session 用户,直接同步到 g.user
if getattr(current_user, "is_authenticated", False) \
and not getattr(current_user, "is_anonymous", True):
if not hasattr(g, "user") or g.user is None \
or getattr(g.user, "is_anonymous", True):
g.user = current_user._get_current_object()
return
# 尝试从 Authorization: Bearer <jwt> 头解析用户
auth_header = req.headers.get("Authorization", "")
if not auth_header.lower().startswith("bearer "):
return
try:
from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity
verify_jwt_in_request(optional=True)
identity = get_jwt_identity()
if not identity:
return
sm = app.appbuilder.sm
user = sm.get_user_by_id(int(identity))
if user and getattr(user, "is_active", False):
login_user(user)
g.user = user
_jwt_logger.debug(
"JWT->g.user sync OK: user_id=%s, username=%s",
user.id, user.username,
)
except Exception as exc:
_jwt_logger.debug("JWT->g.user sync skipped: %s", exc)
print("JWT->g.user middleware registered successfully!")
当 DF 用 JWT Bearer Token 调用 Superset REST API 时,flask_jwt_extended 能识别用户(/api/v1/me/ 正常),但 Flask-AppBuilder 的安全过滤器(DatasourceFilter、DashboardAccessFilter)依赖 g.user 做权限判断。如果 g.user 未从 JWT 同步,过滤器降级为 Public 角色,导致已登录用户只能看到 Public 权限的数据。
以下配置项在 superset_config.py 中也需确认正确:
# Cookie 安全配置(弹窗模式需要 Lax)
SESSION_COOKIE_SAMESITE = "Lax" # 不能为 Strict,否则弹窗无法携带 cookie
SESSION_COOKIE_SECURE = False # HTTP 环境设为 False,HTTPS 环境设为 True
SESSION_COOKIE_HTTPONLY = True
# 如果启用了 PUBLIC_ROLE_LIKE,Bridge 需要区分匿名和真实用户
PUBLIC_ROLE_LIKE = "Gamma" # 可选:让未登录用户拥有只读权限
# CORS 配置(DF 需要跨域调用 Superset API)
ENABLE_CORS = True
CORS_OPTIONS = {
'supports_credentials': True,
'allow_headers': ['*'],
'resources': ['*'],
'origins': ['*'] # 生产环境请限制为 DF 的实际域名
}
.env)# Superset 地址(必填)
PLG_SUPERSET_URL=http://你的SUPERSET地址:8088/
DF 会自动将 SSO 登录 URL 拼接为 {PLG_SUPERSET_URL}/df-sso-bridge/。大多数情况下无需额外设置。
如需覆盖(例如 Superset 通过反向代理暴露的外部地址与内部不同),可设置:
PLG_SUPERSET_SSO_LOGIN_URL=https://superset.example.com/df-sso-bridge/
⚠️ 重要:
PLG_SUPERSET_SSO_LOGIN_URL必须直接指向/df-sso-bridge/端点, 绝对不能指向/login/?next=/df-sso-bridge/。原因:当用户在浏览器中已经登录过 Superset 时,Superset 的
/login/视图检测到 已认证用户会直接重定向到首页(/superset/welcome/),完全忽略?next=参数, 导致弹窗停留在 Superset 首页而不进入 Bridge 处理逻辑,DF 无法拿到 JWT。Bridge 端点自身已经正确处理了未登录的情况——它会将未登录用户重定向到
/login/?next=/df-sso-bridge/,登录完成后再自动回到 Bridge 签发 JWT。
# ❌ 错误示例 — 已登录用户被 /login/ 重定向到 welcome 页,DF 无法获取 token
PLG_SUPERSET_SSO_LOGIN_URL=http://superset:8088/login/?next=/df-sso-bridge/
# ✅ 正确 — 直接指向 Bridge 端点
PLG_SUPERSET_SSO_LOGIN_URL=http://superset:8088/df-sso-bridge/
DF 根据 OIDC_CLIENT_SECRET 是否配置来自动选择认证模式:
| 模式 | 触发条件 | 说明 |
|---|---|---|
| 前端 PKCE(公开客户端) | 未设置 OIDC_CLIENT_SECRET | 浏览器通过 PKCE 直接与 IdP 交互,token 存在浏览器 |
| 后端 Confidential(机密客户端) | 设置了 OIDC_CLIENT_SECRET | DF 服务端处理认证,token 存在服务端 Session |
HTTPS 要求:前端 PKCE 模式要求页面通过 HTTPS 或 localhost 访问(浏览器
crypto.subtleAPI 的安全上下文限制)。纯 HTTP 部署请使用机密客户端模式。
两种模式共用统一的回调地址,在 IdP 中注册:http(s)://<your-df-host>/auth/callback
机密客户端的最小配置(端点 URL 通过自动发现获取,无需手动填写):
AUTH_PROVIDER=oidc
OIDC_ISSUER_URL=https://your-idp/oauth2
OIDC_CLIENT_ID=df-client-id
OIDC_CLIENT_SECRET=df-client-secret
如果 IdP 不支持自动发现(
.well-known/openid-configuration),才需要手动设置OIDC_AUTHORIZE_URL、OIDC_TOKEN_URL、OIDC_USERINFO_URL。
后端模式下,DF 登录完成后会自动尝试向 Superset 换票(如果已部署 TokenExchangeView),用户无需手动操作即可访问 Superset 数据。
访问 Superset 登录页,应看到 SSO 登录按钮。点击后跳转到 IdP 登录,登录成功后返回 Superset 首页。
浏览器已登录 Superset 的状态下访问:
http://SUPERSET地址:8088/df-sso-bridge/?df_origin=http://test
预期:页面显示"正在同步登录状态...",然后显示"登录成功,请关闭此窗口并返回 Data Formulator。"(因为不是从弹窗打开,没有 window.opener)
在 7.2 的页面源码中找到 access_token 的值,然后:
curl -H "Authorization: Bearer <access_token>" http://SUPERSET地址:8088/api/v1/me/
预期:返回当前用户信息 JSON。
如果已部署 TokenExchangeView:
curl -X POST http://SUPERSET地址:8088/api/v1/df-token-exchange/ \
-H "Content-Type: application/json" \
-d '{"sso_access_token": "<valid_sso_access_token>"}'
预期:返回 {"access_token": "...", "refresh_token": "...", "expires_in": 3600, "user": {...}}
如果返回 401,检查 SSO token 是否有效(可先用 curl <userinfo_url> -H "Authorization: Bearer <token>" 验证)。
如果返回 403,检查该 SSO 用户是否已在 Superset 中注册。
.env 中配置了 PLG_SUPERSET_URL)TokenExchangeView.env 中配置了 PLG_SUPERSET_URL、OIDC_* 相关变量)DF 通过 Superset 的 Chart Data API(POST /api/v1/chart/data)拉取数据,该 API 基于 dataset 级别 的权限控制,并自动应用 Row-Level Security (RLS) 规则。
用户只需要以下权限即可正常使用 DF 连接 Superset:
| 权限 | 说明 |
|---|---|
datasource access on [数据集名] | 访问特定数据集(Gamma 角色对被授权的数据集默认具备) |
不需要以下权限:
can execute on SqlLabdatabase access on [数据库名]schema access on [schema名]| 角色 | 能否使用 DF 拉取数据 | 说明 |
|---|---|---|
| Admin | 可以 | 拥有所有数据集的访问权限 |
| Alpha | 可以 | 拥有所有数据集的访问权限 |
| Gamma | 可以(仅限授权数据集) | 需要管理员授予具体数据集的 datasource access 权限 |
| 关注点 | 说明 |
|---|---|
| Bridge 端点访问控制 | 只有已通过 Superset 认证的用户可以获取 JWT,未登录自动重定向到登录页 |
| JWT 传输安全 | 通过 postMessage 只发给 window.opener,targetOrigin 参数限制目标窗口 |
| client_secret | 只存在于 Superset 服务端(oauth_config.py),不暴露给浏览器 |
| Token 存储 | DF 将 Token 存入服务端 Session(TokenStore),不写入浏览器 cookie |
| 角色权限 | SSO 角色通过映射表同步到 Superset,本地 Admin 身份始终保留 |
| 数据访问控制 | DF 通过 Chart Data API 拉取数据,仅需 datasource access 权限,RLS 自动生效 |
| TALISMAN | Bridge 端点需要内联 <script>,因此设置 TALISMAN_ENABLED = False。生产环境可改为配置 CSP nonce |
| 换票端点认证 | TokenExchangeView 通过 DF_EXCHANGE_SHARED_SECRET 限制调用方。生产环境强烈建议启用 |
| 换票 SSO token 验证 | 不仅仅解码 JWT,而是实际调用 IdP userinfo 端点验证 token 有效性 |
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 登录页没有 SSO 按钮 | oauth_config.py 导入失败 | 检查 Superset 启动日志,确认 "OAuth 配置加载成功" |
| SSO 登录后报错 | userinfo 端点返回格式不对 | 检查 SsoHandler.userinfo_url 是否正确 |
| 用户登录但无权限 | 角色映射不匹配 | 检查 SsoHandler.role_mapping |
| Bridge 弹窗空白 | FLASK_APP_MUTATOR 未注册 | 确认 Superset 重启,检查日志 |
| Superset 已登录但弹窗仍显示登录页 | session cookie 的 SameSite=Strict 阻止跨站弹窗携带 cookie | 在 superset_config.py 中设置 SESSION_COOKIE_SAMESITE = "Lax" |
弹窗停在 Superset 首页(/superset/welcome/),DF 无法获取 token | PLG_SUPERSET_SSO_LOGIN_URL 错误地指向了 /login/?next=...,已登录用户被 FAB 登录视图直接跳转首页(忽略 next 参数) | 将 URL 改为直接指向 /df-sso-bridge/(见第 6.1 节说明) |
| 弹窗登录成功但停在 Superset 首页 | SameSite 导致 cookie 未随首次请求发送 + FAB 登录页对已认证用户跳转首页 | 设置 SESSION_COOKIE_SAMESITE = "Lax",或改用 SSO Exchange 模式 |
| DF 点 SSO 登录无反应 | 弹窗被浏览器拦截 | 提示用户允许弹出窗口 |
| DF 能登录但看不到数据集 | JWT→g.user 中间件未生效 | 确认 _ensure_user_from_jwt 已注册 |
| DF 能看到数据集列表但加载数据失败(403) | 用户角色缺少 can execute on SqlLab 权限(如 Gamma) | 将用户映射到 Alpha/Admin 角色,或为角色添加 can execute on SqlLab + 对应数据库访问权限(见 4.2 节说明) |
| curl 测试 JWT 返回 401 | JWT 过期或 SECRET_KEY 不匹配 | 确认 Superset 的 SECRET_KEY 未变更 |
| 连接 Superset 特别慢 | 旧版本 DF 可能急切加载所有数据集详情 | 升级到最新版(已优化为懒加载) |
| 换票端点返回 401 | SSO token 无效或已过期 | 先用 curl 验证 SSO token 是否可以正常调用 IdP userinfo |
| 换票端点返回 403 | Superset 中没有对应用户 | 确认该 SSO 用户至少登录过一次 Superset(或开启 AUTH_USER_REGISTRATION) |
| DF SSO 登录后未自动连接 Superset | 换票端点未部署或返回错误 | 检查 Superset 日志中 "Token exchange" 相关信息 |
Q: 我已经有 CUSTOM_SECURITY_MANAGER,怎么办?
将 SSOBridgeView 的 df_sso_bridge 方法合并到你现有的 Security Manager 类中,或者像本文档这样用独立的 SSOBridgeView + FLASK_APP_MUTATOR 方式注册。
Q: Superset 只需要 SSO 登录,不需要对接 DF,还需要 Bridge 吗?
不需要。只保留 OAUTH_CONFIG 部分即可,不需要 SSOBridgeView 和 FLASK_APP_MUTATOR。
Q: 可以同时支持 SSO 和账密登录吗?
可以。AUTH_TYPE = AUTH_OAUTH 启用 OAuth 后,Superset 登录页会同时显示 SSO 按钮和传统的用户名/密码表单。DF 在数据面板中也同时提供 "Login via Superset"(弹窗 SSO)和用户名/密码连接两种方式。
Q: DF 和 Superset 必须接入同一个 SSO 吗?
不是必须的。委托弹窗模式下,DF 登录 DF 自己的 SSO,Superset 登录 Superset 的 SSO,两者可以是不同的 IdP。只有使用"SSO 换票模式"时才需要同一 IdP。