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}}}` |
|
| 租户握手 `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"}}}` |
|
| 账号密码登录 | `{"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"}}}` |
|
| 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"}}}` |
|
| 手机号注册 | `{"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-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"}}}` |
|
| 租户不匹配 | `{"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">
|
<img src="images/注册页面.png" alt="注册页面" width="360">
|
||||||
|
|
||||||
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
|
**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
|
||||||
|
|
||||||
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
|
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
|
||||||
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
|
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
|
||||||
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
|
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
|
||||||
|
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
|
||||||
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
|
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
|
||||||
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
|
- 邀请码主数据位于 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`;生产接入短信供应商后不得继续使用固定验证码。
|
- 手机号方式:前端只接受 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。
|
- 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。
|
||||||
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。
|
- 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `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。
|
- 提交注册:`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
|
||||||
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
|
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
|
||||||
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
|
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
|
||||||
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
|
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
|
||||||
@@ -884,19 +885,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
用户名方式:
|
用户名方式:
|
||||||
|
|
||||||
```json
|
```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
|
```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
|
```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`。
|
- 成功:返回新 `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/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` 注册发码 | `{"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":"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/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/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"}` |
|
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# IM 注册、邀请码与认证数据模型改造需求文档
|
# 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 评审稿
|
> 版本:V1.0 评审稿
|
||||||
> 编写日期:2026-08-18
|
> 编写日期:2026-08-18
|
||||||
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
|
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
|
||||||
|
|||||||
+2
-1
@@ -150,7 +150,8 @@ type MsgClientAcc struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
|
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
|
||||||
Phone string `json:"phone,omitempty"`
|
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"`
|
InviteCode string `json:"invite_code,omitempty"`
|
||||||
// Authenticate session with the newly created account
|
// Authenticate session with the newly created account
|
||||||
Login bool `json:"login,omitempty"`
|
Login bool `json:"login,omitempty"`
|
||||||
|
|||||||
+26
-1
@@ -1073,7 +1073,8 @@ func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
code := strings.TrimSpace(body.Code)
|
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"})
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1102,6 +1103,30 @@ func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
||||||
return
|
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) {
|
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
|
||||||
nextState := types.VerificationStatePending
|
nextState := types.VerificationStatePending
|
||||||
if vc.Attempts+1 >= vc.MaxAttempts {
|
if vc.Attempts+1 >= vc.MaxAttempts {
|
||||||
|
|||||||
+41
-1
@@ -2299,6 +2299,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
|
|||||||
func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
|
func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
|
||||||
oldTenants := store.Tenants
|
oldTenants := store.Tenants
|
||||||
oldVerifications := store.Verifications
|
oldVerifications := store.Verifications
|
||||||
|
apiKey := makeTestAPIKey(t)
|
||||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||||
ID: 7,
|
ID: 7,
|
||||||
Code: "acme",
|
Code: "acme",
|
||||||
@@ -2324,7 +2325,7 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
|
||||||
bytes.NewBufferString(`{"code":"123456"}`))
|
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")
|
req.Header.Set("X-Tenant-Code", "acme")
|
||||||
rec := httptest.NewRecorder()
|
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) {
|
func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
|
||||||
oldTenants := store.Tenants
|
oldTenants := store.Tenants
|
||||||
oldVerifications := store.Verifications
|
oldVerifications := store.Verifications
|
||||||
|
|||||||
+20
-7
@@ -1321,6 +1321,8 @@ func TestDispatchAccNew(t *testing.T) {
|
|||||||
oldSecurity := store.Security
|
oldSecurity := store.Security
|
||||||
oldVerifications := store.Verifications
|
oldVerifications := store.Verifications
|
||||||
oldInvites := store.RegistrationInvites
|
oldInvites := store.RegistrationInvites
|
||||||
|
oldValidators := globals.validators
|
||||||
|
globals.validators = map[string]credValidator{"tel": {}}
|
||||||
store.Store = ss
|
store.Store = ss
|
||||||
store.Users = uu
|
store.Users = uu
|
||||||
store.Security = &testSecurityStore{version: 1}
|
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),
|
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
|
||||||
}}
|
}}
|
||||||
store.RegistrationInvites = invites
|
store.RegistrationInvites = invites
|
||||||
|
setRegistrationSMSConfigForTest(true)
|
||||||
defer func() {
|
defer func() {
|
||||||
store.Store = nil
|
store.Store = nil
|
||||||
store.Users = nil
|
store.Users = nil
|
||||||
store.Security = oldSecurity
|
store.Security = oldSecurity
|
||||||
store.Verifications = oldVerifications
|
store.Verifications = oldVerifications
|
||||||
store.RegistrationInvites = oldInvites
|
store.RegistrationInvites = oldInvites
|
||||||
|
globals.validators = oldValidators
|
||||||
|
resetRegistrationSMSConfigForTest()
|
||||||
ctrl.Finish()
|
ctrl.Finish()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -1347,10 +1352,14 @@ func TestDispatchAccNew(t *testing.T) {
|
|||||||
State: types.StateOK,
|
State: types.StateOK,
|
||||||
}
|
}
|
||||||
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
|
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
|
||||||
|
ss.EXPECT().GetValidator("tel").Return(registrationTestValidator{})
|
||||||
// This login is available.
|
// This login is available.
|
||||||
aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil)
|
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(
|
uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn(
|
||||||
func(_ types.TenantID, user *types.User, private any) (*types.User, error) {
|
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)
|
user.SetUid(uid)
|
||||||
return user, nil
|
return user, nil
|
||||||
})
|
})
|
||||||
@@ -1378,13 +1387,17 @@ func TestDispatchAccNew(t *testing.T) {
|
|||||||
public := "public name"
|
public := "public name"
|
||||||
msg := &ClientComMessage{
|
msg := &ClientComMessage{
|
||||||
Acc: &MsgClientAcc{
|
Acc: &MsgClientAcc{
|
||||||
Id: "123",
|
Id: "123",
|
||||||
User: "newXYZ",
|
User: "newXYZ",
|
||||||
Scheme: "basic",
|
Scheme: "basic",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
InviteCode: "AbCdE",
|
Phone: "13800138000",
|
||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
Desc: &MsgSetDesc{Public: public},
|
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
|
// Pre-check credentials for validity. We don't know user's access level
|
||||||
// consequently cannot check presence of required credentials. Must do that later.
|
// 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 registrationPhone string
|
||||||
var registrationTelCredential bool
|
var registrationTelCredential bool
|
||||||
for i := range creds {
|
for i := range rawCreds {
|
||||||
cr := &creds[i]
|
cr := &rawCreds[i]
|
||||||
vld := store.Store.GetValidator(cr.Method)
|
vld := store.Store.GetValidator(cr.Method)
|
||||||
if vld == nil || !vld.IsInitialized() {
|
if vld == nil || !vld.IsInitialized() {
|
||||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
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" {
|
if cr.Method == "tel" {
|
||||||
phone := strings.TrimSpace(strings.TrimPrefix(normalized, "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)
|
challengeID, _ := cr.Params["challenge_id"].(string)
|
||||||
if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" ||
|
if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" ||
|
||||||
(registrationPhone != "" && registrationPhone != phone) {
|
(registrationPhone != "" && registrationPhone != phone) {
|
||||||
@@ -148,6 +159,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
|||||||
registrationPhone = phone
|
registrationPhone = phone
|
||||||
registrationTelCredential = true
|
registrationTelCredential = true
|
||||||
}
|
}
|
||||||
|
creds = append(creds, *cr)
|
||||||
}
|
}
|
||||||
if !phoneRegistration && registrationTelCredential {
|
if !phoneRegistration && registrationTelCredential {
|
||||||
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
|
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"}))
|
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if phoneRegistration && registrationTelCredential && registrationPhone != phone {
|
if phoneRegistration && registrationPhone != "" && registrationPhone != phone {
|
||||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
|
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
smsEnabled := registrationSMSEnabled(time.Now().UTC())
|
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)
|
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,
|
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
|
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.
|
// When creating an account, the user must provide all required credentials.
|
||||||
// If any are missing, reject the request.
|
// 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:",
|
logs.Warn.Println("create user: missing credentials; have:", creds, "want:",
|
||||||
globals.authValidators[rec.AuthLevel], s.sid)
|
requiredValidators, s.sid)
|
||||||
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], credentialMethods(creds))
|
_, missing, _ := stringSliceDelta(requiredValidators, credentialMethods(creds))
|
||||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
||||||
map[string]any{"creds": missing}))
|
map[string]any{"creds": missing}))
|
||||||
|
|
||||||
@@ -310,7 +326,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
|||||||
var reply *ServerComMessage
|
var reply *ServerComMessage
|
||||||
if msg.Acc.Login {
|
if msg.Acc.Login {
|
||||||
// Process user's login request.
|
// 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)
|
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Acc.Scheme, account)
|
||||||
} else {
|
} else {
|
||||||
// Not using the new account for logging in.
|
// Not using the new account for logging in.
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ import (
|
|||||||
"github.com/tinode/chat/server/store/types"
|
"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) {
|
func TestResolveRegistrationAccount(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user