fix: canonicalize phone numbers as E.164
This commit is contained in:
+1
-1
@@ -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`,必须带 `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`。
|
- 提交注册:前端校验密码一致和协议后,先调用 `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`。
|
- 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
|
```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":"用户昵称"}}}}
|
{"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":"用户昵称"}}}}
|
||||||
|
|||||||
@@ -1684,7 +1684,12 @@ func (a *adapter) UserCreate(user *t.User) error {
|
|||||||
}
|
}
|
||||||
var phone any
|
var phone any
|
||||||
if strings.TrimSpace(user.Phone) != "" {
|
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(?,?,?,?,?,?,?,?)",
|
if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)",
|
||||||
decoded_uid,
|
decoded_uid,
|
||||||
@@ -3891,6 +3896,15 @@ func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
|
|||||||
// 2.4 Insert new credential record.
|
// 2.4 Insert new credential record.
|
||||||
func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
|
func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
|
||||||
var err 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()
|
ctx, cancel := a.getContextForTx()
|
||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
|
|||||||
@@ -3,6 +3,29 @@
|
|||||||
|
|
||||||
package mysql
|
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 {
|
func withoutUserTags(update map[string]any) map[string]any {
|
||||||
if update == nil {
|
if update == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -144,15 +144,37 @@ func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error
|
|||||||
if scoped {
|
if scoped {
|
||||||
prefix = "tenant_id,"
|
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
|
return err
|
||||||
}
|
}
|
||||||
for _, item := range existing {
|
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)
|
updatedUsers := make(map[string]bool)
|
||||||
for _, item := range candidates {
|
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)
|
userKey := identityUserKey(item.TenantID, item.UserID, scoped)
|
||||||
valueKey := identityUniqueKey(item.TenantID, item.Value, scoped)
|
valueKey := identityUniqueKey(item.TenantID, item.Value, scoped)
|
||||||
if item.Value == "" || updatedUsers[userKey] || seen[valueKey] {
|
if item.Value == "" || updatedUsers[userKey] || seen[valueKey] {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,7 +129,7 @@ func (*validator) PreCheck(cred string, params map[string]any) (string, error) {
|
|||||||
}
|
}
|
||||||
countryCode, ok := params["countryCode"].(string)
|
countryCode, ok := params["countryCode"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
countryCode = "US"
|
countryCode = "CN"
|
||||||
}
|
}
|
||||||
number, err := phonenumbers.Parse(cred, countryCode)
|
number, err := phonenumbers.Parse(cred, countryCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user