From e063e7879ea2be7a1f103d27487ebea446c9befd Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 18 Aug 2026 01:23:18 +0800 Subject: [PATCH] fix: canonicalize phone numbers as E.164 --- docs/IM-前后端对接文档.md | 2 +- server/db/mysql/adapter.go | 16 +++++++++++++- server/db/mysql/user_identity.go | 23 ++++++++++++++++++++ server/db/mysql/user_identity_schema.go | 28 ++++++++++++++++++++++--- server/db/mysql/user_identity_test.go | 25 ++++++++++++++++++++++ server/validate/tel/validate.go | 2 +- server/validate/tel/validate_test.go | 13 ++++++++++++ 7 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 server/db/mysql/user_identity_test.go create mode 100644 server/validate/tel/validate_test.go diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index a677541..dc4290f 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -396,7 +396,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 - 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 - WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会把已校验手机号写入 `im_users.phone`,并保留验证凭证记录;用户标签已废弃,客户端设置 `tags` 会返回 `403 params.what=tags`。 -- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 +- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须统一使用 E.164 格式,如中国手机号 `+86138...`。服务端默认按 `CN` 解析未带国家码的号码并规范化,但客户端不得依赖该兼容行为;数据库 `im_users.phone` 始终保存带 `+` 和国家码的 E.164 值。 ```json {"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index c60ae97..b0129e4 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1684,7 +1684,12 @@ func (a *adapter) UserCreate(user *t.User) error { } var phone any if strings.TrimSpace(user.Phone) != "" { - phone = strings.TrimSpace(user.Phone) + canonicalPhone, err := normalizeUserPhone(user.Phone) + if err != nil { + return err + } + user.Phone = canonicalPhone + phone = canonicalPhone } if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)", decoded_uid, @@ -3891,6 +3896,15 @@ func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error { // 2.4 Insert new credential record. func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { var err error + if cred == nil { + return false, t.ErrMalformed + } + if cred.Method == "tel" { + cred.Value, err = normalizeUserPhone(cred.Value) + if err != nil { + return false, err + } + } ctx, cancel := a.getContextForTx() if cancel != nil { diff --git a/server/db/mysql/user_identity.go b/server/db/mysql/user_identity.go index 3cb6e4f..a12907c 100644 --- a/server/db/mysql/user_identity.go +++ b/server/db/mysql/user_identity.go @@ -3,6 +3,29 @@ package mysql +import ( + "strings" + + "github.com/nyaruka/phonenumbers" + t "github.com/tinode/chat/server/store/types" +) + +func normalizeUserPhone(value string) (string, error) { + value = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(value, "tel:"), "basic:")) + if value == "" { + return "", t.ErrMalformed + } + number, err := phonenumbers.Parse(value, "CN") + if err != nil || !phonenumbers.IsValidNumber(number) { + return "", t.ErrMalformed + } + numberType := phonenumbers.GetNumberType(number) + if numberType != phonenumbers.MOBILE && numberType != phonenumbers.FIXED_LINE_OR_MOBILE { + return "", t.ErrMalformed + } + return phonenumbers.Format(number, phonenumbers.E164), nil +} + func withoutUserTags(update map[string]any) map[string]any { if update == nil { return nil diff --git a/server/db/mysql/user_identity_schema.go b/server/db/mysql/user_identity_schema.go index c086d9d..2dda9dd 100644 --- a/server/db/mysql/user_identity_schema.go +++ b/server/db/mysql/user_identity_schema.go @@ -144,15 +144,37 @@ func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error if scoped { prefix = "tenant_id," } - if err = conn.SelectContext(ctx, &existing, "SELECT "+prefix+"id AS user_id,phone AS value FROM im_users WHERE phone IS NOT NULL AND phone<>''"); err != nil { + orderBy := "id" + if scoped { + orderBy = "tenant_id,id" + } + if err = conn.SelectContext(ctx, &existing, "SELECT "+prefix+"id AS user_id,phone AS value FROM im_users WHERE phone IS NOT NULL AND phone<>'' ORDER BY "+orderBy); err != nil { return err } for _, item := range existing { - seen[identityUniqueKey(item.TenantID, item.Value, scoped)] = true + canonical, err := normalizeUserPhone(item.Value) + if err != nil { + return fmt.Errorf("normalize existing phone for user %d: %w", item.UserID, err) + } + if canonical != item.Value { + query := "UPDATE im_users SET phone=? WHERE id=?" + args := []any{canonical, item.UserID} + if scoped { + query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=?" + args = []any{canonical, item.TenantID, item.UserID} + } + if _, err = conn.ExecContext(ctx, query, args...); err != nil { + return err + } + } + seen[identityUniqueKey(item.TenantID, canonical, scoped)] = true } updatedUsers := make(map[string]bool) for _, item := range candidates { - item.Value = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(item.Value, "tel:"), "basic:")) + item.Value, err = normalizeUserPhone(item.Value) + if err != nil { + return fmt.Errorf("normalize legacy phone for user %d: %w", item.UserID, err) + } userKey := identityUserKey(item.TenantID, item.UserID, scoped) valueKey := identityUniqueKey(item.TenantID, item.Value, scoped) if item.Value == "" || updatedUsers[userKey] || seen[valueKey] { diff --git a/server/db/mysql/user_identity_test.go b/server/db/mysql/user_identity_test.go new file mode 100644 index 0000000..a207842 --- /dev/null +++ b/server/db/mysql/user_identity_test.go @@ -0,0 +1,25 @@ +//go:build mysql +// +build mysql + +package mysql + +import "testing" + +func TestNormalizeUserPhone(t *testing.T) { + for input, want := range map[string]string{ + "13800138000": "+8613800138000", + "+8613800138000": "+8613800138000", + "tel:+17025550001": "+17025550001", + } { + got, err := normalizeUserPhone(input) + if err != nil { + t.Fatalf("normalize %q: %v", input, err) + } + if got != want { + t.Fatalf("normalize %q: got %q, want %q", input, got, want) + } + } + if _, err := normalizeUserPhone("12345"); err == nil { + t.Fatal("invalid phone unexpectedly accepted") + } +} diff --git a/server/validate/tel/validate.go b/server/validate/tel/validate.go index ebb5998..6a371dd 100644 --- a/server/validate/tel/validate.go +++ b/server/validate/tel/validate.go @@ -129,7 +129,7 @@ func (*validator) PreCheck(cred string, params map[string]any) (string, error) { } countryCode, ok := params["countryCode"].(string) if !ok { - countryCode = "US" + countryCode = "CN" } number, err := phonenumbers.Parse(cred, countryCode) if err != nil { diff --git a/server/validate/tel/validate_test.go b/server/validate/tel/validate_test.go new file mode 100644 index 0000000..42f3ee8 --- /dev/null +++ b/server/validate/tel/validate_test.go @@ -0,0 +1,13 @@ +package tel + +import "testing" + +func TestPreCheckDefaultsToChina(t *testing.T) { + got, err := (&validator{}).PreCheck("13800138000", map[string]any{}) + if err != nil { + t.Fatal(err) + } + if got != "tel:+8613800138000" { + t.Fatalf("got %q, want tel:+8613800138000", got) + } +}