0.11.2: 旗舰版第三轮完善(通用聊天/企微钉钉飞书扫码登录/个人安全策略)

- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时
  API Key(加密落库,限额取批准值),聊天经受管网关统一认证/限流/配额/审计;
  会话哈希链完整性 + busy 租约防并发,失败不落库。
- 扫码登录:identity_providers 扩展 wecom/dingtalk/feishu,管理端配置
  (AppID/AppSecret/AgentID/回调/自动开户/默认部门),登录页自动展示;
  one-time state 防 CSRF,provider_uid 全局唯一防多账号绑定,平台端点
  固定公网 URL 复用 public-only 拨号。
- 个人安全策略:账号安全页(登录设备管理/吊销非当前会话/登录提醒开关/
  扫码绑定解绑),登录成功发布 security.login_detected 事件按偏好落站内信
  (新增 security 类别),会话索引只存令牌摘要并惰性清理。
- 迁移 000038-000041;修复 social update 参数越界/凭据回读/路由挂载缺失;
  全量测试 25 包通过,前端 admin/portal 构建通过,端到端验证完成。
This commit is contained in:
LLMGuardX Dev
2026-08-13 12:53:38 +08:00
parent 4563979a15
commit e31cc54b8e
36 changed files with 2823 additions and 60 deletions
+72 -3
View File
@@ -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": "账号安全"}},
}},
}
}
+1
View File
@@ -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
}
+18 -2
View File
@@ -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)
+6 -1
View File
@@ -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)
+34
View File
@@ -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)
}
+53 -2
View File
@@ -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
+117
View File
@@ -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
+385
View File
@@ -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)
}
+190
View File
@@ -0,0 +1,190 @@
package identity
import (
"encoding/json"
"net/http"
"strings"
"aigateway.local/core/internal/platform/apiresponse"
)
// registerSocialAdmin 注册扫码登录身份源的管理端点。
func (h *ManagementHTTPHandler) registerSocialAdmin() {
h.mux.HandleFunc("GET /api/v1/admin/social-providers", h.listSocialProviders)
h.mux.HandleFunc("POST /api/v1/admin/social-providers", h.createSocialProvider)
h.mux.HandleFunc("PUT /api/v1/admin/social-providers/{kind}", h.updateSocialProvider)
h.mux.HandleFunc("DELETE /api/v1/admin/social-providers/{kind}", h.deleteSocialProvider)
}
type socialProviderInput struct {
Code string `json:"code"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
AgentID string `json:"agent_id"`
Secret *string `json:"secret"`
RedirectURI string `json:"redirect_uri"`
PortalReturnURL string `json:"portal_return_url"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id"`
Enabled bool `json:"enabled"`
}
func (h *ManagementHTTPHandler) decodeSocialProvider(w http.ResponseWriter, r *http.Request, creating bool) (socialProviderInput, SocialProvider, bool) {
var input socialProviderInput
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil {
apiresponse.Error(w, 400, "请求格式无效")
return input, SocialProvider{}, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.ClientID = strings.TrimSpace(input.ClientID)
input.AgentID = strings.TrimSpace(input.AgentID)
if input.Code == "" || input.DisplayName == "" || input.ClientID == "" ||
(creating && input.Secret == nil) || (input.Secret != nil && strings.TrimSpace(*input.Secret) == "") {
apiresponse.Error(w, 400, "身份源代码、名称、AppID 或 AppSecret 无效")
return input, SocialProvider{}, false
}
redirectURI, err := validateAbsoluteURL(input.RedirectURI)
if err != nil || strings.Contains(redirectURI, "#") {
apiresponse.Error(w, 400, "回调 URL 无效")
return input, SocialProvider{}, false
}
returnURL, err := validateAbsoluteURL(input.PortalReturnURL)
if err != nil {
apiresponse.Error(w, 400, "门户返回 URL 无效")
return input, SocialProvider{}, false
}
if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" {
department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID)
if err != nil || !department.Active {
apiresponse.Error(w, 400, "默认部门不存在或已停用")
return input, SocialProvider{}, false
}
}
return input, SocialProvider{Code: input.Code, DisplayName: input.DisplayName, ClientID: input.ClientID, AgentID: input.AgentID,
RedirectURI: redirectURI, PortalReturnURL: returnURL, AutoProvision: input.AutoProvision,
DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled}, true
}
func (h *ManagementHTTPHandler) setSocialCredentials(record *SocialProvider, secret string) error {
payload, _ := json.Marshal(socialCredentials{Secret: secret})
encrypted, version, err := h.service.idpCipher.Encrypt(payload)
if err != nil {
return err
}
record.EncryptedCredentials, record.CredentialKEKVersion = encrypted, version
return nil
}
func (h *ManagementHTTPHandler) socialProviderView(record SocialProvider) map[string]any {
configured := false
if plaintext, err := h.service.idpCipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion); err == nil {
var credentials socialCredentials
configured = json.Unmarshal(plaintext, &credentials) == nil && credentials.Secret != ""
}
return map[string]any{"id": record.ID, "code": record.Code, "kind": record.Kind, "display_name": record.DisplayName,
"client_id": record.ClientID, "agent_id": record.AgentID, "secret_configured": configured,
"redirect_uri": record.RedirectURI, "portal_return_url": record.PortalReturnURL,
"auto_provision": record.AutoProvision, "default_department_id": record.DefaultDepartmentID,
"enabled": record.Enabled, "revision": record.Revision, "created_at": record.CreatedAt, "updated_at": record.UpdatedAt}
}
func (h *ManagementHTTPHandler) listSocialProviders(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r); !ok {
return
}
records, err := h.service.repository.ListSocialProviders(r.Context())
if err != nil {
h.writeError(w, err)
return
}
items := make([]map[string]any, 0, len(records))
for _, record := range records {
items = append(items, h.socialProviderView(record))
}
apiresponse.OK(w, items)
}
func (h *ManagementHTTPHandler) createSocialProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind")))
if !socialKindSupported(kind) {
apiresponse.Error(w, 400, "扫码登录平台必须是 wecom/dingtalk/feishu")
return
}
input, record, ok := h.decodeSocialProvider(w, r, true)
if !ok {
return
}
if kind == "wecom" && input.AgentID == "" {
apiresponse.Error(w, 400, "企业微信身份源需要 AgentID")
return
}
if err := h.setSocialCredentials(&record, strings.TrimSpace(*input.Secret)); err != nil {
h.writeError(w, err)
return
}
record.Kind = kind
created, err := h.service.repository.SaveSocialProvider(r.Context(), record, actor.ID, true, true)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, h.socialProviderView(created))
}
func (h *ManagementHTTPHandler) updateSocialProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
kind := strings.ToLower(strings.TrimSpace(r.PathValue("kind")))
if !socialKindSupported(kind) {
apiresponse.Error(w, 400, "扫码登录平台必须是 wecom/dingtalk/feishu")
return
}
existing, err := h.service.repository.GetSocialProviderByKind(r.Context(), kind)
if err != nil {
h.writeError(w, err)
return
}
input, record, ok := h.decodeSocialProvider(w, r, false)
if !ok {
return
}
if kind == "wecom" && input.AgentID == "" {
apiresponse.Error(w, 400, "企业微信身份源需要 AgentID")
return
}
record.ID = existing.ID
record.Kind = kind
replace := input.Secret != nil
if replace {
if err := h.setSocialCredentials(&record, strings.TrimSpace(*input.Secret)); err != nil {
h.writeError(w, err)
return
}
}
updated, err := h.service.repository.SaveSocialProvider(r.Context(), record, actor.ID, false, replace)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, h.socialProviderView(updated))
}
func (h *ManagementHTTPHandler) deleteSocialProvider(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r); !ok {
return
}
if err := h.service.repository.DeleteSocialProvider(r.Context(), r.PathValue("kind")); err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"deleted": true})
}
+122
View File
@@ -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)
}
+220
View File
@@ -0,0 +1,220 @@
package identity
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
)
// SocialProvider 是内置扫码登录身份源(企微/钉钉/飞书)。
// 复用 identity_providers 表:client_id 存平台 AppID(企微为 corp_id),
// encrypted_credentials 加密存放 AppSecret,agent_id 等平台特有参数放 config jsonb。
type SocialProvider struct {
ID string `json:"id"`
Code string `json:"code"`
Kind string `json:"kind"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
AgentID string `json:"agent_id,omitempty"`
EncryptedCredentials []byte `json:"-"`
CredentialKEKVersion int `json:"-"`
RedirectURI string `json:"redirect_uri"`
PortalReturnURL string `json:"portal_return_url"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id,omitempty"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type socialCredentials struct {
Secret string `json:"secret"`
}
// ProviderBinding 是门户账号与企微/钉钉/飞书账号的绑定关系。
type ProviderBinding struct {
Kind string `json:"kind"`
ProviderUID string `json:"provider_uid"`
CreatedAt time.Time `json:"created_at"`
}
const socialKinds = "('wecom','dingtalk','feishu')"
func scanSocialProvider(row pgx.Row) (SocialProvider, error) {
var p SocialProvider
var config []byte
var defaultDepartment *string
err := row.Scan(&p.ID, &p.Code, &p.DisplayName, &p.Kind, &p.ClientID, &config, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.AutoProvision, &defaultDepartment, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrNotFound
}
if err != nil {
return p, mapRepositoryError(err)
}
p.DefaultDepartmentID = defaultDepartment
var values map[string]string
if json.Unmarshal(config, &values) == nil {
p.AgentID = values["agent_id"]
}
return p, nil
}
// ListSocialProviders 返回全部扫码登录身份源。
func (r *Repository) ListSocialProviders(ctx context.Context) ([]SocialProvider, error) {
rows, err := r.pool.Query(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind IN `+socialKinds+` ORDER BY kind,code`)
if err != nil {
return nil, mapRepositoryError(err)
}
defer rows.Close()
items := []SocialProvider{}
for rows.Next() {
p, err := scanSocialProvider(rows)
if err != nil {
return nil, err
}
items = append(items, p)
}
return items, mapRepositoryError(rows.Err())
}
// GetSocialProviderByKind 按 kind 返回扫码登录身份源。
func (r *Repository) GetSocialProviderByKind(ctx context.Context, kind string) (SocialProvider, error) {
return scanSocialProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind=$1`, strings.ToLower(strings.TrimSpace(kind))))
}
// GetSocialProviderByCode 按 SSO 代码返回扫码登录身份源(start/callback 分发用)。
func (r *Repository) GetSocialProviderByCode(ctx context.Context, code string) (SocialProvider, error) {
return scanSocialProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE code=$1 AND kind IN `+socialKinds, strings.ToLower(strings.TrimSpace(code))))
}
// SaveSocialProvider 创建/更新扫码登录身份源;replaceSecret=false 时保留原 Secret。
func (r *Repository) SaveSocialProvider(ctx context.Context, p SocialProvider, actorID string, creating, replaceSecret bool) (SocialProvider, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return p, ErrUnavailable
}
defer func() { _ = tx.Rollback(ctx) }()
if creating {
id, err := platformid.NewUUID()
if err != nil {
return p, err
}
p.ID = id
err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id,enabled,config) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING revision,created_at,updated_at`,
p.ID, p.Code, p.Kind, p.DisplayName, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, agentConfigJSON(p.AgentID)).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,client_id=$4,encrypted_credentials=CASE WHEN $13 THEN $5 ELSE encrypted_credentials END,credential_kek_version=CASE WHEN $13 THEN $6 ELSE credential_kek_version END,redirect_uri=$7,portal_return_url=$8,auto_provision=$9,default_department_id=$10,enabled=$11,config=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind IN `+socialKinds+` RETURNING encrypted_credentials,credential_kek_version,revision,created_at,updated_at`,
p.ID, p.Code, p.DisplayName, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, agentConfigJSON(p.AgentID), replaceSecret).Scan(&p.EncryptedCredentials, &p.CredentialKEKVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
}
if err != nil {
return p, mapManagementError(err)
}
eventID, _ := platformid.NewUUID()
eventType := "identity_provider.updated"
if creating {
eventType = "identity_provider.created"
}
payload, _ := json.Marshal(map[string]any{"identity_provider_id": p.ID, "actor_id": actorID})
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, p.ID, payload); err != nil {
return p, ErrUnavailable
}
if tx.Commit(ctx) != nil {
return p, ErrUnavailable
}
return p, nil
}
// DeleteSocialProvider 删除扫码登录身份源及其全部绑定。
func (r *Repository) DeleteSocialProvider(ctx context.Context, kind string) error {
kind = strings.ToLower(strings.TrimSpace(kind))
tx, err := r.pool.Begin(ctx)
if err != nil {
return ErrUnavailable
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
if err = tx.QueryRow(ctx, `DELETE FROM gateway.identity_providers WHERE kind=$1 RETURNING id::text`, kind).Scan(&id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return mapManagementError(err)
}
if _, err = tx.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE provider_kind=$1`, kind); err != nil {
return ErrUnavailable
}
eventID, _ := platformid.NewUUID()
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'identity_provider.deleted',1,'identity_provider',$2,$3)`, eventID, id, `{"identity_provider_id":"`+id+`"}`); err != nil {
return ErrUnavailable
}
return mapManagementError(tx.Commit(ctx))
}
func agentConfigJSON(agentID string) []byte {
if strings.TrimSpace(agentID) == "" {
return []byte(`{}`)
}
raw, _ := json.Marshal(map[string]string{"agent_id": strings.TrimSpace(agentID)})
return raw
}
// FindProviderBinding 按 (kind, uid) 反查门户账号;未绑定返回 ErrNotFound。
func (r *Repository) FindProviderBinding(ctx context.Context, kind, uid string) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.portal_user_provider_bindings WHERE provider_kind=$1 AND provider_uid=$2`, strings.ToLower(strings.TrimSpace(kind)), uid).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return id, mapRepositoryError(err)
}
// BindProvider 建立绑定。kind+uid 冲突(已被他人绑定)返回错误,账号重复绑定同一
// 平台(unique)冲突时先解绑旧绑定再写入,保证一个账号每平台至多一个绑定。
func (r *Repository) BindProvider(ctx context.Context, portalUserID, kind, uid string) error {
kind = strings.ToLower(strings.TrimSpace(kind))
tx, err := r.pool.Begin(ctx)
if err != nil {
return ErrUnavailable
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err = tx.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 AND provider_kind=$2`, portalUserID, kind); err != nil {
return ErrUnavailable
}
tag, err := tx.Exec(ctx, `INSERT INTO gateway.portal_user_provider_bindings(portal_user_id,provider_kind,provider_uid) VALUES($1,$2,$3) ON CONFLICT(provider_kind,provider_uid) DO NOTHING`, portalUserID, kind, uid)
if err != nil {
return ErrUnavailable
}
if tag.RowsAffected() == 0 {
return errors.New("该平台账号已被其他本系统账号绑定")
}
return mapManagementError(tx.Commit(ctx))
}
// UnbindProvider 解除绑定(仅本人)。
func (r *Repository) UnbindProvider(ctx context.Context, portalUserID, kind string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 AND provider_kind=$2`, portalUserID, strings.ToLower(strings.TrimSpace(kind)))
return mapRepositoryError(err)
}
// ListProviderBindings 返回账号的全部扫码绑定。
func (r *Repository) ListProviderBindings(ctx context.Context, portalUserID string) ([]ProviderBinding, error) {
rows, err := r.pool.Query(ctx, `SELECT provider_kind,provider_uid,created_at FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 ORDER BY provider_kind`, portalUserID)
if err != nil {
return nil, mapRepositoryError(err)
}
defer rows.Close()
items := []ProviderBinding{}
for rows.Next() {
var item ProviderBinding
if err := rows.Scan(&item.Kind, &item.ProviderUID, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, mapRepositoryError(rows.Err())
}
+205
View File
@@ -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)
}
}
}