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 构建通过,端到端验证完成。
This commit is contained in:
LLMGuardX Dev
2026-08-13 12:53:38 +08:00
parent 4563979a15
commit e31cc54b8e
36 changed files with 2823 additions and 60 deletions
+220
View File
@@ -0,0 +1,220 @@
package identity
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
)
// SocialProvider 是内置扫码登录身份源(企微/钉钉/飞书)。
// 复用 identity_providers 表:client_id 存平台 AppID(企微为 corp_id),
// encrypted_credentials 加密存放 AppSecret,agent_id 等平台特有参数放 config jsonb。
type SocialProvider struct {
ID string `json:"id"`
Code string `json:"code"`
Kind string `json:"kind"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
AgentID string `json:"agent_id,omitempty"`
EncryptedCredentials []byte `json:"-"`
CredentialKEKVersion int `json:"-"`
RedirectURI string `json:"redirect_uri"`
PortalReturnURL string `json:"portal_return_url"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id,omitempty"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type socialCredentials struct {
Secret string `json:"secret"`
}
// ProviderBinding 是门户账号与企微/钉钉/飞书账号的绑定关系。
type ProviderBinding struct {
Kind string `json:"kind"`
ProviderUID string `json:"provider_uid"`
CreatedAt time.Time `json:"created_at"`
}
const socialKinds = "('wecom','dingtalk','feishu')"
func scanSocialProvider(row pgx.Row) (SocialProvider, error) {
var p SocialProvider
var config []byte
var defaultDepartment *string
err := row.Scan(&p.ID, &p.Code, &p.DisplayName, &p.Kind, &p.ClientID, &config, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.AutoProvision, &defaultDepartment, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrNotFound
}
if err != nil {
return p, mapRepositoryError(err)
}
p.DefaultDepartmentID = defaultDepartment
var values map[string]string
if json.Unmarshal(config, &values) == nil {
p.AgentID = values["agent_id"]
}
return p, nil
}
// ListSocialProviders 返回全部扫码登录身份源。
func (r *Repository) ListSocialProviders(ctx context.Context) ([]SocialProvider, error) {
rows, err := r.pool.Query(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind IN `+socialKinds+` ORDER BY kind,code`)
if err != nil {
return nil, mapRepositoryError(err)
}
defer rows.Close()
items := []SocialProvider{}
for rows.Next() {
p, err := scanSocialProvider(rows)
if err != nil {
return nil, err
}
items = append(items, p)
}
return items, mapRepositoryError(rows.Err())
}
// GetSocialProviderByKind 按 kind 返回扫码登录身份源。
func (r *Repository) GetSocialProviderByKind(ctx context.Context, kind string) (SocialProvider, error) {
return scanSocialProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind=$1`, strings.ToLower(strings.TrimSpace(kind))))
}
// GetSocialProviderByCode 按 SSO 代码返回扫码登录身份源(start/callback 分发用)。
func (r *Repository) GetSocialProviderByCode(ctx context.Context, code string) (SocialProvider, error) {
return scanSocialProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE code=$1 AND kind IN `+socialKinds, strings.ToLower(strings.TrimSpace(code))))
}
// SaveSocialProvider 创建/更新扫码登录身份源;replaceSecret=false 时保留原 Secret。
func (r *Repository) SaveSocialProvider(ctx context.Context, p SocialProvider, actorID string, creating, replaceSecret bool) (SocialProvider, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return p, ErrUnavailable
}
defer func() { _ = tx.Rollback(ctx) }()
if creating {
id, err := platformid.NewUUID()
if err != nil {
return p, err
}
p.ID = id
err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id,enabled,config) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING revision,created_at,updated_at`,
p.ID, p.Code, p.Kind, p.DisplayName, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, agentConfigJSON(p.AgentID)).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,client_id=$4,encrypted_credentials=CASE WHEN $13 THEN $5 ELSE encrypted_credentials END,credential_kek_version=CASE WHEN $13 THEN $6 ELSE credential_kek_version END,redirect_uri=$7,portal_return_url=$8,auto_provision=$9,default_department_id=$10,enabled=$11,config=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind IN `+socialKinds+` RETURNING encrypted_credentials,credential_kek_version,revision,created_at,updated_at`,
p.ID, p.Code, p.DisplayName, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, agentConfigJSON(p.AgentID), replaceSecret).Scan(&p.EncryptedCredentials, &p.CredentialKEKVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
}
if err != nil {
return p, mapManagementError(err)
}
eventID, _ := platformid.NewUUID()
eventType := "identity_provider.updated"
if creating {
eventType = "identity_provider.created"
}
payload, _ := json.Marshal(map[string]any{"identity_provider_id": p.ID, "actor_id": actorID})
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, p.ID, payload); err != nil {
return p, ErrUnavailable
}
if tx.Commit(ctx) != nil {
return p, ErrUnavailable
}
return p, nil
}
// DeleteSocialProvider 删除扫码登录身份源及其全部绑定。
func (r *Repository) DeleteSocialProvider(ctx context.Context, kind string) error {
kind = strings.ToLower(strings.TrimSpace(kind))
tx, err := r.pool.Begin(ctx)
if err != nil {
return ErrUnavailable
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
if err = tx.QueryRow(ctx, `DELETE FROM gateway.identity_providers WHERE kind=$1 RETURNING id::text`, kind).Scan(&id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return mapManagementError(err)
}
if _, err = tx.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE provider_kind=$1`, kind); err != nil {
return ErrUnavailable
}
eventID, _ := platformid.NewUUID()
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'identity_provider.deleted',1,'identity_provider',$2,$3)`, eventID, id, `{"identity_provider_id":"`+id+`"}`); err != nil {
return ErrUnavailable
}
return mapManagementError(tx.Commit(ctx))
}
func agentConfigJSON(agentID string) []byte {
if strings.TrimSpace(agentID) == "" {
return []byte(`{}`)
}
raw, _ := json.Marshal(map[string]string{"agent_id": strings.TrimSpace(agentID)})
return raw
}
// FindProviderBinding 按 (kind, uid) 反查门户账号;未绑定返回 ErrNotFound。
func (r *Repository) FindProviderBinding(ctx context.Context, kind, uid string) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.portal_user_provider_bindings WHERE provider_kind=$1 AND provider_uid=$2`, strings.ToLower(strings.TrimSpace(kind)), uid).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return id, mapRepositoryError(err)
}
// BindProvider 建立绑定。kind+uid 冲突(已被他人绑定)返回错误,账号重复绑定同一
// 平台(unique)冲突时先解绑旧绑定再写入,保证一个账号每平台至多一个绑定。
func (r *Repository) BindProvider(ctx context.Context, portalUserID, kind, uid string) error {
kind = strings.ToLower(strings.TrimSpace(kind))
tx, err := r.pool.Begin(ctx)
if err != nil {
return ErrUnavailable
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err = tx.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 AND provider_kind=$2`, portalUserID, kind); err != nil {
return ErrUnavailable
}
tag, err := tx.Exec(ctx, `INSERT INTO gateway.portal_user_provider_bindings(portal_user_id,provider_kind,provider_uid) VALUES($1,$2,$3) ON CONFLICT(provider_kind,provider_uid) DO NOTHING`, portalUserID, kind, uid)
if err != nil {
return ErrUnavailable
}
if tag.RowsAffected() == 0 {
return errors.New("该平台账号已被其他本系统账号绑定")
}
return mapManagementError(tx.Commit(ctx))
}
// UnbindProvider 解除绑定(仅本人)。
func (r *Repository) UnbindProvider(ctx context.Context, portalUserID, kind string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 AND provider_kind=$2`, portalUserID, strings.ToLower(strings.TrimSpace(kind)))
return mapRepositoryError(err)
}
// ListProviderBindings 返回账号的全部扫码绑定。
func (r *Repository) ListProviderBindings(ctx context.Context, portalUserID string) ([]ProviderBinding, error) {
rows, err := r.pool.Query(ctx, `SELECT provider_kind,provider_uid,created_at FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 ORDER BY provider_kind`, portalUserID)
if err != nil {
return nil, mapRepositoryError(err)
}
defer rows.Close()
items := []ProviderBinding{}
for rows.Next() {
var item ProviderBinding
if err := rows.Scan(&item.Kind, &item.ProviderUID, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, mapRepositoryError(rows.Err())
}