feat: merge registration invite and verification input
This commit is contained in:
+12
-10
@@ -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>
|
||||
|
||||
<img src="images/注册页面.png" alt="注册页面" width="360">
|
||||
|
||||
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 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"}` |
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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"`
|
||||
|
||||
+26
-1
@@ -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 {
|
||||
|
||||
+41
-1
@@ -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
|
||||
|
||||
+20
-7
@@ -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},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+25
-9
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user