feat: merge registration invite and verification input

This commit is contained in:
richjohnrambo
2026-09-16 14:29:59 +08:00
parent 370e9878ed
commit 7461216c03
8 changed files with 157 additions and 29 deletions
+12 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+20
View File
@@ -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