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
+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)
}
}
}