e31cc54b8e
- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时 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 构建通过,端到端验证完成。
386 lines
14 KiB
Go
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)
|
|
}
|