Superset与Keycloak集成后用户验证通过却触发500错误(未知主机问题)
看起来你已经把Superset和Keycloak的OIDC集成基础配置都捋顺了:Keycloak登录界面能正常弹出、用户身份验证有效、角色映射也匹配,而且Keycloak客户端里的Superset URL配置也没问题。但用户登录后直接跳转到500错误页,从日志和网络请求来看,问题出在Superset回调环节无法访问Keycloak的Token端点,核心报错是(504, b'Unknown Host')。
错误根源分析
从你提供的Docker日志Traceback可以看到,错误发生在flask_oidc处理回调时的flow.step2_exchange(code)步骤——这一步是Superset拿着Keycloak返回的授权码,去请求Keycloak的Token端点换取访问令牌,但Superset容器无法解析Keycloak的主机地址,导致请求失败触发500错误。
排查与解决方案
针对这个“未知主机”问题,你可以按以下步骤排查:
1. 验证Superset容器的DNS解析能力
因为是Docker环境,Superset容器可能无法解析Keycloak的域名/主机名。你可以进入Superset容器,尝试ping Keycloak的地址:
# 进入Superset容器(替换容器名或ID) docker exec -it superset_app bash # ping Keycloak的主机地址(比如你的Keycloak域名/IP) ping your-keycloak-hostname
如果ping不通,说明容器的DNS配置有问题,或者Keycloak的主机地址在容器网络中不可达。你可以:
- 如果Keycloak和Superset在同一Docker网络,改用Keycloak容器的服务名(比如
keycloak)作为地址; - 如果Keycloak在外部网络,确保Superset容器所在的网络能访问Keycloak的IP,并且DNS服务器能解析Keycloak的域名。
2. 检查client_secret.json中的端点地址
你的OIDC_CLIENT_SECRETS指向的client_secret.json文件里,issuer、token_endpoint、authorization_endpoint等地址必须是Superset容器能访问的地址。比如:
- 不要用
http://localhost:8080/auth/...(容器内的localhost是容器自身,不是宿主机); - 改用宿主机的局域网IP,或者Docker内部服务名(如果Keycloak也是容器)。
3. 检查网络与代理配置
- 如果Superset需要通过代理访问外部网络,要确保
httplib2(flask_oidc依赖的HTTP库)的代理配置正确。你可以在superset_config.py里添加代理相关配置:
import os os.environ['HTTP_PROXY'] = 'http://your-proxy:port' os.environ['HTTPS_PROXY'] = 'http://your-proxy:port'
- 检查防火墙或安全组设置,确保Superset容器能访问Keycloak的8080(或自定义)端口。
4. 验证角色解析逻辑(可选)
虽然当前错误是主机解析问题,但如果后续解决后还有角色相关问题,你可以检查security.py中的findAccessRoles函数:确保AFIP_SERVICE_ID和Keycloak客户端的客户端ID完全匹配,否则会找不到resource_access里的角色。
你提供的配置与日志参考
Superset配置(superset_config.py)
from security import OIDCSecurityManager from flask_appbuilder.security.manager import AUTH_OID logger.info('@keycloak config ..') AUTH_TYPE = AUTH_OID curr = os.path.abspath(os.getcwd()) OIDC_CLIENT_SECRETS = curr + '/docker/pythonpath_dev/client_secret.json' OIDC_ID_TOKEN_COOKIE_SECURE = False OIDC_REQUIRE_VERIFIED_EMAIL = False OIDC_ID_TOKEN_COOKIE_NAME = 'pergamino' AUTH_USER_REGISTRATION = True #AUTH_USER_REGISTRATION_ROLE = 'Gamma' AUTH_USER_REGISTRATION_ROLE = 'Public' CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
自定义Security Manager(security.py)
from flask import redirect, request, abort from flask_appbuilder.security.manager import AUTH_OID from superset.security import SupersetSecurityManager from flask_oidc import OpenIDConnect from flask_appbuilder.security.views import AuthOIDView from flask_login import login_user from urllib.parse import quote from flask_appbuilder.views import ModelView, SimpleFormView, expose import logging import json from base64 import b64decode import os logger = logging.getLogger() AFIP_SERVICE_ID = os.environ.get("AFIP_SERVICE_ID", "pergamino") class AuthOIDCView(AuthOIDView): @expose('/login/', methods=['GET', 'POST']) def login(self, flag=True): sm = self.appbuilder.sm oidc = sm.oid @self.appbuilder.sm.oid.require_login def handle_login(): if not oidc.get_access_token(): logger.warn('Not found access_token') return self.logout() roles = findAccessRoles(sm) logger.debug('findAccessRoles %s' % roles) if not roles or len(roles) == 0: logger.warn('Not found roles') return self.logout() info = oidc.user_getinfo( ['preferred_username', 'given_name', 'family_name', 'email', 'sub']) email = oidc.user_getfield('email') if email is None: logger.debug('email no informado, se intenta crear un valor') username = oidc.user_getfield('username') or info.get('preferred_username') email = username + "@afip.gob.ar" user = sm.auth_user_oid(email) if user is None: logger.debug('crear usuario %s' % email) user = sm.add_user(info.get('preferred_username'), info.get( 'given_name'), info.get('family_name'), email, roles[0]) if len(roles) > 1: user.roles = roles sm.update_user(user) elif len(roles) != len(user.roles) or not all(e in roles for e in user.roles): logger.debug('actualizar usuario %s' % email) user.roles = roles sm.update_user(user) logger.debug('user.roles %s' % user.roles) login_user(user, remember=False) return redirect(self.appbuilder.get_url_for_index) return handle_login() @expose('/logout/', methods=['GET', 'POST']) def logout(self): oidc = self.appbuilder.sm.oid oidc.logout() super(AuthOIDCView, self).logout() redirect_url = request.url_root.strip( '/') + self.appbuilder.get_url_for_login return redirect(oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url)) class OIDCSecurityManager(SupersetSecurityManager): authoidview = AuthOIDCView def __init__(self, appbuilder): super(OIDCSecurityManager, self).__init__(appbuilder) if self.auth_type == AUTH_OID: self.oid = OpenIDConnect(self.appbuilder.get_app) def findAccessRoles(sm): try: pre, tkn, post = sm.oid.get_access_token().split('.') # logger.debug("get_access_token split . {},{},{}".format(pre, tkn, post)) if not tkn: return None idp_token_decode = decode_base64(tkn) logger.debug('idp_token_decode %s' % idp_token_decode) access_token = json.loads(idp_token_decode) logger.debug('access_token in key resource_access.%s.roles' % AFIP_SERVICE_ID) rolesAccess = access_token['resource_access'][AFIP_SERVICE_ID]['roles'] except Exception as e: logger.error('Failed to parser access_token: '+ str(e)) return None logger.debug('rolesAccess %s' % rolesAccess) roles = None if rolesAccess or len(rolesAccess) > 0: roles = [] for r in rolesAccess: find = sm.find_role(r.capitalize()) if find: roles.append(find) return roles def decode_base64(data): ''' FIX -> OIDC + Python3 + Incorrect padding during base64decode ''' missing_padding = 4-len(data) % 4 if missing_padding: data = f"{data}{'=' * missing_padding:}" return b64decode(data)
Docker错误日志
superset_app | 10.30.61.186 - - [10/Aug/2023:14:09:06 +0000] "GET /superset/welcome/ HTTP/1.0" 302 201 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" superset_app | 10.30.61.186 - - [10/Aug/2023:14:09:06 +0000] "GET /login/ HTTP/1.0" 302 1449 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" superset_app | 127.0.0.1 - - [10/Aug/2023:14:09:10 +0000] "GET /health HTTP/1.1" 200 2 "-" "curl/7.74.0" superset_app | (504, b'Unknown Host') superset_app | Traceback (most recent call last): superset_app | File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1517, in full_dispatch_request superset_app | rv = self.dispatch_request() superset_app | File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1503, in dispatch_request superset_app | return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args) superset_app | File "/usr/local/lib/python3.8/site-packages/flask_oidc/__init__.py", line 657, in _oidc_callback superset_app | plainreturn, data = self._process_callback('destination') superset_app | File "/usr/local/lib/python3.8/site-packages/flask_oidc/__init__.py", line 689, in _process_callback superset_app | credentials = flow.step2_exchange(code) superset_app | File "/usr/local/lib/python3.8/site-packages/oauth2client/_helpers.py", line 133, in positional_wrapper superset_app | return wrapped(*args, **kwargs) superset_app | File "/usr/local/lib/python3.8/site-packages/oauth2client/client.py", line 2053, in step2_exchange superset_app | resp, content = transport.request( superset_app | File "/usr/local/lib/python3.8/site-packages/oauth2client/transport.py", line 280, in request superset_app | return http_callable(uri, method=method, body=body, headers=headers, superset_app | File "/usr/local/lib/python3.8/site-packages/httplib2/__init__.py", line 1724, in request superset_app | (response, content) = self._request( superset_app | File "/usr/local/lib/python3.8/site-packages/httplib2/__init__.py", line 1444, in _request superset_app | (response, content) = self._conn_request(conn, request_uri, method, body, headers) superset_app | File "/usr/local/lib/python3.8/site-packages/httplib2/__init__.py", line 1366, in _conn_request superset_app | conn.connect() superset_app | File "/usr/local/lib/python3.8/site-packages/httplib2/__init__.py", line 1156, in connect superset_app | sock.connect((self.host, self.port)) superset_app | File "/usr/local/lib/python3.8/site-packages/httplib2/socks.py", line 504, in connect superset_app | self.__negotiatehttp(destpair[0], destpair[1]) superset_app | File "/usr/local/lib/python3.8/site-packages/httplib2/socks.py", line 465, in __negotiatehttp superset_app | raise HTTPError((statuscode, statusline[2])) superset_app | httplib2.socks.HTTPError: (504, b'Unknown Host') superset_app | 2023-08-10 14:09:32,111:ERROR:superset.views.base:(504, b'Unknown Host')
备注:内容来源于stack exchange,提问作者basarix

