Back to Data Formulator

Superset SSO + Data Formulator 对接配置指南

docs/docs-cn/5.1-superset-sso-oauth-config-guide.md

0.7.041.9 KB
Original Source

Superset SSO + Data Formulator 对接配置指南

本文档面向 Superset 管理员,说明如何在 Superset 中配置 SSO 登录并与 Data Formulator(以下简称 DF)正确对接。

涵盖三部分内容:

  1. Superset 接入 SSO(OAuth2 登录)
  2. DF 桥接端点(让 DF 能获取 Superset JWT)
  3. JWT → g.user 中间件(让 DF 的 API 调用拥有正确权限)

1. 前置条件

  • Superset 已部署并可正常访问
  • 已有 OAuth2/OIDC 身份提供商(IdP),并为 Superset 注册了一个 Confidential Client
  • 已知以下信息:
    • IdP 的 discovery 端点 URL
    • Superset 的 client_idclient_secret

2. 工作原理

2.1 委托弹窗模式(默认)

DF 与 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>

2.2 SSO 换票模式(可选,需额外部署)

如果 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 端点。

部署步骤:

  1. 确保 oauth_config.py 中包含第四部分 TokenExchangeViewdf_exchange_bp
  2. superset_config.pyFLASK_APP_MUTATOR 中注册 df_exchange_bp(参见配置示例)
  3. (可选)设置 DF_EXCHANGE_SHARED_SECRET 环境变量限制调用方

DF 端无需额外配置SupersetLoader.auth_config() 已声明 mode: "sso_exchange"exchange_url。DF 的 OIDC 回调会自动尝试换票。

验证方法:

bash
# 用一个有效的 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 两端设置相同的共享密钥:

env
# Superset 端
DF_EXCHANGE_SHARED_SECRET=your-random-secret-here

# DF 端 .env
PLG_SUPERSET_EXCHANGE_SECRET=your-random-secret-here

3. 文件结构

所有 Superset 侧配置集中在两个文件中,放在 Superset 的 PYTHONPATH 下:

superset_config.py      ← Superset 主配置(导入 oauth_config)
oauth_config.py         ← SSO 认证 + DF 桥接(独立文件,便于维护)

参考示例:完整的配置示例文件见 docs/docs-cn/config-examples/superset/,可作为起点按需修改。


4. oauth_config.py — SSO 认证 + DF 桥接

此文件包含四部分:

  1. SSO 用户信息解析(SsoHandler
  2. 自定义 Security Manager(用户创建、角色同步)
  3. DF SSO 桥接端点(SSOBridgeView
  4. (可选)DF SSO 换票端点(TokenExchangeView

4.1 需要修改的占位符

占位符说明示例
SsoHandler.userinfo_urlSSO 的 userinfo 端点https://sso.example.com/api/v1/oauth2/userinfo
SsoHandler.role_mappingSSO 角色 → Superset 角色映射表{'admin': 'Admin', 'viewer': 'Gamma'}
<YOUR_CLIENT_ID>Superset 在 IdP 上的 client_idjp3zm0QtPN...
<YOUR_CLIENT_SECRET>Superset 在 IdP 上的 client_secretJOFSyzoQvY...
<YOUR_DISCOVERY_URL>IdP 的 discovery 端点https://sso.example.com/.well-known/openid-configuration
<YOUR_PROVIDER_NAME>provider 名称(用于日志)keycloakokta

4.2 第一部分:SsoHandler — SSO 用户信息解析

python
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 数据拉取能力,有两种方式:

  1. 修改 Gamma 角色:在 Superset Settings → List Roles → Gamma → Edit 中添加 can execute on SqlLab 权限,并按需添加对应数据库的访问权限。
  2. 创建自定义角色(推荐):创建一个 DFViewer 角色,复制 Gamma 的权限并额外添加 can execute on SqlLab + 所需的数据库访问权限。在 role_mapping 中将需要 DF 功能的用户映射到此角色。

如果有多个 SSO Provider(如测试环境和生产环境),可以通过继承 SsoHandler 创建多个子类,然后建立映射表:

python
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,
}

4.3 第二部分:CustomSsoSecurityManager — 用户创建 + 角色同步

python
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}")

4.4 第三部分:SSOBridgeView — DF 弹窗桥接端点

这是 DF 对接 Superset 的核心端点。完整实现如下:

python
_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 策略使用

4.5 第四部分:OAUTH_CONFIG 导出

python
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
                },
            },
        }
    ]
}

4.6 postMessage 协议

桥接页发送的消息格式:

json
{
    "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。


5. superset_config.py — 导入 OAuth 配置 + 注册中间件

superset_config.py 是 Superset 的主配置文件,需要完成三件事:

  1. 导入 oauth_config.py 中的 OAuth 配置
  2. (可选)定义 TokenExchangeView 换票端点
  3. 通过 FLASK_APP_MUTATOR 注册所有视图和中间件

5.1 导入 OAuth 配置

python
try:
    from oauth_config import OAUTH_CONFIG
    globals().update(OAUTH_CONFIG)
    print("OAuth 配置加载成功")
except ImportError as e:
    print(f"OAuth 配置加载失败: {e}")

5.2 TokenExchangeView — 同 IdP 换票端点(可选)

当 DF 和 Superset 接入同一个 SSO IdP 时,可启用此端点实现后端静默换票。DF 用 SSO 的 access_token 直接换取 Superset JWT,用户无需弹窗登录。

python
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),可以省略此部分。

安全加固: 生产环境务必设置共享密钥,防止未授权换票:

env
# Superset 端
DF_EXCHANGE_SHARED_SECRET=your-random-secret-here

# DF 端 .env
PLG_SUPERSET_EXCHANGE_SECRET=your-random-secret-here

5.3 FLASK_APP_MUTATOR — 统一注册入口

FLASK_APP_MUTATOR 是 Superset 启动时自动调用的钩子函数。所有 DF 集成所需的视图和中间件都在此统一注册:

python
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!")

5.4 为什么需要 JWT→g.user 中间件?

当 DF 用 JWT Bearer Token 调用 Superset REST API 时,flask_jwt_extended 能识别用户(/api/v1/me/ 正常),但 Flask-AppBuilder 的安全过滤器(DatasourceFilterDashboardAccessFilter)依赖 g.user 做权限判断。如果 g.user 未从 JWT 同步,过滤器降级为 Public 角色,导致已登录用户只能看到 Public 权限的数据。

5.5 其他重要配置项

以下配置项在 superset_config.py 中也需确认正确:

python
# 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 的实际域名
}

6. DF 端配置

6.1 基础配置(.env

env
# Superset 地址(必填)
PLG_SUPERSET_URL=http://你的SUPERSET地址:8088/

DF 会自动将 SSO 登录 URL 拼接为 {PLG_SUPERSET_URL}/df-sso-bridge/大多数情况下无需额外设置

如需覆盖(例如 Superset 通过反向代理暴露的外部地址与内部不同),可设置:

env
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。

env
# ❌ 错误示例 — 已登录用户被 /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/

6.2 DF 认证模式(自动推导)

DF 根据 OIDC_CLIENT_SECRET 是否配置来自动选择认证模式:

模式触发条件说明
前端 PKCE(公开客户端)未设置 OIDC_CLIENT_SECRET浏览器通过 PKCE 直接与 IdP 交互,token 存在浏览器
后端 Confidential(机密客户端)设置了 OIDC_CLIENT_SECRETDF 服务端处理认证,token 存在服务端 Session

HTTPS 要求:前端 PKCE 模式要求页面通过 HTTPS 或 localhost 访问(浏览器 crypto.subtle API 的安全上下文限制)。纯 HTTP 部署请使用机密客户端模式。

两种模式共用统一的回调地址,在 IdP 中注册:http(s)://<your-df-host>/auth/callback

机密客户端的最小配置(端点 URL 通过自动发现获取,无需手动填写):

env
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_URLOIDC_TOKEN_URLOIDC_USERINFO_URL

后端模式下,DF 登录完成后会自动尝试向 Superset 换票(如果已部署 TokenExchangeView),用户无需手动操作即可访问 Superset 数据。


7. 验证步骤

7.1 验证 SSO 登录

访问 Superset 登录页,应看到 SSO 登录按钮。点击后跳转到 IdP 登录,登录成功后返回 Superset 首页。

7.2 验证 Bridge 端点

浏览器已登录 Superset 的状态下访问:

http://SUPERSET地址:8088/df-sso-bridge/?df_origin=http://test

预期:页面显示"正在同步登录状态...",然后显示"登录成功,请关闭此窗口并返回 Data Formulator。"(因为不是从弹窗打开,没有 window.opener

7.3 验证 JWT

在 7.2 的页面源码中找到 access_token 的值,然后:

bash
curl -H "Authorization: Bearer <access_token>" http://SUPERSET地址:8088/api/v1/me/

预期:返回当前用户信息 JSON。

7.4 验证换票端点(仅 SSO Exchange 模式)

如果已部署 TokenExchangeView

bash
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 中注册。

7.5 端到端测试(委托弹窗模式)

  1. 启动 DF(确保 .env 中配置了 PLG_SUPERSET_URL
  2. 打开 DF → 数据面板 → 选择 Superset 数据源
  3. 点击 "Login via Superset"
  4. 弹窗打开 → SSO 登录(如果浏览器已有 SSO 会话则自动完成)→ 弹窗关闭
  5. DF 显示 Superset 仪表盘和数据集列表
  6. 选择仪表盘 → 勾选需要的数据集 → 设置筛选条件 → 点击 "Import Selected"

7.6 端到端测试(SSO Exchange 模式)

  1. 确保 Superset 已部署 TokenExchangeView
  2. 启动 DF(确保 .env 中配置了 PLG_SUPERSET_URLOIDC_* 相关变量)
  3. 在 DF 中完成 SSO 登录(DF 后端回调会自动尝试换票)
  4. 打开 DF → 数据面板 → 选择 Superset 数据源
  5. 无需点击 "Login via Superset",应自动连接并显示数据集列表
  6. 如果自动连接未生效,检查 DF 后端日志中是否有 "SSO exchange failed" 或 "SSO token exchange succeeded" 信息

8. Superset 权限要求

DF 通过 Superset 的 Chart Data APIPOST /api/v1/chart/data)拉取数据,该 API 基于 dataset 级别 的权限控制,并自动应用 Row-Level Security (RLS) 规则。

8.1 最低权限

用户只需要以下权限即可正常使用 DF 连接 Superset:

权限说明
datasource access on [数据集名]访问特定数据集(Gamma 角色对被授权的数据集默认具备)

不需要以下权限:

  • can execute on SqlLab — DF 不使用 SQL Lab API
  • database access on [数据库名] — DF 不直接访问数据库
  • schema access on [schema名] — DF 不直接访问 schema

8.2 内置角色权限对照

角色能否使用 DF 拉取数据说明
Admin可以拥有所有数据集的访问权限
Alpha可以拥有所有数据集的访问权限
Gamma可以(仅限授权数据集)需要管理员授予具体数据集的 datasource access 权限

8.3 安全优势

  • dataset 级别隔离:用户无法通过 DF 访问未被授权的数据集,即使这些数据集与已授权的数据集在同一个数据库/schema 下
  • RLS 自动生效:Superset 配置的行级安全规则会自动应用到 DF 的数据拉取请求中
  • 无 SQL 注入风险:DF 不直接构建 SQL,所有查询通过 Superset 的 Chart Data API 执行

9. 安全说明

关注点说明
Bridge 端点访问控制只有已通过 Superset 认证的用户可以获取 JWT,未登录自动重定向到登录页
JWT 传输安全通过 postMessage 只发给 window.openertargetOrigin 参数限制目标窗口
client_secret只存在于 Superset 服务端(oauth_config.py),不暴露给浏览器
Token 存储DF 将 Token 存入服务端 Session(TokenStore),不写入浏览器 cookie
角色权限SSO 角色通过映射表同步到 Superset,本地 Admin 身份始终保留
数据访问控制DF 通过 Chart Data API 拉取数据,仅需 datasource access 权限,RLS 自动生效
TALISMANBridge 端点需要内联 <script>,因此设置 TALISMAN_ENABLED = False。生产环境可改为配置 CSP nonce
换票端点认证TokenExchangeView 通过 DF_EXCHANGE_SHARED_SECRET 限制调用方。生产环境强烈建议启用
换票 SSO token 验证不仅仅解码 JWT,而是实际调用 IdP userinfo 端点验证 token 有效性

10. 故障排查

现象可能原因解决方法
登录页没有 SSO 按钮oauth_config.py 导入失败检查 Superset 启动日志,确认 "OAuth 配置加载成功"
SSO 登录后报错userinfo 端点返回格式不对检查 SsoHandler.userinfo_url 是否正确
用户登录但无权限角色映射不匹配检查 SsoHandler.role_mapping
Bridge 弹窗空白FLASK_APP_MUTATOR 未注册确认 Superset 重启,检查日志
Superset 已登录但弹窗仍显示登录页session cookie 的 SameSite=Strict 阻止跨站弹窗携带 cookiesuperset_config.py 中设置 SESSION_COOKIE_SAMESITE = "Lax"
弹窗停在 Superset 首页(/superset/welcome/),DF 无法获取 tokenPLG_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 返回 401JWT 过期或 SECRET_KEY 不匹配确认 Superset 的 SECRET_KEY 未变更
连接 Superset 特别慢旧版本 DF 可能急切加载所有数据集详情升级到最新版(已优化为懒加载)
换票端点返回 401SSO token 无效或已过期先用 curl 验证 SSO token 是否可以正常调用 IdP userinfo
换票端点返回 403Superset 中没有对应用户确认该 SSO 用户至少登录过一次 Superset(或开启 AUTH_USER_REGISTRATION
DF SSO 登录后未自动连接 Superset换票端点未部署或返回错误检查 Superset 日志中 "Token exchange" 相关信息

10. 常见问题

Q: 我已经有 CUSTOM_SECURITY_MANAGER,怎么办?

SSOBridgeViewdf_sso_bridge 方法合并到你现有的 Security Manager 类中,或者像本文档这样用独立的 SSOBridgeView + FLASK_APP_MUTATOR 方式注册。

Q: Superset 只需要 SSO 登录,不需要对接 DF,还需要 Bridge 吗?

不需要。只保留 OAUTH_CONFIG 部分即可,不需要 SSOBridgeViewFLASK_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。