Files
2026-09-20 00:19:08 +08:00

414 lines
12 KiB
Go
Raw Permalink 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 idp 第三方身份源(微信/钉钉/飞书/QQ/GitHub/Google)统一适配层。
//
// 各平台 OAuth2 流程基本一致,差异只在端点地址、参数名与用户字段,
// 因此用一份通用实现 + 预设配置覆盖,新增平台只需加一条 preset。
package idp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"server/models"
)
// UserInfo 第三方返回的标准化用户信息
type UserInfo struct {
OpenID string // 该平台内的唯一 ID(必填)
UnionID string // 跨平台唯一 ID(微信/钉钉有,GitHub/Google 用 OpenID 代替)
Nickname string
Avatar string
Raw string // 原始 JSON,便于排查
}
// Config 一个第三方身份源的配置
type Config struct {
Provider string
AuthURL string
TokenURL string
UserinfoURL string
Scopes []string
AppID string
AppSecret string
ProxyURL string // GitHub/Google 在国内服务器需要代理时填写
// 参数风格:微信/QQ 用 appid+secret,其余用 client_id+client_secret
UseAppIDStyle bool
// 用户信息接口风格:query=拼在 URL 上(微信/QQ),bearer=放 Authorization 头
UserinfoStyle string
// 用户信息字段映射(一级 JSON 字段)
OpenIDField string
UnionIDField string
NicknameField string
AvatarField string
}
// presets 各平台预设(端点与字段映射)
var presets = map[string]Config{
models.IdPWechat: {
Provider: models.IdPWechat,
AuthURL: "https://open.weixin.qq.com/connect/oauth2/authorize",
TokenURL: "https://api.weixin.qq.com/sns/oauth2/access_token",
UserinfoURL: "https://api.weixin.qq.com/sns/userinfo",
Scopes: []string{"snsapi_userinfo"},
UseAppIDStyle: true,
UserinfoStyle: "query",
OpenIDField: "openid",
UnionIDField: "unionid",
NicknameField: "nickname",
AvatarField: "headimgurl",
},
models.IdPQQ: {
Provider: models.IdPQQ,
AuthURL: "https://graph.qq.com/oauth2.0/authorize",
TokenURL: "https://graph.qq.com/oauth2.0/token",
UserinfoURL: "https://graph.qq.com/user/get_user_info",
Scopes: []string{"get_user_info"},
UseAppIDStyle: true,
UserinfoStyle: "query",
OpenIDField: "openid",
NicknameField: "nickname",
AvatarField: "figureurl_qq_2",
},
models.IdPGitHub: {
Provider: models.IdPGitHub,
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserinfoURL: "https://api.github.com/user",
Scopes: []string{"read:user"},
UserinfoStyle: "bearer",
OpenIDField: "id",
NicknameField: "login",
AvatarField: "avatar_url",
},
models.IdPGoogle: {
Provider: models.IdPGoogle,
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
UserinfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
Scopes: []string{"openid", "profile"},
UserinfoStyle: "bearer",
OpenIDField: "sub",
NicknameField: "name",
AvatarField: "picture",
},
models.IdPDingTalk: {
Provider: models.IdPDingTalk,
AuthURL: "https://login.dingtalk.com/oauth2/auth",
TokenURL: "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
UserinfoURL: "https://api.dingtalk.com/v1.0/contact/users/me",
Scopes: []string{"openid", "profile"},
UserinfoStyle: "bearer",
OpenIDField: "unionId",
UnionIDField: "unionId",
NicknameField: "nick",
AvatarField: "avatarUrl",
},
models.IdPFeishu: {
Provider: models.IdPFeishu,
AuthURL: "https://open.feishu.cn/open-apis/authen/v1/authorize",
TokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
UserinfoURL: "https://open.feishu.cn/open-apis/authen/v1/user_info",
Scopes: []string{"contact:user.base:readonly"},
UserinfoStyle: "bearer",
OpenIDField: "open_id",
UnionIDField: "union_id",
NicknameField: "name",
AvatarField: "avatar_url",
},
}
// Supported 返回支持的第三方平台列表
func Supported() []string {
return []string{
models.IdPWechat, models.IdPDingTalk, models.IdPFeishu,
models.IdPQQ, models.IdPGitHub, models.IdPGoogle,
}
}
// IsSupported 是否为已知平台
func IsSupported(provider string) bool {
_, ok := presets[provider]
return ok
}
// LoadConfig 读取身份源配置:
// - tid > 0 时优先取租户自带身份源(yz_auth_tenant_idp)
// - 取不到或 tid=0 时取平台全局配置(tid=0 的记录)
//
// 未配置任何记录时返回 false,调用方应提示「该登录方式未开通」。
func LoadConfig(provider string, tid uint64) (Config, bool) {
base, ok := presets[provider]
if !ok {
return Config{}, false
}
var row models.AuthTenantIdp
qs := models.Orm.QueryTable(new(models.AuthTenantIdp)).
Filter("provider", provider).
Filter("status", 1)
if tid > 0 {
if err := qs.Filter("tid", tid).One(&row); err == nil {
return applyDBConfig(base, &row), true
}
}
// 回落到平台全局配置(tid=0)
if err := qs.Filter("tid", 0).One(&row); err == nil {
return applyDBConfig(base, &row), true
}
return base, false
}
func applyDBConfig(base Config, row *models.AuthTenantIdp) Config {
if row.AppID != nil && strings.TrimSpace(*row.AppID) != "" {
base.AppID = strings.TrimSpace(*row.AppID)
}
if row.AppSecret != nil && strings.TrimSpace(*row.AppSecret) != "" {
base.AppSecret = strings.TrimSpace(*row.AppSecret)
}
if row.ProxyURL != nil && strings.TrimSpace(*row.ProxyURL) != "" {
base.ProxyURL = strings.TrimSpace(*row.ProxyURL)
}
if row.AuthURL != nil && strings.TrimSpace(*row.AuthURL) != "" {
base.AuthURL = strings.TrimSpace(*row.AuthURL)
}
if row.TokenURL != nil && strings.TrimSpace(*row.TokenURL) != "" {
base.TokenURL = strings.TrimSpace(*row.TokenURL)
}
if row.UserinfoURL != nil && strings.TrimSpace(*row.UserinfoURL) != "" {
base.UserinfoURL = strings.TrimSpace(*row.UserinfoURL)
}
if row.Scopes != nil && strings.TrimSpace(*row.Scopes) != "" {
base.Scopes = splitList(*row.Scopes)
}
return base
}
func splitList(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
out = append(out, v)
}
}
return out
}
// BuildAuthURL 生成跳转到第三方授权页的地址
func BuildAuthURL(cfg Config, redirectURI, state string) string {
v := url.Values{}
if cfg.UseAppIDStyle {
v.Set("appid", cfg.AppID)
} else {
v.Set("client_id", cfg.AppID)
}
v.Set("redirect_uri", redirectURI)
v.Set("response_type", "code")
v.Set("scope", strings.Join(cfg.Scopes, ","))
v.Set("state", state)
addr := cfg.AuthURL + "?" + v.Encode()
if cfg.Provider == models.IdPWechat {
// 微信要求在 hash 后带 #wechat_redirect
return addr + "#wechat_redirect"
}
return addr
}
// Exchange 用授权码换取用户信息
func Exchange(cfg Config, code string) (*UserInfo, error) {
if cfg.AppID == "" || cfg.AppSecret == "" {
return nil, errors.New("该登录方式尚未配置,请联系管理员")
}
// 1. code 换 access_token
v := url.Values{}
if cfg.UseAppIDStyle {
v.Set("appid", cfg.AppID)
v.Set("secret", cfg.AppSecret)
} else {
v.Set("client_id", cfg.AppID)
v.Set("client_secret", cfg.AppSecret)
}
v.Set("code", code)
v.Set("grant_type", "authorization_code")
tokenBody, err := post(cfg, cfg.TokenURL, v, nil)
if err != nil {
return nil, err
}
tokenMap := parseMap(tokenBody)
accessToken := firstStr(tokenMap, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("换取令牌失败: %s", truncate(tokenBody, 200))
}
openID := firstStr(tokenMap, "openid", "unionId", "open_id", "sub", "id")
// 2. 取用户信息
var infoMap map[string]interface{}
if cfg.UserinfoStyle == "query" {
q := url.Values{}
q.Set("access_token", accessToken)
if openID != "" {
q.Set("openid", openID)
}
// QQ 需要额外带 oauth_consumer_key
if cfg.Provider == models.IdPQQ {
q.Set("oauth_consumer_key", cfg.AppID)
}
body, err := get(cfg, cfg.UserinfoURL+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
infoMap = parseMap(body)
} else {
body, err := get(cfg, cfg.UserinfoURL, map[string]string{
"Authorization": "Bearer " + accessToken,
})
if err != nil {
return nil, err
}
infoMap = parseMap(body)
// 飞书把数据包在 data 里
if d, ok := infoMap["data"].(map[string]interface{}); ok {
infoMap = d
}
}
info := &UserInfo{Raw: truncate(mustJSON(infoMap), 2000)}
info.OpenID = strField(infoMap, cfg.OpenIDField)
if cfg.UnionIDField != "" {
info.UnionID = strField(infoMap, cfg.UnionIDField)
}
info.Nickname = strField(infoMap, cfg.NicknameField)
info.Avatar = strField(infoMap, cfg.AvatarField)
if info.OpenID == "" && openID != "" {
info.OpenID = openID
}
if info.OpenID == "" {
return nil, errors.New("未能获取第三方账号标识")
}
if info.UnionID == "" {
info.UnionID = info.OpenID
}
return info, nil
}
// ---------------------------------------------------------------- HTTP
func client(cfg Config) *http.Client {
c := &http.Client{Timeout: 15 * time.Second}
if cfg.ProxyURL != "" {
if u, err := url.Parse(cfg.ProxyURL); err == nil {
c.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
}
}
return c
}
func post(cfg Config, target string, form url.Values, headers map[string]string) (string, error) {
req, err := http.NewRequest("POST", target, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cfg.Provider == models.IdPGitHub {
req.Header.Set("Accept", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client(cfg).Do(req)
if err != nil {
return "", fmt.Errorf("请求第三方失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
}
return string(body), nil
}
func get(cfg Config, target string, headers map[string]string) (string, error) {
req, err := http.NewRequest("GET", target, nil)
if err != nil {
return "", err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client(cfg).Do(req)
if err != nil {
return "", fmt.Errorf("请求第三方失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
}
return string(body), nil
}
// ---------------------------------------------------------------- 解析工具
// parseMap 兼容 JSON 与 querystring 两种响应(微信/QQ 早期接口返回 form 格式)
func parseMap(body string) map[string]interface{} {
out := map[string]interface{}{}
if err := json.Unmarshal([]byte(body), &out); err == nil {
return out
}
if values, err := url.ParseQuery(body); err == nil {
for k, v := range values {
if len(v) > 0 {
out[k] = v[0]
}
}
}
return out
}
func firstStr(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
switch t := v.(type) {
case string:
if t != "" {
return t
}
case float64:
return fmt.Sprintf("%.0f", t)
}
}
}
return ""
}
func strField(m map[string]interface{}, field string) string {
if field == "" {
return ""
}
return firstStr(m, field)
}
func mustJSON(m map[string]interface{}) string {
b, _ := json.Marshal(m)
return string(b)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}