diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 2959a07..65541e1 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -322,7 +322,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | | 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | -| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | | 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | @@ -864,19 +864,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 注册页面 -**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** +**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。** - 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 - 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 -- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。 +- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。 - 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 - 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 -- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 +- 旧版独立 `invite_code` 字段仅保留协议兼容,新客户端不得再展示独立邀请码输入框。 - 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 - 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 -- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 -- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 -- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 +- 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。 +- 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `tel credential`,同时写入邀请码推荐使用关系。 +- 提交注册:`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 - 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 - 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 - 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 @@ -884,19 +885,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 用户名方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}} ``` 手机号验证方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} ``` 仅手机号注册方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} ``` - 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 - 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 @@ -1002,6 +1003,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` | | `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 邀请码 | `{"code":"AbCdE"}` | `{"verification_mode":"invite_code","invite_accepted":true}` | | `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | | `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | diff --git a/docs/IM-注册、邀请码与认证数据模型改造需求文档.md b/docs/IM-注册、邀请码与认证数据模型改造需求文档.md index cb0e7ca..7270920 100644 --- a/docs/IM-注册、邀请码与认证数据模型改造需求文档.md +++ b/docs/IM-注册、邀请码与认证数据模型改造需求文档.md @@ -1,5 +1,16 @@ # IM 注册、邀请码与认证数据模型改造需求文档 +## 0. 统一验证码输入变更(2026-09-16) + +本节为最新规则,覆盖本文后续章节中“独立邀请码输入框”“短信开启时邀请码不能替代验证码”等旧描述: + +1. 注册页面取消独立邀请码输入框,邀请码与短信验证码共用同一个输入框。 +2. 5 位大小写英文字母按邀请码处理,6 位数字按短信验证码处理,其他格式拒绝。 +3. 有效邀请码可以作为注册放行凭证;注册成功后写入 `im_registration_invite_uses`,建立邀请码与新用户的推荐使用关系。 +4. 短信验证码注册会建立已完成的 `tel credential`;邀请码注册只保存手机号,不把手机号标记为短信已验证。 +5. 新客户端不再提交独立 `invite_code`,而是在 `{acc}.cred[].resp` 中提交用户在统一输入框填写的 5 位邀请码。旧 `invite_code` 字段仅保留向后兼容。 +6. `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 输入邀请码时返回 `{"verification_mode":"invite_code","invite_accepted":true}`;输入短信验证码时继续返回一次性 `verification_token`。 + > 版本:V1.0 评审稿 > 编写日期:2026-08-18 > 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL diff --git a/server/datamodel.go b/server/datamodel.go index f9ba13a..cf887c7 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -150,7 +150,8 @@ type MsgClientAcc struct { Username string `json:"username,omitempty"` // Registration mobile number. An 11-digit mainland China number takes precedence over Username. Phone string `json:"phone,omitempty"` - // Optional case-sensitive registration invitation code. + // Deprecated compatibility field. New clients put a five-letter invitation + // code in the shared verification input and submit it as the tel cred response. InviteCode string `json:"invite_code,omitempty"` // Authenticate session with the newly created account Login bool `json:"login,omitempty"` diff --git a/server/im_http.go b/server/im_http.go index 97ae142..595a9dc 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1073,7 +1073,8 @@ func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) { return } code := strings.TrimSpace(body.Code) - if len(code) != imVerificationCodeLength { + inviteCandidate := isValidIMInviteCode(code) + if len(code) != imVerificationCodeLength && !inviteCandidate { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"}) return } @@ -1102,6 +1103,30 @@ func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } + if inviteCandidate { + if vc.Scene != "register" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"}) + return + } + if err := validateRegistrationInvite(tenant.ID, code, now); err != nil { + nextState := types.VerificationStatePending + if vc.Attempts+1 >= vc.MaxAttempts { + nextState = types.VerificationStateLocked + } + _ = store.Verifications.Fail(tenant.ID, challengeID, nextState) + if nextState == types.VerificationStateLocked { + writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil) + return + } + writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "verification_mode": "invite_code", + "invite_accepted": true, + }) + return + } if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) { nextState := types.VerificationStatePending if vc.Attempts+1 >= vc.MaxAttempts { diff --git a/server/im_http_test.go b/server/im_http_test.go index b261b9a..66d506b 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -2299,6 +2299,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) { func TestServeIMVerificationCodeVerifySuccess(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications + apiKey := makeTestAPIKey(t) store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", @@ -2324,7 +2325,7 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", bytes.NewBufferString(`{"code":"123456"}`)) - req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-MeetingIM-APIKey", apiKey) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() @@ -2343,6 +2344,45 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) { } } +func TestServeIMVerificationCodeVerifyAcceptsRegistrationInvite(t *testing.T) { + oldTenants := store.Tenants + oldVerifications := store.Verifications + oldInvites := store.RegistrationInvites + store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive}} + verifications := &testVerificationStore{get: &types.VerificationCode{ + TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash", + State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), + }} + store.Verifications = verifications + store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{ + Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().UTC().Add(time.Hour), + }} + defer func() { + store.Tenants = oldTenants + store.Verifications = oldVerifications + store.RegistrationInvites = oldInvites + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", + bytes.NewBufferString(`{"code":"AbCdE"}`)) + req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-Tenant-Code", "acme") + rec := httptest.NewRecorder() + + serveIMVerificationCodeVerify(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["verification_mode"] != "invite_code" || data["invite_accepted"] != true { + t.Fatalf("unexpected invite response: %#v", data) + } + if verifications.verified { + t.Fatal("invite code must not mark the phone challenge as SMS verified") + } +} + func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications diff --git a/server/session_test.go b/server/session_test.go index e09b939..fde068f 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -1321,6 +1321,8 @@ func TestDispatchAccNew(t *testing.T) { oldSecurity := store.Security oldVerifications := store.Verifications oldInvites := store.RegistrationInvites + oldValidators := globals.validators + globals.validators = map[string]credValidator{"tel": {}} store.Store = ss store.Users = uu store.Security = &testSecurityStore{version: 1} @@ -1329,12 +1331,15 @@ func TestDispatchAccNew(t *testing.T) { Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour), }} store.RegistrationInvites = invites + setRegistrationSMSConfigForTest(true) defer func() { store.Store = nil store.Users = nil store.Security = oldSecurity store.Verifications = oldVerifications store.RegistrationInvites = oldInvites + globals.validators = oldValidators + resetRegistrationSMSConfigForTest() ctrl.Finish() }() @@ -1347,10 +1352,14 @@ func TestDispatchAccNew(t *testing.T) { State: types.StateOK, } ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) + ss.EXPECT().GetValidator("tel").Return(registrationTestValidator{}) // This login is available. aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil) uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn( func(_ types.TenantID, user *types.User, private any) (*types.User, error) { + if user.Phone != "+8613800138000" { + t.Fatalf("unexpected registration phone: %q", user.Phone) + } user.SetUid(uid) return user, nil }) @@ -1378,13 +1387,17 @@ func TestDispatchAccNew(t *testing.T) { public := "public name" msg := &ClientComMessage{ Acc: &MsgClientAcc{ - Id: "123", - User: "newXYZ", - Scheme: "basic", - Username: "alice", - InviteCode: "AbCdE", - Secret: []byte(secret), - Desc: &MsgSetDesc{Public: public}, + Id: "123", + User: "newXYZ", + Scheme: "basic", + Username: "alice", + Phone: "13800138000", + Secret: []byte(secret), + Cred: []MsgCredClient{{ + Method: "tel", Value: "+8613800138000", Response: "AbCdE", + Params: map[string]any{"challenge_id": "challenge-1"}, + }}, + Desc: &MsgSetDesc{Public: public}, }, } diff --git a/server/user.go b/server/user.go index c25857b..789bd4f 100644 --- a/server/user.go +++ b/server/user.go @@ -118,11 +118,12 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { // Pre-check credentials for validity. We don't know user's access level // consequently cannot check presence of required credentials. Must do that later. - creds := normalizeCredentials(msg.Acc.Cred, true) + rawCreds := normalizeCredentials(msg.Acc.Cred, true) + creds := make([]MsgCredClient, 0, len(rawCreds)) var registrationPhone string var registrationTelCredential bool - for i := range creds { - cr := &creds[i] + for i := range rawCreds { + cr := &rawCreds[i] vld := store.Store.GetValidator(cr.Method) if vld == nil || !vld.IsInitialized() { s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, @@ -138,6 +139,16 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { } if cr.Method == "tel" { phone := strings.TrimSpace(strings.TrimPrefix(normalized, "tel:")) + if credentialInviteCode := strings.TrimSpace(cr.Response); isValidIMInviteCode(credentialInviteCode) { + if inviteCode != "" && inviteCode != credentialInviteCode { + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"})) + return + } + inviteCode = credentialInviteCode + registrationPhone = phone + continue + } challengeID, _ := cr.Params["challenge_id"].(string) if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" || (registrationPhone != "" && registrationPhone != phone) { @@ -148,6 +159,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { registrationPhone = phone registrationTelCredential = true } + creds = append(creds, *cr) } if !phoneRegistration && registrationTelCredential { logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid) @@ -155,13 +167,13 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"})) return } - if phoneRegistration && registrationTelCredential && registrationPhone != phone { + if phoneRegistration && registrationPhone != "" && registrationPhone != phone { s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"})) return } smsEnabled := registrationSMSEnabled(time.Now().UTC()) - if phoneRegistration && smsEnabled && !registrationTelCredential { + if phoneRegistration && smsEnabled && !registrationTelCredential && inviteCode == "" { logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid) s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"})) @@ -240,10 +252,14 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { } // When creating an account, the user must provide all required credentials. // If any are missing, reject the request. - if len(creds) < len(globals.authValidators[rec.AuthLevel]) { + requiredValidators := globals.authValidators[rec.AuthLevel] + if inviteCode != "" { + requiredValidators = slices.DeleteFunc(slices.Clone(requiredValidators), func(method string) bool { return method == "tel" }) + } + if len(creds) < len(requiredValidators) { logs.Warn.Println("create user: missing credentials; have:", creds, "want:", - globals.authValidators[rec.AuthLevel], s.sid) - _, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], credentialMethods(creds)) + requiredValidators, s.sid) + _, missing, _ := stringSliceDelta(requiredValidators, credentialMethods(creds)) s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, map[string]any{"creds": missing})) @@ -310,7 +326,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { var reply *ServerComMessage if msg.Acc.Login { // Process user's login request. - _, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated) + _, missing, _ := stringSliceDelta(requiredValidators, validated) reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Acc.Scheme, account) } else { // Not using the new account for logging in. diff --git a/server/user_registration_test.go b/server/user_registration_test.go index 2c25fba..daab4b3 100644 --- a/server/user_registration_test.go +++ b/server/user_registration_test.go @@ -9,6 +9,26 @@ import ( "github.com/tinode/chat/server/store/types" ) +type registrationTestValidator struct{} + +func (registrationTestValidator) Init(string) error { return nil } +func (registrationTestValidator) IsInitialized() bool { return true } +func (registrationTestValidator) PreCheck(value string, _ map[string]any) (string, error) { + return "tel:" + value, nil +} +func (registrationTestValidator) Request(types.TenantID, types.Uid, string, string, string, []byte) (bool, error) { + return true, nil +} +func (registrationTestValidator) ResetSecret(types.TenantID, string, string, string, []byte, map[string]any) error { + return nil +} +func (registrationTestValidator) Check(types.TenantID, types.Uid, string) (string, error) { + return "", nil +} +func (registrationTestValidator) Remove(types.TenantID, types.Uid, string) error { return nil } +func (registrationTestValidator) Delete(types.TenantID, types.Uid) error { return nil } +func (registrationTestValidator) TempAuthScheme() (string, error) { return "code", nil } + func TestResolveRegistrationAccount(t *testing.T) { tests := []struct { name string