Files
ai-gateway-go/internal/identity/social.go
T
LLMGuardX Dev e31cc54b8e 0.11.2: 旗舰版第三轮完善(通用聊天/企微钉钉飞书扫码登录/个人安全策略)
- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时
  API Key(加密落库,限额取批准值),聊天经受管网关统一认证/限流/配额/审计;
  会话哈希链完整性 + busy 租约防并发,失败不落库。
- 扫码登录:identity_providers 扩展 wecom/dingtalk/feishu,管理端配置
  (AppID/AppSecret/AgentID/回调/自动开户/默认部门),登录页自动展示;
  one-time state 防 CSRF,provider_uid 全局唯一防多账号绑定,平台端点
  固定公网 URL 复用 public-only 拨号。
- 个人安全策略:账号安全页(登录设备管理/吊销非当前会话/登录提醒开关/
  扫码绑定解绑),登录成功发布 security.login_detected 事件按偏好落站内信
  (新增 security 类别),会话索引只存令牌摘要并惰性清理。
- 迁移 000038-000041;修复 social update 参数越界/凭据回读/路由挂载缺失;
  全量测试 25 包通过,前端 admin/portal 构建通过,端到端验证完成。
2026-08-13 12:53:38 +08:00

386 lines
14 KiB
Go

package identity
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const socialStateTTL = 5 * time.Minute
// ErrSocialUnbound 表示平台账号未绑定本系统账号(且未开启自动开通)。
var ErrSocialUnbound = errors.New("该企业账号尚未绑定本系统账号,请先用账号密码登录后在「账号安全」中绑定")
type socialIdentity struct {
UID string // 平台稳定唯一标识(userid/unionId/union_id)
Name string
}
type socialChallenge struct {
ProviderID string `json:"provider_id"`
Purpose string `json:"purpose"` // login | bind
BindUserID string `json:"bind_user_id,omitempty"`
}
type SocialLoginResult struct {
Purpose string // login | bind
SSOCode string // login 成功后的一次性交换码(前端换取会话)
BindOK bool // bind 流程是否成功
BindConflict bool // bind 流程:该平台账号已被他人绑定
}
// socialKindSupported 校验扫码登录平台 kind。
func socialKindSupported(kind string) bool {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "wecom", "dingtalk", "feishu":
return true
}
return false
}
// SocialLoginURL 构造跳转企业身份源的登录/绑定 URL。
func (s *Service) SocialLoginURL(ctx context.Context, kind, purpose, bindUserID string) (string, error) {
if !socialKindSupported(kind) {
return "", errors.New("不支持的扫码登录平台")
}
provider, err := s.repository.GetSocialProviderByKind(ctx, kind)
if err != nil {
return "", err
}
if !provider.Enabled {
return "", errors.New("该登录方式未启用")
}
if provider.ClientID == "" || provider.RedirectURI == "" || provider.PortalReturnURL == "" {
return "", errors.New("身份源配置不完整")
}
if provider.Kind == "wecom" && provider.AgentID == "" {
return "", errors.New("企业微信身份源缺少 AgentID")
}
secret, err := s.socialSecret(provider)
if err != nil || secret == "" {
return "", errors.New("身份源密钥未配置")
}
state, err := s.sessions.StoreOneTime(ctx, "social-state", socialChallenge{ProviderID: provider.ID, Purpose: purpose, BindUserID: bindUserID}, socialStateTTL)
if err != nil {
return "", err
}
redirect, _ := url.Parse(provider.RedirectURI)
query := redirect.Query()
query.Set("state", state)
redirect.RawQuery = query.Encode()
switch provider.Kind {
case "wecom":
target, _ := url.Parse("https://open.work.weixin.qq.com/wwopen/sso/qrConnect")
q := target.Query()
q.Set("appid", provider.ClientID)
q.Set("agentid", provider.AgentID)
q.Set("redirect_uri", redirect.String())
q.Set("state", state)
target.RawQuery = q.Encode()
return target.String(), nil
case "dingtalk":
target, _ := url.Parse("https://login.dingtalk.com/oauth2/auth")
q := target.Query()
q.Set("redirect_uri", redirect.String())
q.Set("response_type", "code")
q.Set("client_id", provider.ClientID)
q.Set("scope", "openid")
q.Set("state", state)
q.Set("prompt", "consent")
target.RawQuery = q.Encode()
return target.String(), nil
case "feishu":
target, _ := url.Parse("https://open.feishu.cn/open-apis/authen/v1/authorize")
q := target.Query()
q.Set("app_id", provider.ClientID)
q.Set("redirect_uri", redirect.String())
q.Set("state", state)
target.RawQuery = q.Encode()
return target.String(), nil
}
return "", errors.New("不支持的扫码登录平台")
}
// CompleteSocialLogin 处理平台回调:校验 state、换取平台身份、按目的登录或绑定。
// meta 携带回调请求的登录环境(IP/UA)。
func (s *Service) CompleteSocialLogin(ctx context.Context, kind, state, code string, meta SessionMeta) (SocialLoginResult, error) {
kind = strings.ToLower(strings.TrimSpace(kind))
if !socialKindSupported(kind) || strings.TrimSpace(state) == "" || strings.TrimSpace(code) == "" {
return SocialLoginResult{}, errors.New("扫码登录回调参数无效")
}
var challenge socialChallenge
if err := s.sessions.ConsumeOneTime(ctx, "social-state", state, &challenge); err != nil {
return SocialLoginResult{}, errors.New("登录状态无效或已过期,请重新扫码")
}
provider, err := s.repository.GetSocialProviderByKind(ctx, kind)
if err != nil || provider.ID != challenge.ProviderID || !provider.Enabled {
return SocialLoginResult{}, errors.New("登录身份源无效或已停用")
}
identity, err := s.exchangeSocial(ctx, provider, code)
if err != nil {
return SocialLoginResult{}, err
}
if challenge.Purpose == "bind" {
return s.completeSocialBind(ctx, provider, challenge.BindUserID, identity)
}
if challenge.Purpose != "login" {
return SocialLoginResult{}, errors.New("登录状态无效")
}
accountID, err := s.repository.FindProviderBinding(ctx, provider.Kind, identity.UID)
if errors.Is(err, ErrNotFound) {
if !provider.AutoProvision {
return SocialLoginResult{}, ErrSocialUnbound
}
account, provisionErr := s.repository.resolveExternalAccount(ctx, externalProvider{
ID: provider.ID, Code: provider.Code, AuthSource: provider.Kind, AutoProvision: true, DefaultDepartmentID: provider.DefaultDepartmentID,
}, externalClaims{Subject: identity.UID, Name: identity.Name})
if provisionErr != nil {
return SocialLoginResult{}, provisionErr
}
accountID = account.ID
} else if err != nil {
return SocialLoginResult{}, err
}
account, err := s.findByID(ctx, KindPortal, accountID)
if err != nil {
return SocialLoginResult{}, err
}
if !account.Active {
return SocialLoginResult{}, ErrAccountDisabled
}
token, err := s.sessions.CreateWithMeta(ctx, principalFor(account), meta.IP, meta.UserAgent)
if err != nil {
return SocialLoginResult{}, err
}
s.NotifyLogin(ctx, account.ID, meta)
exchange, err := s.sessions.StoreOneTime(ctx, "oidc-exchange", oidcExchange{Token: token}, time.Minute)
if err != nil {
return SocialLoginResult{}, err
}
return SocialLoginResult{Purpose: "login", SSOCode: exchange}, nil
}
// completeSocialBind 处理绑定回调:同一平台账号只能绑到一个本系统账号。
func (s *Service) completeSocialBind(ctx context.Context, provider SocialProvider, bindUserID string, identity socialIdentity) (SocialLoginResult, error) {
if bindUserID == "" {
return SocialLoginResult{}, errors.New("绑定状态无效")
}
account, err := s.findByID(ctx, KindPortal, bindUserID)
if err != nil || !account.Active {
return SocialLoginResult{}, errors.New("绑定账号不存在或已停用")
}
if existing, err := s.repository.FindProviderBinding(ctx, provider.Kind, identity.UID); err == nil {
if existing == bindUserID {
return SocialLoginResult{Purpose: "bind", BindOK: true}, nil
}
return SocialLoginResult{Purpose: "bind", BindConflict: true}, nil
}
if err := s.repository.BindProvider(ctx, bindUserID, provider.Kind, identity.UID); err != nil {
if strings.Contains(err.Error(), "已被其他") {
return SocialLoginResult{Purpose: "bind", BindConflict: true}, nil
}
return SocialLoginResult{}, err
}
return SocialLoginResult{Purpose: "bind", BindOK: true}, nil
}
// UnbindProvider 解除扫码绑定(仅本人)。
func (s *Service) UnbindProvider(ctx context.Context, portalUserID, kind string) error {
return s.repository.UnbindProvider(ctx, portalUserID, kind)
}
// ProviderBindings 返回账号的扫码绑定列表。
func (s *Service) ProviderBindings(ctx context.Context, portalUserID string) ([]ProviderBinding, error) {
return s.repository.ListProviderBindings(ctx, portalUserID)
}
// socialSecret 解密平台 AppSecret。
func (s *Service) socialSecret(p SocialProvider) (string, error) {
if s.idpCipher == nil || len(p.EncryptedCredentials) == 0 {
return "", ErrUnavailable
}
plaintext, err := s.idpCipher.Decrypt(p.EncryptedCredentials, p.CredentialKEKVersion)
if err != nil {
return "", err
}
var credentials socialCredentials
if err := json.Unmarshal(plaintext, &credentials); err != nil {
return "", err
}
return credentials.Secret, nil
}
// exchangeSocial 用回调 code 换取平台身份。全部为固定公网端点,复用公共地址
// 白名单拨号的 oidcClient,不引入新的出站面。
func (s *Service) exchangeSocial(ctx context.Context, p SocialProvider, code string) (socialIdentity, error) {
secret, err := s.socialSecret(p)
if err != nil || secret == "" {
return socialIdentity{}, errors.New("身份源密钥不可用")
}
switch p.Kind {
case "wecom":
return s.exchangeWeCom(ctx, p, secret, code)
case "dingtalk":
return s.exchangeDingTalk(ctx, p, secret, code)
case "feishu":
return s.exchangeFeishu(ctx, p, secret, code)
}
return socialIdentity{}, errors.New("不支持的扫码登录平台")
}
func (s *Service) exchangeWeCom(ctx context.Context, p SocialProvider, secret, code string) (socialIdentity, error) {
tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", url.QueryEscape(p.ClientID), url.QueryEscape(secret))
var token struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
}
if err := s.socialGetJSON(ctx, tokenURL, &token); err != nil || token.ErrCode != 0 || token.AccessToken == "" {
return socialIdentity{}, errors.New("企业微信 access_token 获取失败")
}
var user struct {
ErrCode int `json:"errcode"`
UserID string `json:"userid"`
OpenID string `json:"openid"`
}
userURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=%s&code=%s", url.QueryEscape(token.AccessToken), url.QueryEscape(code))
if err := s.socialGetJSON(ctx, userURL, &user); err != nil || user.ErrCode != 0 {
return socialIdentity{}, errors.New("企业微信用户信息获取失败")
}
uid := strings.TrimSpace(user.UserID)
if uid == "" {
uid = strings.TrimSpace(user.OpenID)
}
if uid == "" {
return socialIdentity{}, errors.New("企业微信未返回用户标识")
}
return socialIdentity{UID: uid, Name: uid}, nil
}
func (s *Service) exchangeDingTalk(ctx context.Context, p SocialProvider, secret, code string) (socialIdentity, error) {
payload, _ := json.Marshal(map[string]string{"clientId": p.ClientID, "clientSecret": secret, "code": code, "grantType": "authorization_code"})
var token struct {
AccessToken string `json:"accessToken"`
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", bytes.NewReader(payload))
if err != nil {
return socialIdentity{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := s.oidcHTTPClient().Do(request)
if err != nil {
return socialIdentity{}, errors.New("钉钉 access_token 获取失败")
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16))
if response.StatusCode/100 != 2 || json.Unmarshal(raw, &token) != nil || token.AccessToken == "" {
return socialIdentity{}, errors.New("钉钉 access_token 获取失败")
}
userRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.dingtalk.com/v1.0/contact/users/me", nil)
if err != nil {
return socialIdentity{}, err
}
userRequest.Header.Set("x-acs-dingtalk-access-token", token.AccessToken)
userResponse, err := s.oidcHTTPClient().Do(userRequest)
if err != nil {
return socialIdentity{}, errors.New("钉钉用户信息获取失败")
}
defer userResponse.Body.Close()
raw, _ = io.ReadAll(io.LimitReader(userResponse.Body, 1<<16))
var user struct {
UnionID string `json:"unionId"`
OpenID string `json:"openId"`
Nick string `json:"nick"`
}
if userResponse.StatusCode/100 != 2 || json.Unmarshal(raw, &user) != nil {
return socialIdentity{}, errors.New("钉钉用户信息获取失败")
}
uid := strings.TrimSpace(user.UnionID)
if uid == "" {
uid = strings.TrimSpace(user.OpenID)
}
if uid == "" {
return socialIdentity{}, errors.New("钉钉未返回用户标识")
}
return socialIdentity{UID: uid, Name: firstNonEmpty(user.Nick, uid)}, nil
}
func (s *Service) exchangeFeishu(ctx context.Context, p SocialProvider, secret, code string) (socialIdentity, error) {
payload, _ := json.Marshal(map[string]string{"app_id": p.ClientID, "app_secret": secret, "code": code, "grant_type": "authorization_code"})
var token struct {
Code int `json:"code"`
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token", bytes.NewReader(payload))
if err != nil {
return socialIdentity{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := s.oidcHTTPClient().Do(request)
if err != nil {
return socialIdentity{}, errors.New("飞书 access_token 获取失败")
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16))
if response.StatusCode/100 != 2 || json.Unmarshal(raw, &token) != nil || token.Code != 0 || token.Data.AccessToken == "" {
return socialIdentity{}, errors.New("飞书 access_token 获取失败")
}
userRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.feishu.cn/open-apis/authen/v1/user_info", nil)
if err != nil {
return socialIdentity{}, err
}
userRequest.Header.Set("Authorization", "Bearer "+token.Data.AccessToken)
userResponse, err := s.oidcHTTPClient().Do(userRequest)
if err != nil {
return socialIdentity{}, errors.New("飞书用户信息获取失败")
}
defer userResponse.Body.Close()
raw, _ = io.ReadAll(io.LimitReader(userResponse.Body, 1<<16))
var user struct {
Code int `json:"code"`
Data struct {
Name string `json:"name"`
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
} `json:"data"`
}
if userResponse.StatusCode/100 != 2 || json.Unmarshal(raw, &user) != nil || user.Code != 0 {
return socialIdentity{}, errors.New("飞书用户信息获取失败")
}
uid := strings.TrimSpace(user.Data.UnionID)
if uid == "" {
uid = strings.TrimSpace(user.Data.OpenID)
}
if uid == "" {
return socialIdentity{}, errors.New("飞书未返回用户标识")
}
return socialIdentity{UID: uid, Name: firstNonEmpty(user.Data.Name, uid)}, nil
}
// socialGetJSON 执行 GET 并解码 JSON(限长)。
func (s *Service) socialGetJSON(ctx context.Context, endpoint string, target any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
response, err := s.oidcHTTPClient().Do(request)
if err != nil {
return err
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16))
if response.StatusCode/100 != 2 {
return errors.New("platform http error")
}
return json.Unmarshal(raw, target)
}