From e31cc54b8e8aa48d23359b4fb13768ab973e3695 Mon Sep 17 00:00:00 2001 From: LLMGuardX Dev Date: Thu, 13 Aug 2026 12:53:38 +0800 Subject: [PATCH] =?UTF-8?q?0.11.2:=20=E6=97=97=E8=88=B0=E7=89=88=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E8=BD=AE=E5=AE=8C=E5=96=84(=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E8=81=8A=E5=A4=A9/=E4=BC=81=E5=BE=AE=E9=92=89=E9=92=89?= =?UTF-8?q?=E9=A3=9E=E4=B9=A6=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95/?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E5=AE=89=E5=85=A8=E7=AD=96=E7=95=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时 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 构建通过,端到端验证完成。 --- RELEASE.md | 30 ++ cmd/gateway-api/main.go | 4 + docs/security-review-0.10.1.md | 28 ++ docs/旗舰版需求规划与完成情况.md | 17 + internal/channel/service.go | 26 +- internal/gateway/output_policy.go | 6 +- internal/gateway/proxy.go | 30 +- internal/gateway/usage.go | 28 +- internal/identity/http.go | 75 +++- internal/identity/management.go | 1 + internal/identity/oidc.go | 20 +- internal/identity/saml.go | 7 +- internal/identity/security_repo.go | 34 ++ internal/identity/service.go | 55 ++- internal/identity/session.go | 117 ++++++ internal/identity/social.go | 385 ++++++++++++++++++ internal/identity/social_admin.go | 190 +++++++++ internal/identity/social_http.go | 122 ++++++ internal/identity/social_repo.go | 220 ++++++++++ internal/identity/social_test.go | 205 ++++++++++ internal/portal/chat.go | 329 +++++++++++++++ internal/portal/credentials.go | 79 ++++ internal/portal/http.go | 141 +++++++ internal/portal/service.go | 7 +- internal/workbench/inbox.go | 17 + internal/workbench/inbox_test.go | 5 + migrations/000038_portal_chat.sql | 48 +++ migrations/000039_social_login.sql | 30 ++ migrations/000040_security_prefs.sql | 9 + migrations/000041_inbox_security_category.sql | 7 + web/apps/admin/src/api/identities.ts | 47 +++ .../admin/src/views/system/user/index.vue | 141 ++++++- web/apps/portal/src/api/portal.ts | 24 ++ .../portal/src/views/auth/login/index.vue | 11 +- .../portal/src/views/portal/chat/index.vue | 212 ++++++++++ .../src/views/portal/security/index.vue | 176 ++++++++ 36 files changed, 2823 insertions(+), 60 deletions(-) create mode 100644 internal/identity/security_repo.go create mode 100644 internal/identity/social.go create mode 100644 internal/identity/social_admin.go create mode 100644 internal/identity/social_http.go create mode 100644 internal/identity/social_repo.go create mode 100644 internal/identity/social_test.go create mode 100644 internal/portal/chat.go create mode 100644 migrations/000038_portal_chat.sql create mode 100644 migrations/000039_social_login.sql create mode 100644 migrations/000040_security_prefs.sql create mode 100644 migrations/000041_inbox_security_category.sql create mode 100644 web/apps/portal/src/views/portal/chat/index.vue create mode 100644 web/apps/portal/src/views/portal/security/index.vue diff --git a/RELEASE.md b/RELEASE.md index 9a629eb..e1de728 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -18,3 +18,33 @@ Excluded: Start with `deploy/PRODUCTION.md`. Verify the accompanying ZIP checksum before copying the bundle to a deployment host. + +## 0.11.2 — 旗舰版第三轮完善 + +发布时间:2026-08-13 + +新增功能: + +- **门户通用聊天**:门户工作台新增「通用聊天」页面,选择已批准模型直接对话, + 支持会话管理(新建/改名/删除/历史)。模型权限申请审批通过后,系统自动为该 + 用户开通一把 gateway API Key(加密落库 `portal_user_runtime_credentials`, + 限额取批准申请值),聊天调用经受管网关执行,认证/限流/配额/审计与外部 + API Key 完全同权;会话消息沿用哈希链完整性校验与 busy 租约防并发。 +- **企微/钉钉/飞书扫码登录**:`identity_providers` 表扩展 wecom/dingtalk/feishu + kind,管理端「账号与权限 → 扫码登录」配置身份源(AppID/AppSecret/AgentID、 + 回调 URL、自动开户、默认部门)。登录页自动展示已启用身份源按钮,回调经 + one-time state 防 CSRF,支持三种平台 code 换取身份协议;未绑定账号默认 + 需先在「账号安全」扫码绑定(同一平台账号全局唯一绑定),可开启自动开户。 +- **个人安全策略**:门户「账号安全」页面提供登录设备管理(IP/UA/时间,可 + 吊销任意非当前会话)、新设备登录提醒开关(默认开启,经 outbox → 站内信, + 可单独关闭)、扫码登录绑定/解绑。 + +迁移:000038_portal_chat / 000039_social_login / 000040_security_prefs / +000041_inbox_security_category(共 41 个迁移)。 + +安全要点: + +- 扫码登录 state 为 128-bit 一次性令牌(5 分钟),回调原子消费; + provider_uid 全局唯一,防止平台账号同时绑定多个本系统账号。 +- 会话索引只存令牌 SHA-256 摘要,列表时惰性清理过期项;当前会话不可吊销。 +- 登录提醒事件只落站内信,不包含凭据;开关按账号独立生效。 diff --git a/cmd/gateway-api/main.go b/cmd/gateway-api/main.go index 10ba47c..9d0ff7c 100644 --- a/cmd/gateway-api/main.go +++ b/cmd/gateway-api/main.go @@ -358,6 +358,7 @@ func main() { portalService := portal.NewService(db, workbenchService, toolService, identityService) portalService.SetEnvVarService(envVarService) portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime) + portalService.SetGateway(governedGateway) portalService.SetMarketplace(marketplaceService) portalHandler := portal.NewHTTPHandler(portalService, identityService) portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService) @@ -456,6 +457,8 @@ func main() { controlMux.Handle("/api/v1/admin/identity-providers/", identityManagementHandler) controlMux.Handle("/api/v1/admin/saml-providers", identityManagementHandler) controlMux.Handle("/api/v1/admin/saml-providers/", identityManagementHandler) + controlMux.Handle("/api/v1/admin/social-providers", identityManagementHandler) + controlMux.Handle("/api/v1/admin/social-providers/", identityManagementHandler) controlMux.Handle("/api/v1/portal/applications", portalHandler) controlMux.Handle("/api/v1/portal/apps/", portalHandler) controlMux.Handle("/api/v1/portal/catalog", portalHandler) @@ -475,6 +478,7 @@ func main() { controlMux.Handle("/api/v1/portal/prompts/", portalHandler) controlMux.Handle("/api/v1/portal/stats", portalHandler) controlMux.Handle("/api/v1/portal/tools", portalHandler) + controlMux.Handle("/api/v1/portal/chat/", portalHandler) controlMux.Handle("/api/v1/", identityHandler) publicMux := http.NewServeMux() publicMux.Handle("/v1/prompts", workbenchRuntime) diff --git a/docs/security-review-0.10.1.md b/docs/security-review-0.10.1.md index 565815e..cfb9aff 100644 --- a/docs/security-review-0.10.1.md +++ b/docs/security-review-0.10.1.md @@ -426,3 +426,31 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l `POST /v1/channels/{code}/inbound`(企微签名校验/钉钉加签工具), 绑定模型应答后按平台协议回复(企微应用消息/钉钉机器人/飞书应用), 管理端渠道 CRUD + 连通性测试。 + +# 追加:旗舰版功能完善第三轮(0.11.2,2026-08-13) + +1. **门户通用聊天**(迁移 000038 + `internal/portal/chat.go`): + 门户「通用聊天」页选择已批准模型直接对话。模型权限审批通过后按需自动 + 开通用户级运行时 API Key(`portal_user_runtime_credentials` 加密落库, + 限额取批准申请值),聊天请求经 `SetGateway` 注入的受管网关执行,与外部 + Key 同权受审计/配额约束。会话复用哈希链消息完整性与 busy 租约防并发, + 失败不落库(无孤儿/重复消息)。 +2. **企微/钉钉/飞书扫码登录**(迁移 000039 + `internal/identity/social*.go`): + `identity_providers` 扩展三种 kind;管理端「账号与权限 → 扫码登录」配置; + 登录页自动展示;`start/callback` 复用 SSO 路由按 kind 分发。安全设计: + - state 为 128-bit 一次性令牌(Redis one-time,5 分钟),回调原子消费; + - 平台 AppSecret 用 IdP 专用 KEK 加密存储,列表/视图只回 `secret_configured`; + - `provider_uid` 全局唯一 → 平台账号不可同时绑定多个本系统账号; + - 未绑定且未开自动开户时重定向回登录页提示,不静默开户; + - 平台端点全部为固定公网 URL,复用 public-only 拨号客户端(无新增出站面)。 +3. **个人安全策略**(迁移 000040/000041 + 会话索引): + - 「账号安全」页:登录设备列表(IP/UA/时间,索引只存令牌 SHA-256 摘要, + 惰性清理过期项)、吊销任意非当前会话、扫码绑定/解绑; + - 新设备登录提醒:登录成功发布 `security.login_detected` outbox 事件, + notification worker 依据 `portal_security_prefs.login_notify`(默认开) + 落站内信(新增 security 类别),可单独关闭; + - 当前会话吊销返回 400(`ErrRevokeCurrentSession`)。 +4. 修复:`request.patch` → `put`(前端无 patch 方法);社交 provider 更新 + SQL `$14`→`$13` 参数越界;update 返回视图未回读凭据导致 secret_configured + 显示失真;`/api/v1/portal/chat/` 与 `/api/v1/admin/social-providers` 挂载缺失; + start/callback 用 provider code 而非 kind 查询。 diff --git a/docs/旗舰版需求规划与完成情况.md b/docs/旗舰版需求规划与完成情况.md index e17dd86..bccae02 100644 --- a/docs/旗舰版需求规划与完成情况.md +++ b/docs/旗舰版需求规划与完成情况.md @@ -174,3 +174,20 @@ MinIO 对象存储与管理端/个人文件仓库;pgvector + Ollama(bge-m3) - **旧 Python 网关**:`llm-gateway.service`(systemd)监听 8080 已 `systemctl disable`,不再开机抢占。 - **本机无 go 工具链**:编译/测试用 `docker run --rm -v $PWD:/src -w /src -e GOCACHE=/tmp/gocache golang:1.26.5-alpine sh -c 'go build ./...'`;集成测试加 `--network deploy_default` + `WORKBENCH_TEST_DATABASE_URL=postgres://gateway:gateway@postgres:5432/gateway?sslmode=disable`。 - **上线门禁未过**:旧库脱敏快照迁移、影子观察、容量验收、切换/回退演练需真实生产数据与授权后方可执行。 + +--- + +## 八、0.11.2 完成情况(2026-08-13 第三轮完善) + +| 功能 | 状态 | 说明 | +|---|---|---| +| 通用聊天 UI | ✅ | 门户「通用聊天」:选模型直接对话、会话管理、审批通过后自动开通用户级 Key | +| 企微/钉钉/飞书扫码登录 | ✅ | 身份源配置 + 登录页扫码 + 账号安全绑定/解绑 + 自动开户开关 | +| 个人安全策略 | ✅ | 登录设备管理/吊销、新设备登录提醒开关、登录通知站内信(security 类别) | + +剩余依赖外部条件项(已有代码骨架,需真实企业凭据/数据后方可端到端验收): + +- 扫码登录与渠道(企微/钉钉/飞书)真实平台联调:协议实现已单测覆盖(mock 平台端点), + 需企业开放平台应用凭据完成冒烟。 +- 多租户数据隔离重构:当前以部门(tenant_id)为租户维度,跨租户物理隔离(独立 schema/库) + 需明确部署形态后实施。 diff --git a/internal/channel/service.go b/internal/channel/service.go index fbdf73b..04a9f55 100644 --- a/internal/channel/service.go +++ b/internal/channel/service.go @@ -31,19 +31,19 @@ var ( // Channel 是一条渠道配置。 type Channel struct { - ID string `json:"id"` - Code string `json:"code"` - Name string `json:"name"` - Kind string `json:"kind"` - Config json.RawMessage `json:"config,omitempty"` - EncryptedConfig []byte `json:"-"` - ConfigKEKVersion int `json:"-"` - ModelBinding json.RawMessage `json:"model_binding"` - HasAPIKey bool `json:"has_api_key"` - Enabled bool `json:"enabled"` - CreatedBy *string `json:"created_by,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Kind string `json:"kind"` + Config json.RawMessage `json:"config,omitempty"` + EncryptedConfig []byte `json:"-"` + ConfigKEKVersion int `json:"-"` + ModelBinding json.RawMessage `json:"model_binding"` + HasAPIKey bool `json:"has_api_key"` + Enabled bool `json:"enabled"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Config 是渠道的平台配置(明文,仅内部使用)。 diff --git a/internal/gateway/output_policy.go b/internal/gateway/output_policy.go index f76b936..72c82c5 100644 --- a/internal/gateway/output_policy.go +++ b/internal/gateway/output_policy.go @@ -14,10 +14,10 @@ type outputRedactReadCloser struct { engine interface { OutputRedact([]byte) ([]byte, bool) } - sse bool + sse bool buffered []byte // 已处理待输出的字节 - done bool // 非流式已完成缓冲与处理 - pending []byte // 流式:未完成的行 + done bool // 非流式已完成缓冲与处理 + pending []byte // 流式:未完成的行 } func newOutputRedactReadCloser(body io.ReadCloser, contentType string, engine interface { diff --git a/internal/gateway/proxy.go b/internal/gateway/proxy.go index bc56d6b..306658e 100644 --- a/internal/gateway/proxy.go +++ b/internal/gateway/proxy.go @@ -21,20 +21,20 @@ import ( ) type Proxy struct { - resolver AdapterResolver - auth apikey.KeyAuthenticator - maxBody int64 - logger *slog.Logger - transport *http.Transport - proxies sync.Map - circuits sync.Map - admission AdmissionController - tokenQuota TokenQuotaController - modelQuota ModelQuotaController - resilience ResiliencePolicy - audit AuditRecorder - policies *contentpolicy.Engine - pricing *pricing.Service + resolver AdapterResolver + auth apikey.KeyAuthenticator + maxBody int64 + logger *slog.Logger + transport *http.Transport + proxies sync.Map + circuits sync.Map + admission AdmissionController + tokenQuota TokenQuotaController + modelQuota ModelQuotaController + resilience ResiliencePolicy + audit AuditRecorder + policies *contentpolicy.Engine + pricing *pricing.Service outputPolicies interface { OutputRedact([]byte) ([]byte, bool) } @@ -122,7 +122,7 @@ func (p *Proxy) SetOutputPolicyEngine(engine interface { }) { p.outputPolicies = engine } -func (p *Proxy) SetPricingService(service *pricing.Service) { p.pricing = service } +func (p *Proxy) SetPricingService(service *pricing.Service) { p.pricing = service } func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) { started := time.Now() diff --git a/internal/gateway/usage.go b/internal/gateway/usage.go index cea568a..0aedc6f 100644 --- a/internal/gateway/usage.go +++ b/internal/gateway/usage.go @@ -123,19 +123,19 @@ type usageCollector struct { } func (c *usageCollector) feed(chunk []byte) { - if !c.sse { - c.doc = append(c.doc, chunk...) - // Keep only a bounded tail window. The "usage" member lives at the end - // of a non-streaming response, so dropping the head (never the tail) - // preserves accounting for arbitrarily large bodies at a fixed memory - // cost instead of truncating usage away. 仅在超过 2× 窗口时压缩一次, - // 避免每个 32KiB 块都做 O(窗口) 的尾部拷贝(大响应下退化为 O(n²))。 - if len(c.doc) > 2*maxUsageDocumentBytes { - copy(c.doc, c.doc[len(c.doc)-maxUsageDocumentBytes:]) - c.doc = c.doc[:maxUsageDocumentBytes] - } - return + if !c.sse { + c.doc = append(c.doc, chunk...) + // Keep only a bounded tail window. The "usage" member lives at the end + // of a non-streaming response, so dropping the head (never the tail) + // preserves accounting for arbitrarily large bodies at a fixed memory + // cost instead of truncating usage away. 仅在超过 2× 窗口时压缩一次, + // 避免每个 32KiB 块都做 O(窗口) 的尾部拷贝(大响应下退化为 O(n²))。 + if len(c.doc) > 2*maxUsageDocumentBytes { + copy(c.doc, c.doc[len(c.doc)-maxUsageDocumentBytes:]) + c.doc = c.doc[:maxUsageDocumentBytes] } + return + } c.pending = append(c.pending, chunk...) for { index := bytes.IndexByte(c.pending, '\n') @@ -169,8 +169,8 @@ func (c *usageCollector) usage() TokenUsage { c.pending = nil } if !c.sse && len(c.doc) > 0 { - c.consumeJSON(c.doc) // fast path: whole valid JSON object - c.consumeUsageObject(c.doc) // tail extraction: covers truncated bodies + c.consumeJSON(c.doc) // fast path: whole valid JSON object + c.consumeUsageObject(c.doc) // tail extraction: covers truncated bodies } return TokenUsage{Input: c.input, Output: c.output, Total: max(c.total, c.input+c.output)} } diff --git a/internal/identity/http.go b/internal/identity/http.go index f1b9091..eaee7dd 100644 --- a/internal/identity/http.go +++ b/internal/identity/http.go @@ -51,10 +51,15 @@ func NewHTTPHandler(service *Service) *HTTPHandler { handler.mux.HandleFunc("GET /api/v1/admin/menus", handler.menus(KindAdmin)) handler.mux.HandleFunc("POST /api/v1/portal/login", handler.login(KindPortal)) handler.registerTOTP(KindPortal, "/api/v1/portal") + handler.mux.HandleFunc("GET /api/v1/portal/sessions", handler.listSessions) + handler.mux.HandleFunc("POST /api/v1/portal/sessions/{id}/revoke", handler.revokeSession) + handler.mux.HandleFunc("GET /api/v1/portal/security/prefs", handler.securityPrefs) + handler.mux.HandleFunc("PUT /api/v1/portal/security/prefs", handler.setSecurityPrefs) handler.mux.HandleFunc("GET /api/v1/portal/me", handler.whoami(KindPortal)) handler.mux.HandleFunc("POST /api/v1/portal/logout", handler.logout) handler.mux.HandleFunc("GET /api/v1/portal/menus", handler.menus(KindPortal)) handler.registerOIDC() + handler.registerSocial() return handler } @@ -136,7 +141,7 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc { apiresponse.Error(writer, http.StatusBadRequest, "账号或口令格式无效") return } - result, err := h.service.Login(request.Context(), kind, login, input.Password) + result, err := h.service.LoginWithMeta(request.Context(), kind, login, input.Password, SessionMeta{IP: h.service.ClientIP(request), UserAgent: request.UserAgent()}) if err != nil { // 登录失败审计(429 限流在 AllowLogin 阶段已拦截,此处都是真实失败)。 _ = h.service.RecordLoginLog(request.Context(), kind, login, false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err)) @@ -174,6 +179,66 @@ func loginFailureReason(err error) string { } } +// listSessions 我的登录设备列表(当前会话标记 current)。 +func (h *HTTPHandler) listSessions(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + items, err := h.service.ListSessions(r.Context(), KindPortal, account.ID, r.Header.Get("Authorization")) + if err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, items) +} + +// revokeSession 吊销指定登录设备(不允许吊销当前会话)。 +func (h *HTTPHandler) revokeSession(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + if err := h.service.RevokeSession(r.Context(), KindPortal, account.ID, r.PathValue("id"), r.Header.Get("Authorization")); err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"revoked": true}) +} + +// securityPrefs 我的安全偏好。 +func (h *HTTPHandler) securityPrefs(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + loginNotify, err := h.service.SecurityPrefs(r.Context(), account.ID) + if err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"login_notify": loginNotify}) +} + +// setSecurityPrefs 更新安全偏好。 +func (h *HTTPHandler) setSecurityPrefs(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + var input struct { + LoginNotify bool `json:"login_notify"` + } + if !decodeJSON(w, r, &input) { + return + } + if err := h.service.SetSecurityPrefs(r.Context(), account.ID, input.LoginNotify); err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"login_notify": input.LoginNotify}) +} + func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) { h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind)) h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind)) @@ -195,7 +260,7 @@ func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc { apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码或备用码") return } - result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode) + result, err := h.service.CompleteTOTPLoginWithMeta(request.Context(), kind, input.TempToken, input.Code, input.BackupCode, SessionMeta{IP: h.service.ClientIP(request), UserAgent: request.UserAgent()}) if err != nil { _ = h.service.RecordLoginLog(request.Context(), kind, "", false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err)) h.writeIdentityError(writer, err) @@ -374,6 +439,8 @@ func (h *HTTPHandler) writeIdentityError(writer http.ResponseWriter, err error) apiresponse.Error(writer, http.StatusConflict, "两步验证已经启用") case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired): apiresponse.Error(writer, http.StatusConflict, "两步验证尚未完成配置") + case errors.Is(err, ErrRevokeCurrentSession): + apiresponse.Error(writer, http.StatusBadRequest, "不能吊销当前登录的会话") case errors.Is(err, cryptox.ErrKeyUnavailable): apiresponse.Error(writer, http.StatusServiceUnavailable, "两步验证加密密钥不可用") case errors.Is(err, ErrUnavailable): @@ -506,7 +573,8 @@ func adminMenus(account Account) []map[string]any { func portalMenus() []map[string]any { return []map[string]any{ {"name": "Portal", "path": "/portal", "component": "/index/index", "meta": map[string]any{"title": "AI 工作台", "icon": "ri:sparkling-line"}, "children": []map[string]any{ - {"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录", "fixedTab": true}}, + {"name": "PortalChat", "path": "chat", "component": "/portal/chat", "meta": map[string]any{"title": "通用聊天", "fixedTab": true}}, + {"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录"}}, {"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}}, {"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}}, {"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}}, @@ -517,6 +585,7 @@ func portalMenus() []map[string]any { {"name": "PortalMemories", "path": "memories", "component": "/portal/memories", "meta": map[string]any{"title": "记忆管理"}}, {"name": "PortalEnvVars", "path": "env-vars", "component": "/portal/env-vars", "meta": map[string]any{"title": "环境变量"}}, {"name": "PortalLoginLogs", "path": "login-logs", "component": "/portal/login-logs", "meta": map[string]any{"title": "登录记录"}}, + {"name": "PortalSecurity", "path": "security", "component": "/portal/security", "meta": map[string]any{"title": "账号安全"}}, }}, } } diff --git a/internal/identity/management.go b/internal/identity/management.go index 5c93d42..f1166ed 100644 --- a/internal/identity/management.go +++ b/internal/identity/management.go @@ -83,6 +83,7 @@ func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler { h.mux.HandleFunc("POST /api/v1/admin/departments", h.createDepartment) h.mux.HandleFunc("PUT /api/v1/admin/departments/{department_id}", h.updateDepartment) h.registerOIDC() + h.registerSocialAdmin() return h } diff --git a/internal/identity/oidc.go b/internal/identity/oidc.go index d1e716a..49454af 100644 --- a/internal/identity/oidc.go +++ b/internal/identity/oidc.go @@ -84,12 +84,27 @@ func (h *ManagementHTTPHandler) registerOIDC() { func (h *HTTPHandler) registerOIDC() { h.mux.HandleFunc("GET /api/v1/portal/sso/providers", h.listPublicOIDCProviders) h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/start", h.startSSO) - h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/callback", h.callbackOIDC) + h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/callback", h.callbackSSO) h.mux.HandleFunc("POST /api/v1/portal/sso/{provider_code}/callback", h.callbackSAML) h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/metadata", h.samlMetadata) h.mux.HandleFunc("POST /api/v1/portal/sso/exchange", h.exchangeOIDC) } +// callbackSSO 按身份源 kind 分发 GET 回调:OIDC 走标准 code 交换,扫码登录 +// 走企微/钉钉/飞书协议。 +func (h *HTTPHandler) callbackSSO(w http.ResponseWriter, r *http.Request) { + kind, err := h.service.repository.GetIdentityProviderKind(r.Context(), r.PathValue("provider_code")) + if err != nil { + http.NotFound(w, r) + return + } + if socialKindSupported(kind) { + h.callbackSocial(w, r) + return + } + h.callbackOIDC(w, r) +} + func (h *ManagementHTTPHandler) listOIDCProviders(w http.ResponseWriter, r *http.Request) { if _, ok := h.requirePermission(w, r); !ok { return @@ -349,11 +364,12 @@ func (h *HTTPHandler) callbackOIDC(w http.ResponseWriter, r *http.Request) { h.writeIdentityError(w, ErrAccountDisabled) return } - token, err := h.service.sessions.Create(r.Context(), principalFor(account)) + token, err := h.service.sessions.CreateWithMeta(r.Context(), principalFor(account), h.service.ClientIP(r), r.UserAgent()) if err != nil { h.writeIdentityError(w, err) return } + h.service.NotifyLogin(r.Context(), account.ID, SessionMeta{IP: h.service.ClientIP(r), UserAgent: r.UserAgent()}) exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute) if err != nil { h.writeIdentityError(w, err) diff --git a/internal/identity/saml.go b/internal/identity/saml.go index a910d63..2f26516 100644 --- a/internal/identity/saml.go +++ b/internal/identity/saml.go @@ -222,6 +222,10 @@ func (h *HTTPHandler) startSSO(w http.ResponseWriter, r *http.Request) { h.startSAML(w, r) return } + if socialKindSupported(kind) { + h.startSocial(w, r) + return + } h.startOIDC(w, r) } @@ -308,11 +312,12 @@ func (h *HTTPHandler) callbackSAML(w http.ResponseWriter, r *http.Request) { h.writeIdentityError(w, ErrAccountDisabled) return } - token, err := h.service.sessions.Create(r.Context(), principalFor(account)) + token, err := h.service.sessions.CreateWithMeta(r.Context(), principalFor(account), h.service.ClientIP(r), r.UserAgent()) if err != nil { h.writeIdentityError(w, err) return } + h.service.NotifyLogin(r.Context(), account.ID, SessionMeta{IP: h.service.ClientIP(r), UserAgent: r.UserAgent()}) exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute) if err != nil { h.writeIdentityError(w, err) diff --git a/internal/identity/security_repo.go b/internal/identity/security_repo.go new file mode 100644 index 0000000..958bb72 --- /dev/null +++ b/internal/identity/security_repo.go @@ -0,0 +1,34 @@ +package identity + +import ( + "context" + + platformid "aigateway.local/core/internal/platform/id" +) + +// InsertOutboxEvent 发布一条 outbox 事件(供通知 worker 消费)。 +func (r *Repository) InsertOutboxEvent(ctx context.Context, eventType, aggregateType, aggregateID string, payload []byte) error { + if r.pool == nil { + return ErrUnavailable + } + eventID, err := platformid.NewUUID() + if err != nil { + return err + } + _, err = r.pool.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,$3,$4,$5)`, eventID, eventType, aggregateType, aggregateID, payload) + return mapRepositoryError(err) +} + +// SecurityPrefs 返回登录通知偏好;未配置时默认开启。 +func (r *Repository) SecurityPrefs(ctx context.Context, portalUserID string) (bool, error) { + var loginNotify bool + err := r.pool.QueryRow(ctx, `SELECT COALESCE((SELECT login_notify FROM gateway.portal_security_prefs WHERE portal_user_id=$1),true)`, portalUserID).Scan(&loginNotify) + return loginNotify, mapRepositoryError(err) +} + +// SetSecurityPrefs 更新登录通知偏好。 +func (r *Repository) SetSecurityPrefs(ctx context.Context, portalUserID string, loginNotify bool) error { + _, err := r.pool.Exec(ctx, `INSERT INTO gateway.portal_security_prefs(portal_user_id,login_notify) VALUES($1,$2) + ON CONFLICT(portal_user_id) DO UPDATE SET login_notify=$2,updated_at=clock_timestamp()`, portalUserID, loginNotify) + return mapRepositoryError(err) +} diff --git a/internal/identity/service.go b/internal/identity/service.go index e1c2ce2..d5ed570 100644 --- a/internal/identity/service.go +++ b/internal/identity/service.go @@ -2,6 +2,7 @@ package identity import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -96,7 +97,17 @@ func (s *Service) ClientIP(r *http.Request) string { return s.limiter.ClientIP(r) } +// SessionMeta 携带登录环境信息(IP/UA),用于设备管理与登录提醒。 +type SessionMeta struct { + IP string + UserAgent string +} + func (s *Service) Login(ctx context.Context, kind Kind, login, password string) (LoginResult, error) { + return s.LoginWithMeta(ctx, kind, login, password, SessionMeta{}) +} + +func (s *Service) LoginWithMeta(ctx context.Context, kind Kind, login, password string, meta SessionMeta) (LoginResult, error) { account, err := s.findByLogin(ctx, kind, login) if errors.Is(err, ErrNotFound) { _ = s.hasher.Verify(password, dummyPasswordHash) @@ -139,7 +150,7 @@ func (s *Service) Login(ctx context.Context, kind Kind, login, password string) upgradedHash = &hash } principal := principalFor(account) - token, err := s.sessions.Create(ctx, principal) + token, err := s.sessions.CreateWithMeta(ctx, principal, meta.IP, meta.UserAgent) if err != nil { return LoginResult{}, err } @@ -147,10 +158,17 @@ func (s *Service) Login(ctx context.Context, kind Kind, login, password string) _ = s.sessions.Delete(ctx, "Bearer "+token) return LoginResult{}, err } + if kind == KindPortal { + s.NotifyLogin(ctx, account.ID, meta) + } return LoginResult{Token: token, Account: account}, nil } func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, code, backupCode string) (LoginResult, error) { + return s.CompleteTOTPLoginWithMeta(ctx, kind, tempToken, code, backupCode, SessionMeta{}) +} + +func (s *Service) CompleteTOTPLoginWithMeta(ctx context.Context, kind Kind, tempToken, code, backupCode string, meta SessionMeta) (LoginResult, error) { // 先只读取(不消费)挑战令牌:验证码输错时令牌保留,用户可用同一 // 令牌重试,而不是每个笔误都强制重新走完整登录。 principal, err := s.sessions.AuthenticatePending(ctx, tempToken, kind) @@ -189,7 +207,7 @@ func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, c if _, err := s.sessions.ConsumePending(ctx, tempToken, kind); err != nil { return LoginResult{}, err } - token, err := s.sessions.Create(ctx, principalFor(account)) + token, err := s.sessions.CreateWithMeta(ctx, principalFor(account), meta.IP, meta.UserAgent) if err != nil { return LoginResult{}, err } @@ -198,9 +216,42 @@ func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, c return LoginResult{}, err } _ = s.sessions.DeleteToken(ctx, tempToken) + if kind == KindPortal { + s.NotifyLogin(ctx, account.ID, meta) + } return LoginResult{Token: token, Account: account}, nil } +// NotifyLogin 发布"新设备登录"事件(尽力而为,失败不影响登录)。站内信是否 +// 落盘由通知 worker 按用户的安全偏好决定。 +func (s *Service) NotifyLogin(ctx context.Context, portalUserID string, meta SessionMeta) { + if s.repository == nil || portalUserID == "" { + return + } + payload, _ := json.Marshal(map[string]any{"portal_user_id": portalUserID, "ip": meta.IP, "user_agent": meta.UserAgent}) + _ = s.repository.InsertOutboxEvent(ctx, "security.login_detected", "identity", portalUserID, payload) +} + +// ListSessions 返回账号的有效会话(我的登录设备)。 +func (s *Service) ListSessions(ctx context.Context, kind Kind, subjectID, authorization string) ([]SessionView, error) { + return s.sessions.ListSessions(ctx, kind, subjectID, authorization) +} + +// RevokeSession 吊销指定会话(当前会话除外)。 +func (s *Service) RevokeSession(ctx context.Context, kind Kind, subjectID, sessionID, authorization string) error { + return s.sessions.RevokeSession(ctx, kind, subjectID, sessionID, authorization) +} + +// SecurityPrefs 返回门户账号的安全偏好(登录通知开关,默认开启)。 +func (s *Service) SecurityPrefs(ctx context.Context, portalUserID string) (bool, error) { + return s.repository.SecurityPrefs(ctx, portalUserID) +} + +// SetSecurityPrefs 更新门户账号的安全偏好。 +func (s *Service) SetSecurityPrefs(ctx context.Context, portalUserID string, loginNotify bool) error { + return s.repository.SetSecurityPrefs(ctx, portalUserID, loginNotify) +} + func (s *Service) SetupTOTP(ctx context.Context, account Account, password string) (TOTPSetupResult, error) { if account.TOTPEnabled { return TOTPSetupResult{}, ErrTOTPAlreadyEnabled diff --git a/internal/identity/session.go b/internal/identity/session.go index 5529fa4..4d60515 100644 --- a/internal/identity/session.go +++ b/internal/identity/session.go @@ -25,6 +25,9 @@ type Principal struct { Role string `json:"role,omitempty"` Purpose string `json:"purpose"` IssuedAt int64 `json:"issued_at"` + // IP 与 UserAgent 记录签发时的登录环境,供"我的登录设备"展示与登录提醒。 + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` // AuthVersion 是签发会话时账号的凭据版本;凭据变更(改密/2FA 变更)会 // 递增该版本,旧版本会话在 Authenticate 时被拒绝,被盗会话无法在 // 凭据轮换后继续存活。 @@ -76,6 +79,14 @@ func (s *SessionStore) Create(ctx context.Context, principal Principal) (string, return s.create(ctx, principal, s.ttl) } +// CreateWithMeta 签发会话并记录登录环境(IP/UA),供设备管理与登录提醒使用。 +func (s *SessionStore) CreateWithMeta(ctx context.Context, principal Principal, ip, userAgent string) (string, error) { + principal.Purpose = "session" + principal.IP = truncate(strings.TrimSpace(ip), 64) + principal.UserAgent = truncate(strings.TrimSpace(userAgent), 256) + return s.create(ctx, principal, s.ttl) +} + func (s *SessionStore) CreatePending(ctx context.Context, principal Principal, ttl time.Duration) (string, error) { principal.Purpose = "totp_pending" return s.create(ctx, principal, ttl) @@ -99,6 +110,11 @@ func (s *SessionStore) create(ctx context.Context, principal Principal, ttl time if err := s.client.Set(ctx, sessionKey(token), payload, ttl).Err(); err != nil { return "", fmt.Errorf("%w: %v", ErrUnavailable, err) } + // 维护账号的会话索引(仅正式会话,不含 TOTP 挑战令牌),供 + // "我的登录设备"列出与吊销。索引不做 TTL 管理,列出时惰性清理过期项。 + if principal.Purpose == "session" { + _ = s.client.SAdd(ctx, sessionIndexKey(principal.Kind, principal.SubjectID), sessionHex(token)).Err() + } return token, nil } @@ -198,6 +214,107 @@ func (s *SessionStore) Delete(ctx context.Context, authorization string) error { return nil } +// SessionView 是"我的登录设备"视图。 +type SessionView struct { + ID string `json:"id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + IssuedAt int64 `json:"issued_at"` + Current bool `json:"current"` +} + +// ListSessions 列出账号的全部有效会话并惰性清理已过期项。 +// currentToken 为当前请求的会话,标记 current=true。 +func (s *SessionStore) ListSessions(ctx context.Context, kind Kind, subjectID, currentAuthorization string) ([]SessionView, error) { + if s.client == nil { + return nil, ErrUnavailable + } + hexes, err := s.client.SMembers(ctx, sessionIndexKey(kind, subjectID)).Result() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + items := []SessionView{} + for _, hex := range hexes { + payload, err := s.client.Get(ctx, sessionKeyFromHex(hex)).Bytes() + if errors.Is(err, redis.Nil) { + _ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err() + continue + } + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + var principal Principal + if json.Unmarshal(payload, &principal) != nil || principal.Kind != kind || principal.SubjectID != subjectID || principal.Purpose != "session" { + _ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err() + continue + } + items = append(items, SessionView{ID: hex, IP: principal.IP, UserAgent: principal.UserAgent, IssuedAt: principal.IssuedAt, + Current: s.sessionHexMatches(currentAuthorization, hex)}) + } + return items, nil +} + +// ErrRevokeCurrentSession 表示试图吊销当前登录的会话。 +var ErrRevokeCurrentSession = errors.New("不能吊销当前登录的会话") + +// RevokeSession 吊销指定会话(hex ID);当前会话不得吊销。 +func (s *SessionStore) RevokeSession(ctx context.Context, kind Kind, subjectID, sessionID, currentAuthorization string) error { + if s.client == nil { + return ErrUnavailable + } + sessionID = strings.TrimSpace(sessionID) + if len(sessionID) != 64 || !isHex(sessionID) { + return errors.New("会话标识无效") + } + if s.sessionHexMatches(currentAuthorization, sessionID) { + return ErrRevokeCurrentSession + } + removed, err := s.client.SRem(ctx, sessionIndexKey(kind, subjectID), sessionID).Result() + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if removed == 0 { + return ErrInvalidSession + } + if err := s.client.Del(ctx, sessionKeyFromHex(sessionID)).Err(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} + +func (s *SessionStore) sessionHexMatches(authorization, hex string) bool { + token, ok := bearerToken(authorization) + if !ok { + return false + } + return sessionHex(token) == hex +} + +func sessionIndexKey(kind Kind, subjectID string) string { + return "gateway:session-index:" + string(kind) + ":" + subjectID +} + +func sessionHex(token string) string { + digest := sha256.Sum256([]byte(token)) + return hex.EncodeToString(digest[:]) +} + +func sessionKeyFromHex(value string) string { + return "gateway:session:v1:" + value +} + +func isHex(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + return false + } + } + return true +} + func (s *SessionStore) StoreOneTime(ctx context.Context, namespace string, value any, ttl time.Duration) (string, error) { if s.client == nil { return "", ErrUnavailable diff --git a/internal/identity/social.go b/internal/identity/social.go new file mode 100644 index 0000000..1a44115 --- /dev/null +++ b/internal/identity/social.go @@ -0,0 +1,385 @@ +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) +} diff --git a/internal/identity/social_admin.go b/internal/identity/social_admin.go new file mode 100644 index 0000000..83318ce --- /dev/null +++ b/internal/identity/social_admin.go @@ -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}) +} diff --git a/internal/identity/social_http.go b/internal/identity/social_http.go new file mode 100644 index 0000000..8a8afc3 --- /dev/null +++ b/internal/identity/social_http.go @@ -0,0 +1,122 @@ +package identity + +import ( + "net/http" + "net/url" + "strings" + + "aigateway.local/core/internal/platform/apiresponse" +) + +// registerSocial 注册扫码登录的公开与已认证端点。 +// 公开入口复用 SSO 的 start/callback 路径(按 provider kind 分发); +// 绑定/解绑/绑定列表挂在 portal 账号安全页面。 +func (h *HTTPHandler) registerSocial() { + h.mux.HandleFunc("POST /api/v1/portal/social/{kind}/bind/start", h.bindStart) + h.mux.HandleFunc("DELETE /api/v1/portal/social/{kind}/bind", h.unbind) + h.mux.HandleFunc("GET /api/v1/portal/social/bindings", h.bindings) +} + +// startSocial 处理扫码登录的 start 分发(由 startSSO 按 kind 调用)。 +func (h *HTTPHandler) startSocial(w http.ResponseWriter, r *http.Request) { + provider, err := h.service.repository.GetSocialProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil || !provider.Enabled { + http.NotFound(w, r) + return + } + redirectURL, err := h.service.SocialLoginURL(r.Context(), provider.Kind, "login", "") + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// callbackSocial 处理扫码登录回调:成功后 302 回门户 return_url 并携带 +// sso_code(登录)或 bind_result(绑定),失败携带 sso_error。 +func (h *HTTPHandler) callbackSocial(w http.ResponseWriter, r *http.Request) { + provider, err := h.service.repository.GetSocialProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, "登录方式不存在") + return + } + if r.URL.Query().Get("error") != "" { + h.socialRedirect(w, r, provider, "sso_error", "企业登录已取消或拒绝") + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + code := strings.TrimSpace(r.URL.Query().Get("code")) + if state == "" || code == "" { + h.socialRedirect(w, r, provider, "sso_error", "登录回调参数无效") + return + } + result, err := h.service.CompleteSocialLogin(r.Context(), provider.Kind, state, code, SessionMeta{IP: h.service.ClientIP(r), UserAgent: r.UserAgent()}) + if err != nil { + h.socialRedirect(w, r, provider, "sso_error", err.Error()) + return + } + switch result.Purpose { + case "bind": + if result.BindConflict { + h.socialRedirect(w, r, provider, "bind_result", "conflict") + return + } + h.socialRedirect(w, r, provider, "bind_result", "ok") + case "login": + h.socialRedirect(w, r, provider, "sso_code", result.SSOCode) + } +} + +// socialRedirect 302 到门户 return_url 并携带结果参数。 +func (h *HTTPHandler) socialRedirect(w http.ResponseWriter, r *http.Request, provider SocialProvider, key, value string) { + target, err := url.Parse(provider.PortalReturnURL) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, "门户返回地址无效") + return + } + query := target.Query() + query.Set(key, value) + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +// bindStart 已认证用户发起扫码绑定:返回跳转企业身份源的 URL。 +func (h *HTTPHandler) bindStart(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + redirectURL, err := h.service.SocialLoginURL(r.Context(), r.PathValue("kind"), "bind", account.ID) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]string{"redirect_url": redirectURL}) +} + +// unbind 解除扫码绑定(仅本人)。 +func (h *HTTPHandler) unbind(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + if err := h.service.UnbindProvider(r.Context(), account.ID, r.PathValue("kind")); err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"unbound": true}) +} + +// bindings 返回账号的扫码绑定列表。 +func (h *HTTPHandler) bindings(w http.ResponseWriter, r *http.Request) { + account, ok := h.requireAccount(w, r, KindPortal) + if !ok { + return + } + items, err := h.service.ProviderBindings(r.Context(), account.ID) + if err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, items) +} diff --git a/internal/identity/social_repo.go b/internal/identity/social_repo.go new file mode 100644 index 0000000..a307fe4 --- /dev/null +++ b/internal/identity/social_repo.go @@ -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()) +} diff --git a/internal/identity/social_test.go b/internal/identity/social_test.go new file mode 100644 index 0000000..cf48f7d --- /dev/null +++ b/internal/identity/social_test.go @@ -0,0 +1,205 @@ +package identity + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "aigateway.local/core/internal/platform/cryptox" +) + +// hostRouter 把固定平台域名路由到本地的 httptest 服务,验证三个平台的 +// code 换取身份协议(端点、请求体、响应字段)。 +type hostRouter struct { + targets map[string]string + inner *http.Transport +} + +func (r hostRouter) RoundTrip(request *http.Request) (*http.Response, error) { + base, ok := r.targets[request.URL.Host] + if !ok { + return nil, errors.New("unexpected host " + request.URL.Host) + } + target, err := url.Parse(base) + if err != nil { + return nil, err + } + clone := request.Clone(request.Context()) + clone.URL.Scheme = target.Scheme + clone.URL.Host = target.Host + return r.inner.RoundTrip(clone) +} + +func testSocialService(t *testing.T, targets map[string]string) *Service { + t.Helper() + key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + cipher, err := cryptox.NewAESGCM(key, 1, "test") + if err != nil { + t.Fatal(err) + } + return &Service{ + idpCipher: cipher, + oidcClient: &http.Client{Transport: hostRouter{targets: targets, inner: http.DefaultTransport.(*http.Transport).Clone()}}, + } +} + +func socialProviderWithSecret(t *testing.T, service *Service, kind, clientID string, secret string) SocialProvider { + t.Helper() + raw, _ := json.Marshal(socialCredentials{Secret: secret}) + encrypted, version, err := service.idpCipher.Encrypt(raw) + if err != nil { + t.Fatal(err) + } + return SocialProvider{Kind: kind, ClientID: clientID, EncryptedCredentials: encrypted, CredentialKEKVersion: version} +} + +func TestExchangeWeCom(t *testing.T) { + tokenCalls := 0 + userCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/cgi-bin/gettoken"): + tokenCalls++ + if r.URL.Query().Get("corpid") != "corp-1" || r.URL.Query().Get("corpsecret") != "s3cret" { + t.Errorf("gettoken query = %v", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "access_token": "token-1"}) + case strings.HasPrefix(r.URL.Path, "/cgi-bin/auth/getuserinfo"): + userCalls++ + if r.URL.Query().Get("code") != "code-x" { + t.Errorf("getuserinfo code = %q", r.URL.Query().Get("code")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "userid": "zhangsan", "openid": "open-1"}) + default: + t.Errorf("unexpected wecom path %s", r.URL.Path) + } + })) + defer server.Close() + + service := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server.URL}) + identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "wecom", "corp-1", "s3cret"), "code-x") + if err != nil { + t.Fatalf("wecom exchange failed: %v", err) + } + if identity.UID != "zhangsan" { + t.Errorf("uid = %q, want zhangsan", identity.UID) + } + if tokenCalls != 1 || userCalls != 1 { + t.Errorf("calls token=%d user=%d, want 1/1", tokenCalls, userCalls) + } + + // 企业外成员只有 openid 时回退 openid。 + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/cgi-bin/gettoken") { + _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "access_token": "token-2"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "userid": "", "openid": "open-2"}) + })) + defer server2.Close() + service2 := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server2.URL}) + identity2, err := service2.exchangeSocial(context.Background(), socialProviderWithSecret(t, service2, "wecom", "corp-1", "s3cret"), "code-x") + if err != nil { + t.Fatalf("wecom openid fallback failed: %v", err) + } + if identity2.UID != "open-2" { + t.Errorf("uid = %q, want open-2", identity2.UID) + } +} + +func TestExchangeDingTalk(t *testing.T) { + var tokenBody map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/v1.0/oauth2/userAccessToken"): + if err := json.NewDecoder(r.Body).Decode(&tokenBody); err != nil { + t.Errorf("decode token body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "dt-token"}) + case strings.HasPrefix(r.URL.Path, "/v1.0/contact/users/me"): + if r.Header.Get("x-acs-dingtalk-access-token") != "dt-token" { + t.Errorf("missing x-acs-dingtalk-access-token header") + } + _ = json.NewEncoder(w).Encode(map[string]any{"unionId": "union-9", "openId": "open-9", "nick": "张三"}) + default: + t.Errorf("unexpected dingtalk path %s", r.URL.Path) + } + })) + defer server.Close() + + service := testSocialService(t, map[string]string{"api.dingtalk.com": server.URL}) + identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "dingtalk", "app-key", "app-secret"), "code-d") + if err != nil { + t.Fatalf("dingtalk exchange failed: %v", err) + } + if identity.UID != "union-9" || identity.Name != "张三" { + t.Errorf("uid=%q name=%q", identity.UID, identity.Name) + } + if tokenBody["grantType"] != "authorization_code" || tokenBody["clientId"] != "app-key" { + t.Errorf("token body = %v", tokenBody) + } +} + +func TestExchangeFeishu(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/open-apis/authen/v1/oidc/access_token"): + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + if body["grant_type"] != "authorization_code" || body["app_id"] != "app-1" { + t.Errorf("token body = %v", body) + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"access_token": "fs-token"}}) + case strings.HasPrefix(r.URL.Path, "/open-apis/authen/v1/user_info"): + if r.Header.Get("Authorization") != "Bearer fs-token" { + t.Errorf("missing bearer token") + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"name": "李四", "open_id": "ou_1", "union_id": "on_1"}}) + default: + t.Errorf("unexpected feishu path %s", r.URL.Path) + } + })) + defer server.Close() + + service := testSocialService(t, map[string]string{"open.feishu.cn": server.URL}) + identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "feishu", "app-1", "app-secret"), "code-f") + if err != nil { + t.Fatalf("feishu exchange failed: %v", err) + } + if identity.UID != "on_1" || identity.Name != "李四" { + t.Errorf("uid=%q name=%q", identity.UID, identity.Name) + } +} + +func TestExchangeSocialRejectsPlatformErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40013, "errmsg": "invalid corpsecret"}) + })) + defer server.Close() + + service := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server.URL}) + if _, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "wecom", "corp-1", "bad"), "code-x"); err == nil { + t.Fatal("expected error for platform errcode != 0") + } +} + +func TestSocialKindSupported(t *testing.T) { + for _, kind := range []string{"wecom", "dingtalk", "feishu", "WECOM", " DingTalk "} { + if !socialKindSupported(kind) { + t.Errorf("kind %q should be supported (case/space normalized)", kind) + } + } + for _, kind := range []string{"oidc", "saml", "", "weixin"} { + if socialKindSupported(kind) { + t.Errorf("kind %q should not be supported", kind) + } + } +} diff --git a/internal/portal/chat.go b/internal/portal/chat.go new file mode 100644 index 0000000..fa6343f --- /dev/null +++ b/internal/portal/chat.go @@ -0,0 +1,329 @@ +package portal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/identity" + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" +) + +// ChatModel 是门户用户经审批可用的模型。 +type ChatModel struct { + ProviderCode string `json:"provider_code"` + Model string `json:"model"` + ApprovedAt time.Time `json:"approved_at"` +} + +// ChatModels 返回该用户所有已批准且供应商/模型仍启用的模型。 +func (s *Service) ChatModels(ctx context.Context, account identity.Account) ([]ChatModel, error) { + rows, err := s.pool.Query(ctx, `SELECT DISTINCT r.provider_code,r.model,max(r.decided_at) + FROM gateway.model_access_requests r + JOIN gateway.providers p ON p.code=r.provider_code AND p.enabled + JOIN gateway.provider_models m ON m.provider_id=p.id AND m.provider_model_id=r.model AND m.enabled + WHERE r.portal_user_id=$1 AND r.status='approved' + GROUP BY r.provider_code,r.model ORDER BY r.provider_code,r.model`, account.ID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChatModel{} + for rows.Next() { + var item ChatModel + if err := rows.Scan(&item.ProviderCode, &item.Model, &item.ApprovedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +// approvedModel 校验模型是否在该用户的已批准清单内。 +func (s *Service) approvedModel(ctx context.Context, account identity.Account, providerCode, model string) (ChatModel, error) { + providerCode = strings.ToLower(strings.TrimSpace(providerCode)) + model = strings.TrimSpace(model) + var item ChatModel + err := s.pool.QueryRow(ctx, `SELECT r.provider_code,r.model,r.decided_at + FROM gateway.model_access_requests r + JOIN gateway.providers p ON p.code=r.provider_code AND p.enabled + JOIN gateway.provider_models m ON m.provider_id=p.id AND m.provider_model_id=r.model AND m.enabled + WHERE r.portal_user_id=$1 AND r.provider_code=$2 AND r.model=$3 AND r.status='approved' + ORDER BY r.decided_at DESC LIMIT 1`, account.ID, providerCode, model).Scan(&item.ProviderCode, &item.Model, &item.ApprovedAt) + if errors.Is(err, pgx.ErrNoRows) { + return ChatModel{}, ErrNotFound + } + return item, err +} + +// ensureChatCredential 为用户开通/复用聊天运行时凭据,限额取已批准申请的最大值。 +func (s *Service) ensureChatCredential(ctx context.Context, account identity.Account) (string, error) { + if s.credentials == nil || s.runtime == nil { + return "", errors.New("聊天服务未配置") + } + var rpm int + var monthlyTokens int64 + if err := s.pool.QueryRow(ctx, `SELECT COALESCE(max(requested_rpm),0),COALESCE(max(requested_monthly_tokens),0) FROM gateway.model_access_requests WHERE portal_user_id=$1 AND status='approved'`, account.ID).Scan(&rpm, &monthlyTokens); err != nil { + return "", err + } + secret, _, err := s.credentials.EnsureUser(ctx, account.ID, account.DepartmentID, rpm, monthlyTokens) + return secret, err +} + +// ChatSession 是一条通用聊天会话。 +type ChatSession struct { + ID string `json:"id"` + Title string `json:"title"` + ProviderCode string `json:"provider_code"` + Model string `json:"model"` + Status string `json:"status"` + Messages []ConversationMessage `json:"messages,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +const chatSessionSelect = `SELECT id::text,title,provider_code,model,status,created_at,updated_at FROM gateway.portal_chat_sessions` + +func (s *Service) ListChatSessions(ctx context.Context, account identity.Account, limit int) ([]ChatSession, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, chatSessionSelect+` WHERE portal_user_id=$1 AND status='active' ORDER BY updated_at DESC LIMIT $2`, account.ID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChatSession{} + for rows.Next() { + var item ChatSession + if err := rows.Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Service) CreateChatSession(ctx context.Context, account identity.Account, providerCode, model string) (ChatSession, error) { + if _, err := s.approvedModel(ctx, account, providerCode, model); err != nil { + return ChatSession{}, ErrNotFound + } + if _, err := s.ensureChatCredential(ctx, account); err != nil { + return ChatSession{}, err + } + id, err := platformid.NewUUID() + if err != nil { + return ChatSession{}, err + } + var item ChatSession + err = s.pool.QueryRow(ctx, `INSERT INTO gateway.portal_chat_sessions(id,portal_user_id,provider_code,model) VALUES($1,$2,$3,$4) RETURNING id::text,'',provider_code,model,status,created_at,updated_at`, id, account.ID, providerCode, model).Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt) + item.Messages = []ConversationMessage{} + return item, err +} + +func (s *Service) RenameChatSession(ctx context.Context, account identity.Account, id, title string) (ChatSession, error) { + title = strings.TrimSpace(title) + if title == "" || len(title) > 128 { + return ChatSession{}, errors.New("会话标题必须为 1-128 个字符") + } + var item ChatSession + err := s.pool.QueryRow(ctx, `UPDATE gateway.portal_chat_sessions SET title=$3,updated_at=clock_timestamp() WHERE id=$1 AND portal_user_id=$2 AND status='active' RETURNING id::text,title,provider_code,model,status,created_at,updated_at`, id, account.ID, title).Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return ChatSession{}, ErrNotFound + } + return item, err +} + +func (s *Service) DeleteChatSession(ctx context.Context, account identity.Account, id string) error { + tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET status='archived' WHERE id=$1 AND portal_user_id=$2`, id, account.ID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ChatSession 返回会话与全部消息(哈希链完整性校验)。 +func (s *Service) ChatSession(ctx context.Context, account identity.Account, id string) (ChatSession, error) { + var item ChatSession + err := s.pool.QueryRow(ctx, chatSessionSelect+` WHERE id=$1 AND portal_user_id=$2`, id, account.ID).Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return ChatSession{}, ErrNotFound + } + if err != nil { + return ChatSession{}, err + } + rows, err := s.pool.Query(ctx, `SELECT sequence,role,content,previous_hash,message_hash,created_at FROM gateway.portal_chat_messages WHERE session_id=$1 ORDER BY sequence`, id) + if err != nil { + return ChatSession{}, err + } + defer rows.Close() + previous := strings.Repeat("0", 64) + item.Messages = []ConversationMessage{} + for rows.Next() { + var message ConversationMessage + var storedPrevious, storedHash string + if err = rows.Scan(&message.Sequence, &message.Role, &message.Content, &storedPrevious, &storedHash, &message.CreatedAt); err != nil { + return ChatSession{}, err + } + if storedPrevious != previous || storedHash != messageDigest(previous, message.Sequence, message.Role, message.Content) { + return ChatSession{}, errors.New("会话历史完整性校验失败") + } + previous = storedHash + item.Messages = append(item.Messages, message) + } + return item, rows.Err() +} + +// appendChatMessage 在会话上追加一条消息(哈希链 + 序号,事务内完成)。 +func (s *Service) appendChatMessage(ctx context.Context, sessionID, role, content string) (ConversationMessage, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return ConversationMessage{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var sequence int + if err = tx.QueryRow(ctx, `SELECT next_sequence FROM gateway.portal_chat_sessions WHERE id=$1 FOR UPDATE`, sessionID).Scan(&sequence); err != nil { + return ConversationMessage{}, err + } + if sequence > 200 { + return ConversationMessage{}, errors.New("本会话已达到 200 条消息上限") + } + previous := strings.Repeat("0", 64) + if sequence > 1 { + if err = tx.QueryRow(ctx, `SELECT message_hash FROM gateway.portal_chat_messages WHERE session_id=$1 AND sequence=$2`, sessionID, sequence-1).Scan(&previous); err != nil { + return ConversationMessage{}, err + } + } + id, err := platformid.NewUUID() + if err != nil { + return ConversationMessage{}, err + } + hash := messageDigest(previous, sequence, role, content) + var created time.Time + if err = tx.QueryRow(ctx, `INSERT INTO gateway.portal_chat_messages(id,session_id,sequence,role,content,previous_hash,message_hash) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING created_at`, id, sessionID, sequence, role, content, previous, hash).Scan(&created); err != nil { + return ConversationMessage{}, err + } + _, err = tx.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET next_sequence=next_sequence+1,title=CASE WHEN next_sequence=1 THEN left($2,160) ELSE title END,updated_at=clock_timestamp() WHERE id=$1`, sessionID, content) + if err != nil { + return ConversationMessage{}, err + } + if err = tx.Commit(ctx); err != nil { + return ConversationMessage{}, err + } + return ConversationMessage{Sequence: sequence, Role: role, Content: content, CreatedAt: created}, nil +} + +// callChat 用用户的运行时凭据直接调用受管网关 /v1/chat/completions。 +// 认证、限流、配额、审计与路由都由网关统一执行,与外部 API Key 调用完全同权。 +func (s *Service) callChat(ctx context.Context, secret, providerCode, model string, messages []ConversationMessage) (map[string]any, string, error) { + payloadMessages := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + payloadMessages = append(payloadMessages, map[string]any{"role": m.Role, "content": m.Content}) + } + payload, _ := json.Marshal(map[string]any{"model": model, "messages": payloadMessages, "stream": false}) + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "portal-chat-"+time.Now().UTC().Format("20060102150405.000000000"))) + request.Header.Set("Authorization", "Bearer "+secret) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + s.gateway.ServeHTTP(recorder, request) + var response map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + return nil, "", errors.New("模型响应无法解析") + } + if recorder.Code < 200 || recorder.Code >= 300 { + message := fmt.Sprintf("模型调用失败(HTTP %d)", recorder.Code) + if value, ok := response["error"].(map[string]any); ok { + if text, ok := value["message"].(string); ok { + message = text + } + } + return response, "", errors.New(message) + } + choices, _ := response["choices"].([]any) + if len(choices) == 0 { + return response, "", errors.New("模型未返回回答") + } + choice, _ := choices[0].(map[string]any) + message, _ := choice["message"].(map[string]any) + answer, _ := message["content"].(string) + if strings.TrimSpace(answer) == "" { + return response, "", errors.New("模型未返回文本回答") + } + return response, answer, nil +} + +// ChatOnce 一次性对话(不落库):模型须已批准,凭据自动开通。 +func (s *Service) ChatOnce(ctx context.Context, account identity.Account, providerCode, model, message string) (map[string]any, error) { + if _, err := s.approvedModel(ctx, account, providerCode, model); err != nil { + return nil, ErrNotFound + } + message = strings.TrimSpace(message) + if message == "" || len(message) > 100000 { + return nil, errors.New("消息为空或过长") + } + secret, err := s.ensureChatCredential(ctx, account) + if err != nil { + return nil, err + } + response, _, err := s.callChat(ctx, secret, providerCode, model, []ConversationMessage{{Role: "user", Content: message}}) + return response, err +} + +// AppendChatMessage 在会话上追加一轮对话:busy 租约防并发交错,消息在模型 +// 调用成功后才落库,失败重试不会产生孤儿消息或重复消息。 +func (s *Service) AppendChatMessage(ctx context.Context, account identity.Account, id, message string) (map[string]any, error) { + message = strings.TrimSpace(message) + if message == "" || len(message) > 100000 { + return nil, errors.New("消息为空或过长") + } + lease, _ := platformid.NewUUID() + tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET busy=true,busy_token=$3,busy_since=clock_timestamp() WHERE id=$1 AND portal_user_id=$2 AND status='active' AND (NOT busy OR busy_since= 1), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS portal_chat_sessions_user_time_idx + ON gateway.portal_chat_sessions (portal_user_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS gateway.portal_chat_messages ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES gateway.portal_chat_sessions(id) ON DELETE CASCADE, + sequence integer NOT NULL, + role varchar(16) NOT NULL CHECK (role IN ('user', 'assistant')), + content text NOT NULL, + previous_hash varchar(64) NOT NULL, + message_hash varchar(64) NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (session_id, sequence) +); + +CREATE INDEX IF NOT EXISTS portal_chat_messages_session_idx + ON gateway.portal_chat_messages (session_id, sequence); diff --git a/migrations/000039_social_login.sql b/migrations/000039_social_login.sql new file mode 100644 index 0000000..6fe2122 --- /dev/null +++ b/migrations/000039_social_login.sql @@ -0,0 +1,30 @@ +-- 000039_social_login.sql — 企微/钉钉/飞书扫码登录。 +-- 身份源表 identity_providers 扩展三种内置扫码登录 kind;企业用户与平台账号的 +-- 绑定关系单独落表(provider_uid 全局唯一,防止同一企微账号绑定多个本系统账号)。 + +ALTER TABLE gateway.identity_providers + DROP CONSTRAINT IF EXISTS identity_providers_kind_check; + +ALTER TABLE gateway.identity_providers + ADD CONSTRAINT identity_providers_kind_check + CHECK (kind IN ('oidc', 'saml', 'wecom', 'dingtalk', 'feishu')); + +-- 扫码登录自动开通的账号 auth_source 记为平台 kind。 +ALTER TABLE gateway.portal_users + DROP CONSTRAINT IF EXISTS portal_users_auth_source_check; + +ALTER TABLE gateway.portal_users + ADD CONSTRAINT portal_users_auth_source_check + CHECK (auth_source IN ('local', 'feishu', 'oidc', 'saml', 'wecom', 'dingtalk')); + +CREATE TABLE IF NOT EXISTS gateway.portal_user_provider_bindings ( + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + provider_kind varchar(16) NOT NULL CHECK (provider_kind IN ('wecom', 'dingtalk', 'feishu')), + provider_uid varchar(255) NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (provider_kind, provider_uid), + UNIQUE (portal_user_id, provider_kind) +); + +CREATE INDEX IF NOT EXISTS portal_user_provider_bindings_user_idx + ON gateway.portal_user_provider_bindings (portal_user_id); diff --git a/migrations/000040_security_prefs.sql b/migrations/000040_security_prefs.sql new file mode 100644 index 0000000..c27d9a1 --- /dev/null +++ b/migrations/000040_security_prefs.sql @@ -0,0 +1,9 @@ +-- 000040_security_prefs.sql — 个人安全策略:新设备登录通知开关。 +-- 登录成功后 identity 服务发布 security.login_detected 事件,通知 worker 依据 +-- 本表开关决定是否落站内信(默认开启,收件人 = 账号本人)。 + +CREATE TABLE IF NOT EXISTS gateway.portal_security_prefs ( + portal_user_id uuid PRIMARY KEY REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + login_notify boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); diff --git a/migrations/000041_inbox_security_category.sql b/migrations/000041_inbox_security_category.sql new file mode 100644 index 0000000..8f9a6cd --- /dev/null +++ b/migrations/000041_inbox_security_category.sql @@ -0,0 +1,7 @@ +-- 000041_inbox_security_category.sql — 站内信新增 security 类别(登录提醒等安全事件)。 +ALTER TABLE gateway.inbox_messages + DROP CONSTRAINT IF EXISTS inbox_messages_category_check; + +ALTER TABLE gateway.inbox_messages + ADD CONSTRAINT inbox_messages_category_check + CHECK (category = ANY (ARRAY['system', 'approval', 'task_result', 'resource', 'security'])); diff --git a/web/apps/admin/src/api/identities.ts b/web/apps/admin/src/api/identities.ts index 57c9cb1..805371d 100644 --- a/web/apps/admin/src/api/identities.ts +++ b/web/apps/admin/src/api/identities.ts @@ -160,3 +160,50 @@ export function createSAMLProvider(data: SAMLProviderInput) { export function updateSAMLProvider(id: string, data: SAMLProviderInput) { return request.put({ url: `/api/v1/admin/saml-providers/${id}`, params: data }) } + +export interface SocialProviderRecord { + id: string + code: string + kind: 'wecom' | 'dingtalk' | 'feishu' + display_name: string + client_id: string + agent_id: string + secret_configured: boolean + redirect_uri: string + portal_return_url: string + auto_provision: boolean + default_department_id?: string + enabled: boolean + revision: number + created_at: string + updated_at: string +} + +export interface SocialProviderInput { + code: string + display_name: string + client_id: string + agent_id?: string + secret?: string + redirect_uri: string + portal_return_url: string + auto_provision: boolean + default_department_id?: string + enabled: boolean +} + +export function fetchSocialProviders() { + return request.get({ url: '/api/v1/admin/social-providers' }) +} + +export function createSocialProvider(kind: string, data: SocialProviderInput) { + return request.post({ url: '/api/v1/admin/social-providers', params: { kind }, data }) +} + +export function updateSocialProvider(kind: string, data: SocialProviderInput) { + return request.put({ url: `/api/v1/admin/social-providers/${kind}`, params: data }) +} + +export function deleteSocialProvider(kind: string) { + return request.del({ url: `/api/v1/admin/social-providers/${kind}` }) +} diff --git a/web/apps/admin/src/views/system/user/index.vue b/web/apps/admin/src/views/system/user/index.vue index ae0d25b..9b91a4b 100644 --- a/web/apps/admin/src/views/system/user/index.vue +++ b/web/apps/admin/src/views/system/user/index.vue @@ -6,7 +6,7 @@

管理员和门户账号统一管理,权限变更即时生效

- {{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' ? '新增身份源' : '新增账号' }} + {{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' || activeTab === 'social' ? '新增身份源' : '新增账号' }} @@ -23,6 +23,7 @@ + @@ -101,6 +102,31 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -257,22 +283,62 @@ 保存 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/apps/portal/src/views/portal/security/index.vue b/web/apps/portal/src/views/portal/security/index.vue new file mode 100644 index 0000000..4c609d8 --- /dev/null +++ b/web/apps/portal/src/views/portal/security/index.vue @@ -0,0 +1,176 @@ + +