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) }