diff --git a/README.md b/README.md index db170b6..118b474 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,19 @@ X-Request-ID: {uuid} 服务从 Redis 读取 `im:session:token:{sha256(token)}` 校验登录态。 +`usr...` 用户 ID 编解码优先从 IM Manager 内部配置接口读取 UID 加密 key,读不到时回退到 +`auth.uid_key` 或环境变量 `IM_WORKBENCH_UID_KEY`。测试环境需要配置: + +```text +IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com +IM_WORKBENCH_MANAGER_SERVICE_NAME=im-server +IM_WORKBENCH_MANAGER_SECRET={IM_CONFIG_API_SECRET} +``` + +默认读取 `GET /internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key`。 +如果后台配置使用了其他 group/key,可通过 `IM_WORKBENCH_MANAGER_UID_KEY_GROUP` 和 +`IM_WORKBENCH_MANAGER_UID_KEY_KEYS` 调整。 + HotGo 后台访问 `/internal/admin/workbench/*` 时需要配置同一组内部鉴权参数: ```text diff --git a/configs/workbench.local.json b/configs/workbench.local.json index 3107a2b..1a31fef 100644 --- a/configs/workbench.local.json +++ b/configs/workbench.local.json @@ -19,11 +19,24 @@ "auth": { "api_keys": [], "session_key_pattern": "im:session:token:%s", + "uid_key": "la6YsO+bNX/+XIkOqc5Svw==", "allow_dev_header_auth": true, "required_tenant_code": true, "required_api_key": false, "skip_session_in_dev": false, "internal_service_name": "hotgo-admin", "internal_secret": "local-workbench-secret" + }, + "manager": { + "base_url": "", + "service_name": "im-server", + "secret": "", + "uid_key_group": "im", + "uid_key_keys": [ + "uid_encryption_key", + "UID_ENCRYPTION_KEY", + "uid_key" + ], + "timeout_ms": 3000 } } diff --git a/configs/workbench.test.json b/configs/workbench.test.json index f38c1e0..04a0eb6 100644 --- a/configs/workbench.test.json +++ b/configs/workbench.test.json @@ -23,11 +23,24 @@ "auth": { "api_keys": [], "session_key_pattern": "im:session:token:%s", + "uid_key": "la6YsO+bNX/+XIkOqc5Svw==", "allow_dev_header_auth": false, "required_tenant_code": true, "required_api_key": true, "skip_session_in_dev": false, "internal_service_name": "hotgo-admin", "internal_secret": "" + }, + "manager": { + "base_url": "https://admin.118im.com", + "service_name": "im-server", + "secret": "", + "uid_key_group": "im", + "uid_key_keys": [ + "uid_encryption_key", + "UID_ENCRYPTION_KEY", + "uid_key" + ], + "timeout_ms": 3000 } } diff --git a/go.mod b/go.mod index aed48b3..38157d9 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.12.1 github.com/segmentio/kafka-go v0.4.51 + golang.org/x/crypto v0.53.0 ) require ( @@ -26,7 +27,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index bf48577..87b1d48 100644 --- a/go.sum +++ b/go.sum @@ -68,12 +68,14 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -81,16 +83,16 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/app/app.go b/internal/app/app.go index 610ac0e..af7794f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/im_workbench/internal/events" "git.118im.com/im_team/im_workbench/internal/httpapi" + "git.118im.com/im_team/im_workbench/internal/managerconfig" "git.118im.com/im_team/im_workbench/internal/store" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" @@ -22,6 +23,10 @@ type App struct { } func New(cfg *config.Config) (*App, error) { + uidKey := managerconfig.ResolveUIDKey(context.Background(), cfg.Manager, cfg.Auth.UIDKey) + if err := httpapi.SetUIDKey(uidKey); err != nil { + return nil, err + } st, err := store.Open(cfg.MySQL) if err != nil { return nil, err diff --git a/internal/config/config.go b/internal/config/config.go index 4159482..8516726 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { Redis RedisConfig `json:"redis"` Kafka KafkaConfig `json:"kafka"` Auth AuthConfig `json:"auth"` + Manager ManagerConfig `json:"manager"` ReadTimeout time.Duration `json:"-"` WriteTimeout time.Duration `json:"-"` } @@ -46,6 +47,7 @@ type KafkaConfig struct { type AuthConfig struct { APIKeys []string `json:"api_keys"` SessionKeyPattern string `json:"session_key_pattern"` + UIDKey string `json:"uid_key"` AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` RequiredTenantCode bool `json:"required_tenant_code"` RequiredAPIKey bool `json:"required_api_key"` @@ -55,6 +57,15 @@ type AuthConfig struct { InternalSecret string `json:"internal_secret"` } +type ManagerConfig struct { + BaseURL string `json:"base_url"` + ServiceName string `json:"service_name"` + Secret string `json:"secret"` + UIDKeyGroup string `json:"uid_key_group"` + UIDKeyKeys []string `json:"uid_key_keys"` + TimeoutMS int `json:"timeout_ms"` +} + func Load() (*Config, error) { cfg := defaultConfig() if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" { @@ -89,9 +100,16 @@ func defaultConfig() *Config { }, Auth: AuthConfig{ SessionKeyPattern: "im:session:token:%s", + UIDKey: "la6YsO+bNX/+XIkOqc5Svw==", RequiredTenantCode: true, RequiredAPIKey: true, }, + Manager: ManagerConfig{ + ServiceName: "im-server", + UIDKeyGroup: "im", + UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}, + TimeoutMS: 3000, + }, ReadTimeout: 8 * time.Second, WriteTimeout: 8 * time.Second, } @@ -110,26 +128,45 @@ func applyEnv(cfg *Config) { setString(&cfg.Kafka.SASL, "IM_WORKBENCH_KAFKA_SASL") setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID") setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN") + setString(&cfg.Auth.UIDKey, "IM_WORKBENCH_UID_KEY") setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR") setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME") setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET") + setString(&cfg.Manager.BaseURL, "IM_WORKBENCH_MANAGER_BASE_URL") + setString(&cfg.Manager.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME") + setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET") + setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP") if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET") } if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { setString(&cfg.Auth.InternalSecret, "IM_ADMIN_API_SECRET") } + if strings.TrimSpace(cfg.Manager.Secret) == "" { + setString(&cfg.Manager.Secret, "IM_CONFIG_API_SECRET") + } + if strings.TrimSpace(cfg.Manager.Secret) == "" { + setString(&cfg.Manager.Secret, "IM_INTERNAL_API_SECRET") + } if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" { cfg.Kafka.Brokers = splitCSV(brokers) } if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_API_KEYS")); keys != "" { cfg.Auth.APIKeys = splitCSV(keys) } + if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_UID_KEY_KEYS")); keys != "" { + cfg.Manager.UIDKeyKeys = splitCSV(keys) + } if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BATCH_SIZE")); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Kafka.BatchSize = parsed } } + if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_TIMEOUT_MS")); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Manager.TimeoutMS = parsed + } + } if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_REDIS_DB")); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Redis.DB = parsed diff --git a/internal/httpapi/uid.go b/internal/httpapi/uid.go index 878e524..874e7d5 100644 --- a/internal/httpapi/uid.go +++ b/internal/httpapi/uid.go @@ -7,10 +7,41 @@ import ( "fmt" "strconv" "strings" + "sync" + + "golang.org/x/crypto/xtea" ) type userID int64 +const defaultUIDKey = "la6YsO+bNX/+XIkOqc5Svw==" + +var ( + uidCodecMu sync.RWMutex + uidCipher *xtea.Cipher +) + +func init() { + if err := SetUIDKey(defaultUIDKey); err != nil { + panic(err) + } +} + +func SetUIDKey(encoded string) error { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return fmt.Errorf("invalid uid key: %w", err) + } + cipher, err := xtea.NewCipher(key) + if err != nil { + return fmt.Errorf("invalid uid key: %w", err) + } + uidCodecMu.Lock() + uidCipher = cipher + uidCodecMu.Unlock() + return nil +} + func (id *userID) UnmarshalJSON(raw []byte) error { value, err := parseUserIDJSON(raw) if err != nil { @@ -63,7 +94,7 @@ func parseUserID(value string) (int64, error) { if err != nil || len(decoded) < 8 { return 0, fmt.Errorf("invalid user id") } - return int64(binary.LittleEndian.Uint64(decoded[:8])), nil + return decryptUID(decoded[:8]), nil } return strconv.ParseInt(value, 10, 64) } @@ -72,11 +103,8 @@ func formatUserID(id int64) string { if id == 0 { return "" } - src := make([]byte, 8) - dst := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).EncodedLen(8)) - binary.LittleEndian.PutUint64(src, uint64(id)) - base64.URLEncoding.WithPadding(base64.NoPadding).Encode(dst, src) - return "usr" + string(dst) + encoded := encryptUID(id) + return "usr" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encoded) } func parseUserIDList(values []userID) []int64 { @@ -92,3 +120,23 @@ func parseUserIDList(values []userID) []int64 { } return items } + +func decryptUID(encoded []byte) int64 { + dst := make([]byte, 8) + uidCodecMu.RLock() + cipher := uidCipher + uidCodecMu.RUnlock() + cipher.Decrypt(dst, encoded[:8]) + return int64(binary.LittleEndian.Uint64(dst)) +} + +func encryptUID(id int64) []byte { + src := make([]byte, 8) + dst := make([]byte, 8) + binary.LittleEndian.PutUint64(src, uint64(id)) + uidCodecMu.RLock() + cipher := uidCipher + uidCodecMu.RUnlock() + cipher.Encrypt(dst, src) + return dst +} diff --git a/internal/httpapi/uid_test.go b/internal/httpapi/uid_test.go index b61b5e8..4a70eea 100644 --- a/internal/httpapi/uid_test.go +++ b/internal/httpapi/uid_test.go @@ -22,6 +22,23 @@ func TestUserIDJSONAcceptsIMUserID(t *testing.T) { } } +func TestUserIDCodecMatchesIMServer(t *testing.T) { + const ( + publicID = "usrOj2NWDNdlMU" + dbID = int64(1574592702383656960) + ) + got, err := parseUserID(publicID) + if err != nil { + t.Fatalf("parseUserID: %v", err) + } + if got != dbID { + t.Fatalf("parseUserID(%q)=%d, want %d", publicID, got, dbID) + } + if formatted := formatUserID(dbID); formatted != publicID { + t.Fatalf("formatUserID(%d)=%q, want %q", dbID, formatted, publicID) + } +} + func TestUserIDJSONRejectsNumericID(t *testing.T) { var id userID if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err == nil { diff --git a/internal/managerconfig/client.go b/internal/managerconfig/client.go new file mode 100644 index 0000000..a414b8d --- /dev/null +++ b/internal/managerconfig/client.go @@ -0,0 +1,170 @@ +package managerconfig + +import ( + "bytes" + "context" + "crypto/hmac" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/config" +) + +const maxResponseBody = 1 << 20 + +type Client struct { + cfg config.ManagerConfig + http *http.Client +} + +func ResolveUIDKey(ctx context.Context, cfg config.ManagerConfig, fallback string) string { + key, err := New(cfg).UIDKey(ctx) + if err != nil || strings.TrimSpace(key) == "" { + return fallback + } + return key +} + +func New(cfg config.ManagerConfig) *Client { + timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 3 * time.Second + } + return &Client{ + cfg: cfg, + http: &http.Client{Timeout: timeout}, + } +} + +func (c *Client) UIDKey(ctx context.Context) (string, error) { + baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/") + if baseURL == "" { + return "", fmt.Errorf("manager base url is empty") + } + if strings.TrimSpace(c.cfg.Secret) == "" { + return "", fmt.Errorf("manager secret is empty") + } + keys := cleanList(c.cfg.UIDKeyKeys) + if len(keys) == 0 { + keys = []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"} + } + group := strings.TrimSpace(c.cfg.UIDKeyGroup) + if group == "" { + group = "im" + } + values := url.Values{} + values.Set("keys", strings.Join(keys, ",")) + path := "/internal/im-config/third-party/" + url.PathEscape(group) + "?" + values.Encode() + req, err := c.newRequest(ctx, http.MethodGet, baseURL+path, nil) + if err != nil { + return "", err + } + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("manager status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return "", err + } + valuesMap, err := parseValues(body) + if err != nil { + return "", err + } + for _, key := range keys { + if value := strings.TrimSpace(valuesMap[key]); value != "" { + return value, nil + } + } + return "", fmt.Errorf("uid key is empty") +} + +func (c *Client) newRequest(ctx context.Context, method string, rawURL string, body []byte) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) + nonce, err := randomNonce() + if err != nil { + return nil, err + } + bodyHash := sha256.Sum256(body) + signingText := strings.Join([]string{ + method, + req.URL.RequestURI(), + hex.EncodeToString(bodyHash[:]), + timestamp, + nonce, + }, "\n") + mac := hmac.New(sha256.New, []byte(c.cfg.Secret)) + _, _ = mac.Write([]byte(signingText)) + serviceName := strings.TrimSpace(c.cfg.ServiceName) + if serviceName == "" { + serviceName = "im-server" + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Service-Name", serviceName) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) + return req, nil +} + +func parseValues(body []byte) (map[string]string, error) { + var envelope struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { + if envelope.Code != 0 { + return nil, fmt.Errorf("manager code %d", envelope.Code) + } + body = envelope.Data + } + var payload struct { + Values map[string]string `json:"values"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if payload.Values == nil { + return map[string]string{}, nil + } + return payload.Values, nil +} + +func cleanList(values []string) []string { + items := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + items = append(items, value) + } + return items +} + +func randomNonce() (string, error) { + var b [16]byte + if _, err := cryptorand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/managerconfig/client_test.go b/internal/managerconfig/client_test.go new file mode 100644 index 0000000..3be78d3 --- /dev/null +++ b/internal/managerconfig/client_test.go @@ -0,0 +1,83 @@ +package managerconfig + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.118im.com/im_team/im_workbench/internal/config" +) + +func TestResolveUIDKeyPrefersManager(t *testing.T) { + const secret = "test-secret" + const managerKey = "manager-key" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/im-config/third-party/im" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if r.URL.Query().Get("keys") != "uid_encryption_key,UID_ENCRYPTION_KEY,uid_key" { + t.Fatalf("unexpected keys %s", r.URL.Query().Get("keys")) + } + if r.Header.Get("X-Service-Name") != "im-server" { + t.Fatalf("unexpected service %s", r.Header.Get("X-Service-Name")) + } + if !validSignature(t, r, secret) { + t.Fatal("invalid signature") + } + _, _ = w.Write([]byte(`{"code":0,"data":{"values":{"uid_encryption_key":"manager-key"}}}`)) + })) + defer server.Close() + + got := ResolveUIDKey(context.Background(), config.ManagerConfig{ + BaseURL: server.URL, + ServiceName: "im-server", + Secret: secret, + UIDKeyGroup: "im", + UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}, + }, "fallback-key") + if got != managerKey { + t.Fatalf("ResolveUIDKey() = %q, want %q", got, managerKey) + } +} + +func TestResolveUIDKeyFallsBack(t *testing.T) { + got := ResolveUIDKey(context.Background(), config.ManagerConfig{}, "fallback-key") + if got != "fallback-key" { + t.Fatalf("ResolveUIDKey() = %q, want fallback", got) + } +} + +func TestParseValuesAcceptsUnwrappedResponse(t *testing.T) { + values, err := parseValues([]byte(`{"values":{"UID_ENCRYPTION_KEY":"key"}}`)) + if err != nil { + t.Fatal(err) + } + if values["UID_ENCRYPTION_KEY"] != "key" { + t.Fatalf("unexpected values %#v", values) + } +} + +func validSignature(t *testing.T, r *http.Request, secret string) bool { + t.Helper() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + bodyHash := sha256.Sum256(body) + signingText := strings.Join([]string{ + r.Method, + r.URL.RequestURI(), + hex.EncodeToString(bodyHash[:]), + r.Header.Get("X-Timestamp"), + r.Header.Get("X-Nonce"), + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + return hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) +}