From c60932ea69d6d348e15bad68d0b9c2871f79ac55 Mon Sep 17 00:00:00 2001 From: FamousMai <906631095@qq.com> Date: Sun, 10 Aug 2025 18:16:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3oauth2.0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=A4=B1=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/server/api/v1/gaia/system_oauth2.go | 20 +-- admin/server/model/gaia/request/system.go | 32 ++--- admin/server/service/gaia/system.go | 33 +++-- .../view/systemIntegrated/oauth2/index.vue | 33 ++++- api/libs/oauth.py | 117 +++++++++++++++--- docker/docker-compose.dify-plus.yaml | 1 + 6 files changed, 184 insertions(+), 52 deletions(-) diff --git a/admin/server/api/v1/gaia/system_oauth2.go b/admin/server/api/v1/gaia/system_oauth2.go index 4700968eb..71b68e57c 100644 --- a/admin/server/api/v1/gaia/system_oauth2.go +++ b/admin/server/api/v1/gaia/system_oauth2.go @@ -60,14 +60,18 @@ func (s *SystemOAuth2Api) SetOAuth2Config(c *gin.Context) { // 序列化为JSON configBytes, err := json.Marshal(&map[string]string{ - "server_url": req.ServerURL, - "authorize_url": req.AuthorizeURL, - "token_url": req.TokenURL, - "userinfo_url": req.UserinfoURL, - "logout_url": req.LogoutURL, - "user_name_field": req.UserNameField, - "user_email_field": req.UserEmailField, - "user_id_field": req.UserIDField, + "server_url": req.ServerURL, + "authorize_url": req.AuthorizeURL, + "token_url": req.TokenURL, + "userinfo_url": req.UserinfoURL, + "logout_url": req.LogoutURL, + "discovery_url": req.DiscoveryURL, + "user_name_field": req.UserNameField, + "user_email_field": req.UserEmailField, + "user_id_field": req.UserIDField, + "scope": req.Scope, + "token_auth_method": req.TokenAuthMethod, + "redirect_uri": req.RedirectUri, }) if err != nil { global.GVA_LOG.Error("序列化OAuth2配置失败!", zap.Error(err)) diff --git a/admin/server/model/gaia/request/system.go b/admin/server/model/gaia/request/system.go index 5a5b63345..7cee9ddd1 100644 --- a/admin/server/model/gaia/request/system.go +++ b/admin/server/model/gaia/request/system.go @@ -8,18 +8,22 @@ type SystemOAuth2Error struct { // SystemOAuth2Request OAuth2 集成配置 type SystemOAuth2Request struct { - Classify uint `json:"classify" gorm:"comment:分类"` // 分类 - Status bool `json:"status" gorm:"comment:状态"` // 状态 - ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址 - AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的URL"` // 申请认证的URL - TokenURL string `json:"token_url" gorm:"comment:获取Token的URL"` // 获取Token的URL - UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息URL"` // 获取用户信息的URL - LogoutURL string `json:"logout_url" gorm:"comment:退出登录回调URL"` // 退出登录回调URL - AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID - AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret - UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段 - UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段 - UserIDField string `json:"user_id_field" gorm:"comment:用户唯一标识字段"` // 用户唯一标识字段 - Test bool `json:"test" gorm:"default:0;comment:是否测试链接联通性"` // 是否测试链接联通性 - Code string `json:"code" gorm:"default:0;comment:code代码"` // code代码 + Classify uint `json:"classify" gorm:"comment:分类"` // 分类 + Status bool `json:"status" gorm:"comment:状态"` // 状态 + ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址 + AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的URL"` // 申请认证的URL + TokenURL string `json:"token_url" gorm:"comment:获取Token的URL"` // 获取Token的URL + UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息URL"` // 获取用户信息的URL + LogoutURL string `json:"logout_url" gorm:"comment:退出登录回调URL"` // 退出登录回调URL + DiscoveryURL string `json:"discovery_url" gorm:"comment:OIDC发现配置URL"` // OIDC 发现配置URL + AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID + AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret + UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段 + UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段 + UserIDField string `json:"user_id_field" gorm:"comment:用户唯一标识字段"` // 用户唯一标识字段 + Scope string `json:"scope" gorm:"comment:授权范围scope"` // 授权范围 + TokenAuthMethod string `json:"token_auth_method" gorm:"comment:令牌端点认证方式"` // client_secret_post|client_secret_basic + RedirectUri string `json:"redirect_uri" gorm:"comment:测试用回调地址"` // 测试用回调地址 + Test bool `json:"test" gorm:"default:0;comment:是否测试链接联通性"` // 是否测试链接联通性 + Code string `json:"code" gorm:"default:0;comment:code代码"` // code代码 } diff --git a/admin/server/service/gaia/system.go b/admin/server/service/gaia/system.go index 95e6ebcad..671fde1f4 100644 --- a/admin/server/service/gaia/system.go +++ b/admin/server/service/gaia/system.go @@ -4,6 +4,12 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "github.com/faabiosr/cachego/file" "github.com/fastwego/dingding" "github.com/flipped-aurora/gin-vue-admin/server/global" @@ -12,11 +18,6 @@ import ( "github.com/flipped-aurora/gin-vue-admin/server/utils" "github.com/google/uuid" "go.uber.org/zap" - "io" - "net/http" - "net/url" - "os" - "strings" ) type SystemIntegratedService struct{} @@ -192,10 +193,17 @@ func (e *SystemIntegratedService) TestOAuth2Connection(integrate gaia.SystemInte // 合成请求byte formData := url.Values{} formData.Set("grant_type", "authorization_code") - formData.Set("client_secret", integrate.AppSecret) - formData.Set("client_id", integrate.AppID) - formData.Set("redirect_uri", "") formData.Set("code", code) + // redirect_uri 必须与授权时一致 + formData.Set("redirect_uri", strings.TrimSpace(configMap.RedirectUri)) + // 支持basic与post两种认证方式 + // 默认使用client_secret_post,除非配置为basic + tokenAuthMethod := strings.ToLower(strings.TrimSpace(configMap.TokenAuthMethod)) + useBasic := tokenAuthMethod == "client_secret_basic" + if !useBasic { + formData.Set("client_secret", integrate.AppSecret) + formData.Set("client_id", integrate.AppID) + } // 发送请求 var req *http.Request @@ -207,8 +215,13 @@ func (e *SystemIntegratedService) TestOAuth2Connection(integrate gaia.SystemInte return errors.New(fmt.Sprintf("创建测试请求失败: %s", err.Error())) } - // 设置Content-Type - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // 设置认证与Content-Type + if useBasic { + req.SetBasicAuth(integrate.AppID, integrate.AppSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } // 发送请求 resp, err := client.Do(req) diff --git a/admin/web/src/view/systemIntegrated/oauth2/index.vue b/admin/web/src/view/systemIntegrated/oauth2/index.vue index 4e10fafa9..a1dd809b6 100644 --- a/admin/web/src/view/systemIntegrated/oauth2/index.vue +++ b/admin/web/src/view/systemIntegrated/oauth2/index.vue @@ -73,11 +73,29 @@ {{ config.token_url || '未配置' }} +
+ Scope: + + {{ config.scope || 'openid profile email' }} +
+
+ 令牌端点认证方式: + + + + + {{ config.token_auth_method || 'client_secret_post' }} +
获取用户信息 URL: {{ config.userinfo_url || '未配置' }}
+
+ OIDC 发现配置 URL (.well-known): + + {{ config.discovery_url || '未配置' }} +
退出登录回调 URL: @@ -180,6 +198,10 @@ const config = ref({ user_name_field: "", user_email_field: "", user_id_field: "", + scope: "", + token_auth_method: "client_secret_post", + redirect_uri: "", + discovery_url: "", test: false, }) @@ -224,10 +246,13 @@ const copyHost = () => { // 测试连接 const testConnection = async () => { - let host = config.value.server_url - let authorizeUrl = `${host}${config.value.authorize_url}` - let redirectUri = encodeURIComponent(`${location.protocol}//${location.host}/api/base/auth2/callback`) - window.open(`${authorizeUrl}?client_id=${config.value.app_id}&response_type=code&scope=all&redirect_uri=${redirectUri}`) + const base = config.value.server_url || '' + const authPath = config.value.authorize_url || '' + let authorizeUrl = authPath.startsWith('http://') || authPath.startsWith('https://') ? authPath : `${base}${authPath}` + let redirectUriRaw = config.value.redirect_uri || `${location.protocol}//${location.host}/api/base/auth2/callback` + let redirectUri = encodeURIComponent(redirectUriRaw) + let scope = encodeURIComponent(config.value.scope || 'openid profile email') + window.open(`${authorizeUrl}?client_id=${encodeURIComponent(config.value.app_id)}&response_type=code&scope=${scope}&redirect_uri=${redirectUri}`) } const initForm = async() => { diff --git a/api/libs/oauth.py b/api/libs/oauth.py index 87ff5b348..1483d90b8 100644 --- a/api/libs/oauth.py +++ b/api/libs/oauth.py @@ -141,6 +141,47 @@ class GoogleOAuth(OAuth): # Extend Start: OAuth2 class OaOAuth(OAuth): + def _is_absolute_url(self, url: str) -> bool: + return isinstance(url, str) and (url.startswith("http://") or url.startswith("https://")) + + def _join_url(self, base: str, path_or_url: str) -> str: + if not path_or_url: + return "" + if self._is_absolute_url(path_or_url): + return path_or_url + return f"{base}{path_or_url}" + + def _resolve_endpoints(self, config: dict) -> dict: + """ + Resolve authorize/token/userinfo endpoints from config or OIDC discovery. + """ + if not isinstance(config, dict): + return {} + server_url = config.get('server_url') or '' + authorize_url = config.get('authorize_url') or '' + token_url = config.get('token_url') or '' + userinfo_url = config.get('userinfo_url') or '' + discovery_url = config.get('discovery_url') or '' + + # If any endpoint missing and discovery available, fetch + if discovery_url and (not authorize_url or not token_url or not userinfo_url): + try: + discover_full = self._join_url(server_url, discovery_url) + resp = requests.get(discover_full, timeout=10) + if resp.ok: + data = resp.json() + authorize_url = authorize_url or data.get('authorization_endpoint', '') + token_url = token_url or data.get('token_endpoint', '') + userinfo_url = userinfo_url or data.get('userinfo_endpoint', '') + except Exception: + pass + + return { + 'authorize_url': self._join_url(server_url, authorize_url), + 'token_url': self._join_url(server_url, token_url), + 'userinfo_url': self._join_url(server_url, userinfo_url), + } + def get_auto2_conf(self): # oauth start integration = db.session.query(SystemIntegrationExtend).filter( @@ -193,30 +234,51 @@ class OaOAuth(OAuth): if integration is None: return # 构建查询参数 - query_string = urllib.parse.urlencode({ + config = auto2_conf.get('config') + params = { + 'response_type': 'code', 'redirect_uri': dify_config.CONSOLE_API_URL + "/console/api/oauth/authorize/oauth2", 'client_id': integration.app_id, - }) + # 重要:未设置 scope 时,Casdoor /api/userinfo 仅返回 openid 最小字段 + # 从配置读取 scope,默认请求更完整的信息 + 'scope': (config.get('scope') if isinstance(config, dict) and config.get('scope') else 'openid profile email'), + } + if invite_token: + params['state'] = invite_token + query_string = urllib.parse.urlencode(params) - # 构建完整URL - config = auto2_conf.get('config') - return f"{config.get('server_url')}{config.get('authorize_url')}?{query_string}" + endpoints = self._resolve_endpoints(config) + auth_url = endpoints.get('authorize_url') + return f"{auth_url}?{query_string}" def get_access_token(self, code: str): auto2_conf = self.get_auto2_conf() integration = auto2_conf.get('integration') if integration is None: return "" + config = auto2_conf.get('config') + endpoints = self._resolve_endpoints(config) + token_url = endpoints.get('token_url') + token_auth_method = str(config.get('token_auth_method') or '').strip().lower() + use_basic = token_auth_method == 'client_secret_basic' + + # 构建请求 data = { - "code": code, - "client_id": integration.app_id, "grant_type": "authorization_code", - "client_secret": auto2_conf.get('passwd'), + "code": code, "redirect_uri": dify_config.CONSOLE_API_URL + "/console/api/oauth/authorize/oauth2", } headers = {"Accept": "application/json"} - config = auto2_conf.get('config') - response = requests.post(f"{config.get('server_url')}{config.get('token_url')}", data=data, headers=headers) + if use_basic: + auth = (integration.app_id, auto2_conf.get('passwd')) + else: + data.update({ + "client_id": integration.app_id, + "client_secret": auto2_conf.get('passwd'), + }) + auth = None + + response = requests.post(token_url, data=data, headers=headers, auth=auth) response.encoding = "utf-8" if response.status_code != 200: return "" @@ -233,8 +295,9 @@ class OaOAuth(OAuth): if auto2_conf.get('integration') is None: return "" config = auto2_conf.get('config') + endpoints = self._resolve_endpoints(config) headers = {"Authorization": f"Bearer {token}"} - response = requests.get(f"{config.get('server_url')}{config.get('userinfo_url')}", headers=headers) + response = requests.get(endpoints.get('userinfo_url'), headers=headers) response.raise_for_status() return response.json() @@ -247,13 +310,35 @@ class OaOAuth(OAuth): name="", email="", ) - # 提取参数 + # 提取参数(更健壮:支持点分路径、扁平键名和标准 OIDC 兜底) config = auto2_conf.get('config') - name = self.extract_data(raw_info, config.get('user_name_field')) - email = self.extract_data(raw_info, config.get('user_email_field')) - username = self.extract_data(raw_info, config.get('user_id_field')) + name_field = config.get('user_name_field') if isinstance(config, dict) else None + email_field = config.get('user_email_field') if isinstance(config, dict) else None + id_field = config.get('user_id_field') if isinstance(config, dict) else None + + # 首选:按配置路径提取 + name = self.extract_data(raw_info, name_field) if name_field else None + email = self.extract_data(raw_info, email_field) if email_field else None + username = self.extract_data(raw_info, id_field) if id_field else None + + # 如果配置为 data.name 但返回是扁平结构,尝试最后一级键名 + if name is None and isinstance(name_field, str) and '.' in name_field: + name = raw_info.get(name_field.split('.')[-1]) + if email is None and isinstance(email_field, str) and '.' in email_field: + email = raw_info.get(email_field.split('.')[-1]) + if username is None and isinstance(id_field, str) and '.' in id_field: + username = raw_info.get(id_field.split('.')[-1]) + + # OIDC 常见字段兜底 + if username is None: + username = raw_info.get('sub') or raw_info.get('preferred_username') or raw_info.get('id') or raw_info.get('user_id') + if name is None: + name = raw_info.get('name') or raw_info.get('preferred_username') + if email is None: + email = raw_info.get('email') + if not username: - raise ValueError("OAuth2返回用户数据格式不正确。请返回进行重新登录。") + raise ValueError("OAuth2返回用户数据格式不正确。请检查相关配置是否正确。响应信息为:" + str(raw_info)) return OAuthUserInfo( id=str(username) if username is not None else None, diff --git a/docker/docker-compose.dify-plus.yaml b/docker/docker-compose.dify-plus.yaml index 293b04daa..0cbe19975 100644 --- a/docker/docker-compose.dify-plus.yaml +++ b/docker/docker-compose.dify-plus.yaml @@ -473,6 +473,7 @@ services: PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800} INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} FULL_CODE_EXECUTION_ENDPOINT: ${FULL_CODE_EXECUTION_ENDPOINT:-http://sandbox-full:8194} + ALLOW_REGISTER: ${ALLOW_REGISTER:-True} depends_on: - db - redis