fix: load uid key from manager config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
Reference in New Issue
Block a user