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
+190
View File
@@ -0,0 +1,190 @@
package identity
import (
"encoding/json"
"net/http"
"strings"
"aigateway.local/core/internal/platform/apiresponse"
)
// registerSocialAdmin 注册扫码登录身份源的管理端点。
func (h *ManagementHTTPHandler) registerSocialAdmin() {
h.mux.HandleFunc("GET /api/v1/admin/social-providers", h.listSocialProviders)
h.mux.HandleFunc("POST /api/v1/admin/social-providers", h.createSocialProvider)
h.mux.HandleFunc("PUT /api/v1/admin/social-providers/{kind}", h.updateSocialProvider)
h.mux.HandleFunc("DELETE /api/v1/admin/social-providers/{kind}", h.deleteSocialProvider)
}
type socialProviderInput struct {
Code string `json:"code"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
AgentID string `json:"agent_id"`
Secret *string `json:"secret"`
RedirectURI string `json:"redirect_uri"`
PortalReturnURL string `json:"portal_return_url"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id"`
Enabled bool `json:"enabled"`
}
func (h *ManagementHTTPHandler) decodeSocialProvider(w http.ResponseWriter, r *http.Request, creating bool) (socialProviderInput, SocialProvider, bool) {
var input socialProviderInput
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil {
apiresponse.Error(w, 400, "请求格式无效")
return input, SocialProvider{}, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.ClientID = strings.TrimSpace(input.ClientID)
input.AgentID = strings.TrimSpace(input.AgentID)
if input.Code == "" || input.DisplayName == "" || input.ClientID == "" ||
(creating && input.Secret == nil) || (input.Secret != nil && strings.TrimSpace(*input.Secret) == "") {
apiresponse.Error(w, 400, "身份源代码、名称、AppID 或 AppSecret 无效")
return input, SocialProvider{}, false
}
redirectURI, err := validateAbsoluteURL(input.RedirectURI)
if err != nil || strings.Contains(redirectURI, "#") {
apiresponse.Error(w, 400, "回调 URL 无效")
return input, SocialProvider{}, false
}
returnURL, err := validateAbsoluteURL(input.PortalReturnURL)
if err != nil {
apiresponse.Error(w, 400, "门户返回 URL 无效")
return input, SocialProvider{}, false
}
if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" {
department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID)
if err != nil || !department.Active {
apiresponse.Error(w, 400, "默认部门不存在或已停用")
return input, SocialProvider{}, false
}
}
return input, SocialProvider{Code: input.Code, DisplayName: input.DisplayName, ClientID: input.ClientID, AgentID: input.AgentID,
RedirectURI: redirectURI, PortalReturnURL: returnURL, AutoProvision: input.AutoProvision,
DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled}, true
}
func (h *ManagementHTTPHandler) setSocialCredentials(record *SocialProvider, secret string) error {
payload, _ := json.Marshal(socialCredentials{Secret: secret})
encrypted, version, err := h.service.idpCipher.Encrypt(payload)
if err != nil {
return err
}
record.EncryptedCredentials, record.CredentialKEKVersion = encrypted, version
return nil
}
func (h *ManagementHTTPHandler) socialProviderView(record SocialProvider) map[string]any {
configured := false
if plaintext, err := h.service.idpCipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion); err == nil {
var credentials socialCredentials
configured = json.Unmarshal(plaintext, &credentials) == nil && credentials.Secret != ""
}
return map[string]any{"id": record.ID, "code": record.Code, "kind": record.Kind, "display_name": record.DisplayName,
"client_id": record.ClientID, "agent_id": record.AgentID, "secret_configured": configured,
"redirect_uri": record.RedirectURI, "portal_return_url": record.PortalReturnURL,
"auto_provision": record.AutoProvision, "default_department_id": record.DefaultDepartmentID,
"enabled": record.Enabled, "revision": record.Revision, "created_at": record.CreatedAt, "updated_at": record.UpdatedAt}
}
func (h *ManagementHTTPHandler) listSocialProviders(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r); !ok {
return
}
records, err := h.service.repository.ListSocialProviders(r.Context())
if err != nil {
h.writeError(w, err)
return
}
items := make([]map[string]any, 0, len(records))
for _, record := range records {
items = append(items, h.socialProviderView(record))
}
apiresponse.OK(w, items)
}
func (h *ManagementHTTPHandler) createSocialProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind")))
if !socialKindSupported(kind) {
apiresponse.Error(w, 400, "扫码登录平台必须是 wecom/dingtalk/feishu")
return
}
input, record, ok := h.decodeSocialProvider(w, r, true)
if !ok {
return
}
if kind == "wecom" && input.AgentID == "" {
apiresponse.Error(w, 400, "企业微信身份源需要 AgentID")
return
}
if err := h.setSocialCredentials(&record, strings.TrimSpace(*input.Secret)); err != nil {
h.writeError(w, err)
return
}
record.Kind = kind
created, err := h.service.repository.SaveSocialProvider(r.Context(), record, actor.ID, true, true)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, h.socialProviderView(created))
}
func (h *ManagementHTTPHandler) updateSocialProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
kind := strings.ToLower(strings.TrimSpace(r.PathValue("kind")))
if !socialKindSupported(kind) {
apiresponse.Error(w, 400, "扫码登录平台必须是 wecom/dingtalk/feishu")
return
}
existing, err := h.service.repository.GetSocialProviderByKind(r.Context(), kind)
if err != nil {
h.writeError(w, err)
return
}
input, record, ok := h.decodeSocialProvider(w, r, false)
if !ok {
return
}
if kind == "wecom" && input.AgentID == "" {
apiresponse.Error(w, 400, "企业微信身份源需要 AgentID")
return
}
record.ID = existing.ID
record.Kind = kind
replace := input.Secret != nil
if replace {
if err := h.setSocialCredentials(&record, strings.TrimSpace(*input.Secret)); err != nil {
h.writeError(w, err)
return
}
}
updated, err := h.service.repository.SaveSocialProvider(r.Context(), record, actor.ID, false, replace)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, h.socialProviderView(updated))
}
func (h *ManagementHTTPHandler) deleteSocialProvider(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r); !ok {
return
}
if err := h.service.repository.DeleteSocialProvider(r.Context(), r.PathValue("kind")); err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"deleted": true})
}