Files
dify-plus/admin/server/service/gaia/gaia_login.go
T
2026-03-11 12:05:53 +08:00

439 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gaia
import (
"bytes"
"encoding/json"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/pkg/errors"
"go.uber.org/zap"
"gorm.io/gorm"
"io"
"net/http"
"net/url"
"os"
"strings"
)
// OAuth2CodeLogin 使用 Gaia 系统 OAuth2 配置:code 换 token 或直接用 access_tokenExtend: 兼容 casdoor)、拉用户信息、查找/创建用户、签发 JWT
func (e *SystemIntegratedService) OAuth2CodeLogin(
req request.GaiaOAuth2LoginReq) (*response.GaiaLoginResult, error) {
// init
var accessToken, tokenType string
var configMap request.SystemOAuth2Request
if strings.TrimSpace(req.Code) == "" && strings.TrimSpace(req.AccessToken) == "" {
return nil, fmt.Errorf("请提供 code 或 access_token")
}
integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationOAuth2)
if !integrate.Status {
return nil, fmt.Errorf("OAuth2 未启用")
}
if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
return nil, fmt.Errorf("OAuth2 配置解析失败")
}
if configMap.UserinfoURL == "" {
return nil, fmt.Errorf("OAuth2 配置不完整(缺少 userinfo")
}
if strings.TrimSpace(req.AccessToken) != "" {
accessToken = strings.TrimSpace(req.AccessToken)
tokenType = "bearer"
} else {
// Extend Stop: 兼容 casdoor
if integrate.AppID == "" || integrate.AppSecret == "" || configMap.TokenURL == "" {
return nil, fmt.Errorf("OAuth2 配置不完整")
}
redirectURI := strings.TrimSpace(configMap.RedirectUri)
if redirectURI == "" {
redirectURI = req.RedirectURI
}
formData := url.Values{}
formData.Set("code", req.Code)
formData.Set("redirect_uri", redirectURI)
formData.Set("grant_type", "authorization_code")
tokenAuthMethod := strings.ToLower(strings.TrimSpace(configMap.TokenAuthMethod))
if tokenAuthMethod != "client_secret_basic" {
formData.Set("client_id", integrate.AppID)
formData.Set("client_secret", integrate.AppSecret)
}
tokenURL := strings.TrimSuffix(configMap.ServerURL, "/") + configMap.TokenURL
httpReq, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if tokenAuthMethod == "client_secret_basic" {
httpReq.SetBasicAuth(integrate.AppID, integrate.AppSecret)
}
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求 token 失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
global.GVA_LOG.Error("OAuth2 token 接口非 200", zap.Int("status", resp.StatusCode), zap.String("body", string(body)))
return nil, fmt.Errorf("OAuth2 返回错误: %d", resp.StatusCode)
}
var tokenResp map[string]interface{}
if err = json.Unmarshal(body, &tokenResp); err != nil || tokenResp["access_token"] == "" {
return nil, fmt.Errorf("解析 OAuth2 token 失败")
}
accessToken = tokenResp["access_token"].(string)
if tokenCache, ok := tokenResp["token_type"]; ok {
tokenType = strings.ToLower(tokenCache.(string))
} else {
tokenType = "bearer"
}
// Extend Start: 兼容 casdoor
}
// Extend Stop: 兼容 casdoor
// 拉用户信息
userInfoURL := strings.TrimSuffix(configMap.ServerURL, "/") + configMap.UserinfoURL
userReq, err := http.NewRequest("GET", userInfoURL, nil)
if err != nil {
return nil, err
}
if strings.ToLower(tokenType) == "bearer" {
userReq.Header.Set("Authorization", "Bearer "+accessToken)
} else {
userReq.Header.Set("Authorization", accessToken)
}
client := &http.Client{}
userResp, err := client.Do(userReq)
if err != nil {
return nil, fmt.Errorf("请求用户信息失败: %w", err)
}
defer userResp.Body.Close()
userBody, _ := io.ReadAll(userResp.Body)
if userResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("用户信息接口返回: %d", userResp.StatusCode)
}
var userInfoMap map[string]interface{}
if err = json.Unmarshal(userBody, &userInfoMap); err != nil {
return nil, fmt.Errorf("解析用户信息失败")
}
// 用户信息映射:支持 Jinja 风格路径,如 name、email、phone 或 user.name、data.attributes.phone
username := getStringByPathOrKeys(userInfoMap, configMap.UserNameField, "name", "username", "preferred_username")
email := getStringByPathOrKeys(userInfoMap, configMap.UserEmailField, "email", "sub")
userID := getStringByPathOrKeys(userInfoMap, configMap.UserIDField, "phone", "sub", "id")
if username == "" {
username = email
if username == "" {
username = userID
}
}
if email == "" && userID == "" {
return nil, fmt.Errorf("无法从 OAuth2 用户信息中获取邮箱或用户唯一标识")
}
sysUser, err := e.findUserByEmailOrPhone(email, userID)
if err != nil {
return nil, err
}
token, _, err := utils.LoginToken(sysUser)
if err != nil {
global.GVA_LOG.Error("签发 JWT 失败", zap.Error(err))
return nil, fmt.Errorf("签发 token 失败")
}
return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
}
// DingTalkTestCallback 仅用 code 换 token,用于「测试连接」回调,不登录、不写 session
func (e *SystemIntegratedService) DingTalkTestCallback(code string) error {
code = strings.TrimSpace(code)
if code == "" {
return fmt.Errorf("授权码为空")
}
integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
if integrate.AppKey == "" || integrate.AppSecret == "" {
return fmt.Errorf("钉钉配置不完整")
}
bodyJSON, _ := json.Marshal(map[string]string{
"clientId": integrate.AppKey,
"clientSecret": integrate.AppSecret,
"code": code,
"grantType": "authorization_code",
})
httpReq, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", bytes.NewReader(bodyJSON))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("钉钉 token 请求失败: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
global.GVA_LOG.Error("钉钉 token 非 200", zap.Int("status", resp.StatusCode), zap.String("body", string(respBody)))
return fmt.Errorf("钉钉返回错误: %d", resp.StatusCode)
}
var tokenResp map[string]interface{}
if err = json.Unmarshal(respBody, &tokenResp); err != nil || tokenResp["accessToken"] == "" {
return fmt.Errorf("解析钉钉 token 失败")
}
return nil
}
// DingTalkCodeLogin 钉钉 code 换用户并登录(扫码/OAuth2 回调带 code
func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLoginReq) (*response.GaiaLoginResult, error) {
integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
if !integrate.Status {
return nil, fmt.Errorf("钉钉登录未启用")
}
if integrate.AppKey == "" || integrate.AppSecret == "" {
return nil, fmt.Errorf("钉钉配置不完整")
}
// 钉钉 OAuth2: 用 code 换 userAccessToken
bodyJSON, _ := json.Marshal(map[string]string{
"clientId": integrate.AppKey,
"clientSecret": integrate.AppSecret,
"code": req.AuthCode,
"grantType": "authorization_code",
})
httpReq, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", bytes.NewReader(bodyJSON))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("钉钉 token 请求失败: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
global.GVA_LOG.Error("钉钉 token 非 200", zap.Int("status", resp.StatusCode), zap.String("body", string(respBody)))
return nil, fmt.Errorf("钉钉返回错误: %d", resp.StatusCode)
}
var tokenResp map[string]interface{}
if err = json.Unmarshal(respBody, &tokenResp); err != nil || tokenResp["accessToken"] == "" {
return nil, fmt.Errorf("解析钉钉 token 失败")
}
// 获取用户信息
userReq, _ := http.NewRequest("GET", "https://api.dingtalk.com/v1.0/contact/users/me", nil)
userReq.Header.Set("x-acs-dingtalk-access-token", tokenResp["accessToken"].(string))
userResp, err := client.Do(userReq)
if err != nil {
return nil, fmt.Errorf("钉钉用户信息请求失败: %w", err)
}
defer userResp.Body.Close()
userBody, _ := io.ReadAll(userResp.Body)
if userResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("钉钉用户信息返回: %d", userResp.StatusCode)
}
var dingUser map[string]interface{}
if err = json.Unmarshal(userBody, &dingUser); err != nil {
return nil, fmt.Errorf("解析钉钉用户信息失败")
}
// 提取钉钉 IDuser_id 字段)
dingId := ""
if v, ok := dingUser["unionId"]; ok && v != nil {
dingId, _ = v.(string)
}
if dingId == "" {
if v, ok := dingUser["userId"]; ok && v != nil {
dingId, _ = v.(string)
}
}
// 解析邮箱配置
var configMap request.DingTalkConfigRequest
var emailConfig request.EmailApiConfig
if integrate.Config != "" {
if jsonErr := json.Unmarshal([]byte(integrate.Config), &configMap); jsonErr == nil {
var rawMsg json.RawMessage
if rawBytes, marshalErr := json.Marshal(configMap.EmailApi); marshalErr == nil {
rawMsg = rawBytes
if cfg, parseErr := parseEmailApiConfigFromJSON(rawMsg); parseErr == nil {
emailConfig = cfg
}
}
}
}
// 优先通过邮箱 API 获取邮箱(新格式)
if emailConfig.Enabled && dingId != "" {
email, apiErr := e.callEmailApi(dingId, emailConfig)
if apiErr == nil && email != "" {
global.GVA_LOG.Info("DingTalkCodeLogin: 通过第三方邮箱 API 获取邮箱",
zap.String("ding_id", dingId), zap.String("email", email))
sysUser, findErr := e.findUserByEmail(email)
if findErr != nil {
return nil, findErr
}
token, _, tokenErr := utils.LoginToken(sysUser)
if tokenErr != nil {
return nil, fmt.Errorf("签发 token 失败")
}
return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
}
global.GVA_LOG.Warn("DingTalkCodeLogin: 第三方邮箱 API 获取失败,尝试钉钉直接返回邮箱",
zap.String("ding_id", dingId), zap.Error(apiErr))
}
// 回退:直接从钉钉用户信息获取邮箱
email, _ := dingUser["email"].(string)
username, _ := dingUser["nick"].(string)
if username == "" {
username = email
}
if email == "" {
return nil, fmt.Errorf("钉钉未返回邮箱")
}
sysUser, err := e.findUserByEmail(email)
if err != nil {
return nil, err
}
token, _, err := utils.LoginToken(sysUser)
if err != nil {
return nil, fmt.Errorf("签发 token 失败")
}
return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
}
func getStringFromMap(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if k == "" {
continue
}
if v, ok := m[k]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
}
}
return ""
}
// getStringByJinjaPath 按 Jinja 风格路径从 map 提取字符串,支持点分路径如 "name"、"user.email"、"data.attributes.phone"
// path 可带 {{ }},会先去除再解析。
func getStringByJinjaPath(m map[string]interface{}, path string) string {
path = strings.TrimSpace(path)
path = strings.TrimPrefix(path, "{{")
path = strings.TrimSuffix(path, "}}")
path = strings.TrimSpace(path)
if path == "" {
return ""
}
keys := strings.Split(path, ".")
var current interface{} = m
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
switch v := current.(type) {
case map[string]interface{}:
current = v[key]
case map[string]string:
current = v[key]
default:
return ""
}
if current == nil {
return ""
}
}
switch v := current.(type) {
case string:
return v
case float64:
return fmt.Sprintf("%v", v)
case int:
return fmt.Sprintf("%d", v)
case int64:
return fmt.Sprintf("%d", v)
case bool:
if v {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", current)
}
}
// getStringByPathOrKeys 优先用 Jinja 路径从 m 取值,若为空则按 keys 顺序从顶层取。
func getStringByPathOrKeys(m map[string]interface{}, path string, fallbackKeys ...string) string {
if path != "" {
if s := getStringByJinjaPath(m, path); s != "" {
return s
}
}
return getStringFromMap(m, fallbackKeys...)
}
// findUserByEmail 按邮箱查找已存在的用户(需在 gaia.accounts 中有对应记录方可签发 JWT)
func (e *SystemIntegratedService) findUserByEmail(email string) (*system.SysUser, error) {
var u system.SysUser
var mailList []string
mailList = append(mailList, email)
parts := strings.Split(email, "@")
defaultMail := os.Getenv(gaia.EmailDomainEnv)
if len(defaultMail) > 0 && len(parts) == 2 {
mailList = append(mailList, parts[0]+"@"+defaultMail)
}
// 查询关联邮箱
if err := global.GVA_DB.Where("email IN (?)", mailList).Preload(
"Authorities").Preload("Authority").First(&u).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("邮箱%s尚未开通账号,请联系管理员", email)
}
return nil, err
}
if u.Enable != 1 {
return nil, fmt.Errorf("账号已被禁用")
}
// 默认路由由调用方(api/system)设置,避免 gaia -> system 循环依赖
return &u, nil
}
// findUserByEmailOrPhone 按邮箱或用户唯一标识(如手机号)查找用户,优先邮箱
func (e *SystemIntegratedService) findUserByEmailOrPhone(email, userID string) (*system.SysUser, error) {
if email != "" {
u, err := e.findUserByEmail(email)
if err == nil {
return u, nil
}
// 仅当“未开通”时再尝试按 userID(phone) 查,其他错误直接返回
if err != nil && !strings.Contains(err.Error(), "尚未开通") {
return nil, err
}
}
if userID != "" {
var u system.SysUser
if err := global.GVA_DB.Where("phone = ?", userID).Preload("Authorities").Preload("Authority").First(&u).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("该用户唯一标识尚未开通后台账号,请联系管理员")
}
return nil, err
}
if u.Enable != 1 {
return nil, fmt.Errorf("账号已被禁用")
}
return &u, nil
}
return nil, fmt.Errorf("无法从 OAuth2 用户信息中获取邮箱或用户唯一标识")
}