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