fix: adjust phone registration username flow

This commit is contained in:
richjohnrambo
2026-08-20 12:08:55 +08:00
parent 73ddb2d9fa
commit 43e8af68d6
7 changed files with 202 additions and 78 deletions
+16 -9
View File
@@ -394,20 +394,21 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/注册页面.png" alt="注册页面" width="360">
**状态:注册原型和服务端注册分流均已完成;手机号非空时优先走手机号方式,服务端已接入 username、邀请码和短信开关逻辑。**
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
- 页面规则:手机号非空时以手机号方式为准并忽略用户名输入;只有手机号为空时才使用用户名。两个字段同时为空时拒绝注册。
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。手机号非空时忽略用户名输入,也不校验被忽略用户名的格式。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。
- 短信开关开启:手机号方式必须完成验证码验证,邀请码不能绕过短信验证;验证成功后用户名字段和手机号字段均保存规范化的 E.164 手机号。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号方式提供有效邀请码后可无验证码注册,规范化手机号同时写入 `username` 和 `phone`,但不建立已完成的 `tel credential`。
- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`secret` 使用最终选择值与密码。
- 服务端判定:`phone` 非空时忽略 `username`,并校验 `secret` 登录名与规范化手机号一致;`phone` 为空时使用 `username` 并校验 `^[a-z0-9]{1,10}$`。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `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。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
用户名方式:
@@ -419,9 +420,15 @@ X-Tinode-APIKey: <预置的普通 API Key>
手机号验证方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"ignored-if-present","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":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
- 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
仅手机号注册方式:
```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"}}]}}
```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
### 1.5 注册页面-倒计时.png
@@ -9,7 +9,7 @@
本文统一定义以下改造:
1. 注册账号支持“用户名或手机号”。
1. 注册账号支持“用户名、手机号、用户名加手机号”三种输入组合。
2. 手机号注册支持短信验证,后端固定补中国国家号 +86。
3. HotGo 后台增加注册短信发送开关。
4. 邀请码作为无短信验证时的注册放行凭证。
@@ -23,10 +23,10 @@
| --- | --- |
| 用户名输入 | 注册页面独立的用户名输入框 |
| 手机号输入 | 注册页面独立的手机号输入框,只接受 11 位 ASCII 数字 |
| 选择优先级 | 手机号非空时优先走手机号方式;只有手机号为空时才使用用户名 |
| 放行优先级 | 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭或未填写手机号时以邀请码为注册放行依据 |
| 已验证手机号 | 已成功消费 register 场景 verification_token,并建立已完成 tel credential 的手机号 |
| 用户名注册 | 不产生已验证 phone,依靠有效邀请码放行的注册 |
| 手机号注册 | 将规范化手机号同时写入 username 和 phone;短信验证状态由 tel credential 区分 |
| 用户名注册 | 保存用户填写的 username;未填写手机号时依靠有效邀请码放行,phone 为 NULL |
| 手机号注册 | 手机号字段非空的注册;保存用户填写的 username 和规范化 phone,未填写 username 时由服务端随机生成 username;短信验证状态由 tel credential 区分 |
| 注册短信开关 | 控制服务端是否真正调用短信服务商的全局后台配置 |
| 邀请码 | 租户内生成的 5 位大小写英文字母明文码,区分大小写、不可重复,有效期内可供多人使用、可撤销 |
@@ -38,16 +38,18 @@
| 字段 | 约束 | 说明 |
| --- | --- | --- |
| username | 非空、租户内唯一 | 主登录账号;普通用户名必须匹配 `^[a-z0-9]{1,10}$`,手机号方式保存 E.164 号码 |
| phone | 可空、租户内唯一 | 手机号方式的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 |
| username | 非空、租户内唯一 | 主登录账号;用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`。未填写 username 但填写手机号时,由服务端随机生成 3 位小写字母 + 6 位数字的系统 username |
| phone | 可空、租户内唯一 | 用户填写手机号时的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 |
落库规则:
| 注册方式 | username | phone |
| --- | --- | --- |
| 手机号验证注册 | +8613800138000 | +8613800138000 |
| 用户名加手机号,验证码注册 | alice | +8613800138000 |
| 仅手机号,验证码注册 | abc123456 | +8613800138000 |
| 普通用户名加邀请码注册 | alice | NULL |
| 短信关闭,手机号加邀请码注册 | +8613800138000 | +8613800138000 |
| 用户名加手机号,短信关闭邀请码注册 | alice | +8613800138000 |
| 仅手机号,短信关闭邀请码注册 | abc123456 | +8613800138000 |
普通用户名注册规则:
@@ -55,21 +57,22 @@
2. 只允许 ASCII 小写英文字母 `a-z` 和数字 `0-9`,允许纯字母、字母数字组合或纯数字。
3. 大写字母、中文、空格、点、下划线、冒号及其他符号均不允许;服务端校验原始输入,不通过静默转小写来放行不合规注册。
4. 前端按相同规则限制输入并提示,服务端必须再次校验,不能只依赖客户端。
5. 该规则只约束用户填写的普通用户名。手机号方式写入 `username` 的 E.164 号码,以及存量迁移生成的内部保留账号,不适用此正则,也不能作为普通用户名注册。
5. 该规则只约束用户填写的普通用户名。服务端随机生成的系统 username 固定为 9 位 `^[a-z]{3}[0-9]{6}$`,属于系统值例外;存量迁移生成的内部保留账号也不适用此正则,不能作为普通用户名注册。
### 3.2 选择优先级和手机号规范化
### 3.2 字段保存和手机号规范化
1. 页面分别展示用户名输入框和手机号输入框。
2. 手机号字段非空时,无论用户名是否填写,服务端都选择手机号方式并忽略用户名输入。
3. 只有手机号字段为空时,服务端才使用用户名方式。
2. 用户名字段非空时必须按原值校验并保存到 `im_users.username`,不能因为同时填写手机号而忽略用户名。
3. 手机号字段非空时必须校验并保存到 `im_users.phone`;手机号字段为空时 `phone` 保持 NULL。
4. 手机号字段使用 ^[0-9]{11}$ 校验;手机号非空但格式错误时直接拒绝,不能降级为用户名方式。
5. 发送短信和落库前,后端把 11 位数字规范化为 +86xxxxxxxxxxx。
6. 手机号方式将规范化手机号同时写入 username 和 phone。
7. 用户名方式只写 username,phone 保持 NULL。
6. 用户名和手机号同时填写时,`username` 保存用户填写的用户名,`phone` 保存规范化手机号。
7. 仅手机号注册且 username 为空时,服务端随机生成 `^[a-z]{3}[0-9]{6}$` 格式的 username,检查租户内唯一,冲突时重新生成;`phone` 保存规范化手机号。
8. 不提供国家和区号选择,本期固定中国 +86。
9. 登录页面仍使用统一的“用户名/手机号”输入框;先精确查询 username,未命中时将 11 位输入补 +86 后查询 phone。
10. 注册、绑定手机号时必须检查 username 和 phone 两个命名空间,防止同一输入命中两个用户。
11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;手机号字段非空时忽略用户名输入,不因同时填写的用户名格式错误而拒绝手机号注册。
11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;用户名字段非空且格式错误时必须拒绝,不能因手机号合法而跳过用户名校验。
12. 注册创建公开资料时,账户昵称暂时默认等于最终保存的 username;客户端未传 `desc.public.fn` 或传入值为空时,服务端使用最终 username 填充昵称。
## 4. 注册放行规则
@@ -77,12 +80,14 @@
| 用户名字段 | 手机号字段 | 短信开关 | 有效验证票据 | 邀请码 | 服务端结果 |
| --- | --- | --- | --- | --- | --- |
| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,按用户名注册,phone=NULL |
| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,保存 username,phone=NULL |
| 已填 | 空 | 任意 | 不适用 | 未填或无效 | 拒绝 |
| 任意 | 11 位 | 开 | 有 | 未填 | 放行,按手机号注册,手机号覆盖用户名输入 |
| 任意 | 11 位 | 开 | 有 | 有效 | 放行,按手机号注册并记录邀请码使用关系 |
| 已填 | 11 位 | 开 | 有 | 未填 | 放行,保存 username 和 phone |
| 已填 | 11 位 | 开 | 有 | 有效 | 放行,保存 username 和 phone,并记录邀请码使用关系 |
| 空 | 11 位 | 开 | 有 | 未填或有效 | 放行,随机生成 username,phone 写手机号 |
| 任意 | 11 位 | 开 | 无 | 任意 | 拒绝,邀请码不能绕过已开启的短信验证 |
| 任意 | 11 位 | 关 | 无 | 有效 | 放行,按手机号注册,username 和 phone 均写手机号 |
| 已填 | 11 位 | 关 | 无 | 有效 | 放行,保存 username 和 phone |
| 空 | 11 位 | 关 | 无 | 有效 | 放行,随机生成 username,phone 写手机号 |
| 任意 | 11 位 | 关 | 无 | 未填或无效 | 拒绝 |
| 已填 | 格式错误 | 任意 | 任意 | 任意 | 拒绝,不能降级为用户名方式 |
| 空 | 空 | 任意 | 任意 | 任意 | 拒绝,必须填写用户名或手机号 |
@@ -92,15 +97,15 @@
1. 有效 verification_token 的优先级高于当前短信开关。开关关闭不能让已验证票据失效。
2. 短信开关开启时,邀请码不能绕过手机号验证。
3. 用户主动填写邀请码后,服务端必须校验;不能在邀请码无效时静默忽略。
4. 手机号验证注册时邀请码可不填;用户名方式以及短信关闭的手机号方式必须填写有效邀请码。
4. 手机号字段非空且短信开关开启时,以验证码为准,邀请码可不填;手机号字段为空或短信开关关闭时,必须填写有效邀请码。
5. 短信关闭时邀请码替代短信验证作为注册授权,但不建立已完成的 tel credential。
6. 用户创建、租户用户配额校验、邀请码使用记录、手机号票据消费应原子完成。
### 4.2 客户端页面行为
1. 页面分别显示“请输入用户名”和“请输入手机号”。
2. 手机号非空时以手机号方式为准,用户名输入不参与注册。
3. 手机号为空时才使用用户名方式。
2. 用户名和手机号是独立字段;两个都填写时都参与注册,用户名保存为主登录账号,手机号保存为绑定号码。
3. 手机号为空时不发送短信,使用用户名加邀请码方式注册。
4. 手机号为 11 位数字时允许点击“获取验证码”。
5. 用户名方式不发送短信,验证码字段不作为提交项。
6. 邀请码字段始终显示,文案为“请输入邀请码(非必填)”。
@@ -265,9 +270,11 @@ POST /v1/im/auth/verification-codes
用户名方式时,phone 为空,secret 使用 username:password,cred 为空且 invite_code 必须有效。
手机号方式时,phone 非空,secret 使用 phone:password,服务端忽略同时提交的 username。短信验证成功时 cred 传递一次性 verification_token;短信关闭时 cred 为空,必须提供有效 invite_code。两种手机号路径都把规范化手机号同时保存到 username 和 phone;短信关闭路径不建立已完成的 tel credential。
手机号字段非空时,短信开关开启则必须通过验证码放行,cred 传递一次性 verification_token;短信开关关闭则走邀请码放行,cred 可为空且 invite_code 必须有效。用户名字段非空时保存用户名并使用 username:password 生成 secret;用户名字段为空时由服务端随机生成 3 位小写字母 + 6 位数字的 username,客户端提交 secret 时仍使用 phone:password。短信关闭路径不建立已完成的 tel credential。
服务端必须校验 secret 中的登录名与最终选择值一致:phone 非空时必须匹配规范化手机号,phone 为空时必须匹配 username。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。
服务端必须校验 secret 中的登录名与客户端可确定的注册账号一致:username 非空时必须匹配 username;username 为空且 phone 非空时必须匹配规范化手机号。服务端生成的随机 username 在账号创建后返回给客户端,并作为后续登录主账号。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。
账户昵称暂时与最终 username 保持一致:服务端创建用户资料时以最终 username 写入 `desc.public.fn` 或资料表昵称;仅手机号注册时昵称等于随机生成的 username,不使用手机号作为昵称。
客户端不得提交 phone_account、sms_enabled、skip_verification 等服务端决策字段。
@@ -451,7 +458,7 @@ CREATE TABLE im_registration_invite_uses (
### 12.1 邀请码手机号注册风险
短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号同时写入 username 和 phone。
短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号写入 phone;如果用户同时填写 username,username 保存用户填写值,否则由服务端随机生成 3 位小写字母 + 6 位数字的 username。
为避免把“已填写手机号”和“已验证手机号”混为一谈:
@@ -468,22 +475,25 @@ CREATE TABLE im_registration_invite_uses (
### 13.1 注册
1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 和 phone 均为规范化手机号。
1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。
2. 只填写手机号,短信开启但没有验证票据,即使邀请码有效也不能注册。
3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 和 phone 均为规范化手机号。
3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。
4. 只填写手机号,短信关闭且不填邀请码不能注册。
5. 只填写用户名并提供有效邀请码可以注册,phone=NULL。
6. 只填写用户名但不填邀请码不能注册。
7. 用户名和手机号同时填写时走手机号方式,username 最终保存规范化手机号。
8. 手机号非空但格式错误时拒绝,不能回退到用户名方式。
9. 用户名和手机号同时为空时拒绝注册。
10. 过期、已撤销邀请码均不能注册。
11. 同一有效邀请码并发注册时可以有多个不同用户成功,每个用户分别写入一条使用记录,邀请码保持 active。
12. 用户名账号 phone=NULL 时,短信找回接口不能重置密码。
13. 管理员可以重置 phone=NULL 用户的密码,重置后旧 Token 和 Session 全部失效。
14. 普通用户名 `alice01`、`a`、`1234567890` 可以进入注册校验。
15. 普通用户名 `Alice`、`alice_1`、`alice.1`、`中文`、空字符串或超过 10 位时返回 `USERNAME_INVALID_FORMAT`。
16. 用户名格式不合法但手机号合法且非空时,仍按手机号方式注册并忽略用户名输入。
7. 用户名和手机号同时填写且短信开启时,验证码验证成功后可以注册,username 保存用户填写的用户名,phone 保存规范化手机号。
8. 用户名和手机号同时填写且短信关闭时,邀请码有效即可注册,username 保存用户填写的用户名,phone 保存规范化手机号。
9. 手机号非空但格式错误时拒绝,不能回退到用户名方式。
10. 用户名和手机号同时为空时拒绝注册。
11. 过期、已撤销邀请码均不能注册。
12. 同一有效邀请码并发注册时可以有多个不同用户成功,每个用户分别写入一条使用记录,邀请码保持 active。
13. 用户名账号 phone=NULL 时,短信找回接口不能重置密码。
14. 管理员可以重置 phone=NULL 用户的密码,重置后旧 Token 和 Session 全部失效。
15. 普通用户名 `alice01`、`a`、`1234567890` 可以进入注册校验。
16. 普通用户名 `Alice`、`alice_1`、`alice.1`、`中文`、空字符串或超过 10 位时返回 `USERNAME_INVALID_FORMAT`。
17. 用户名格式不合法但手机号合法且非空时仍拒绝,不能忽略用户名输入。
18. 仅手机号注册时,随机 username 必须在租户内唯一;发生唯一索引冲突时服务端重新生成并重试。
19. 注册成功后的账户昵称等于最终 username:填写用户名时昵称等于该用户名;仅手机号注册时昵称等于随机 username。
### 13.2 短信开关
@@ -515,11 +525,11 @@ CREATE TABLE im_registration_invite_uses (
本方案的核心决策为:
1. 注册页的用户名和手机号是两个独立输入框,手机号非空时优先使用手机号方式。
1. 注册页的用户名和手机号是两个独立输入框,用户名非空时必须保存,手机号非空时必须保存。
2. username 是所有用户非空的主登录账号。
3. 手机号方式同时写 username 和 phone,用户名方式只写 username。
4. 用户名和手机号同时填写时忽略用户名输入,以手机号方式为准。
5. 短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册,但不建立已完成的 tel credential。
3. 用户名和手机号同时填写时,username 保存用户名,phone 保存规范化手机号;仅手机号注册时 username 由服务端随机生成 3 位小写字母 + 6 位数字。
4. 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭时以邀请码为注册放行依据。
5. 短信关闭时,手机号输入搭配有效邀请码可注册,但不建立已完成的 tel credential。
6. 邀请码为 5 位大小写英文字母,区分大小写,以明文落库并在租户内唯一;有效期内可供多人使用,支持单个、批量、自定义有效期、明文查询/导出、撤销和使用审计。
7. im_auth 只按 userid 保存 Basic 密码凭据,数据库删除 scheme、uname 和 basic: 前缀。
8. 协议层仍保留 basic/token scheme,不与数据库结构混淆。
-5
View File
@@ -491,11 +491,6 @@ func main() {
globals.immutableTagNS[name] = true
}
if len(vconf.Required) == 0 {
// Skip disabled validator.
continue
}
var reqLevels []auth.Level
for _, req := range vconf.Required {
lvl := auth.ParseAuthLevel(req)
+5 -2
View File
@@ -1336,8 +1336,11 @@ func TestDispatchAccNew(t *testing.T) {
if lvl := p["authlvl"].(string); lvl != auth.LevelAuth.String() {
t.Errorf("Auth level: expected '%s', found '%s'.", auth.LevelAuth.String(), lvl)
}
if desc := p["desc"].(*MsgTopicDesc); desc.Public.(string) != public {
t.Errorf("Public: expected '%s', found '%s'.", public, desc.Public.(string))
if username := p["username"].(string); username != "alice" {
t.Errorf("Username: expected 'alice', found '%s'.", username)
}
if desc := p["desc"].(*MsgTopicDesc); desc.Public.(map[string]any)["fn"] != "alice" {
t.Errorf("Public fn: expected 'alice', found '%v'.", desc.Public)
}
} else {
t.Error("Response must contain a ctrl message.")
+4
View File
@@ -84,6 +84,9 @@
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "Tinode <noreply@118im.com>",
"validation_templ": "server/templ/email-validation-en.templ",
"reset_secret_templ": "server/templ/email-password-reset-en.templ",
"debug_response": "123456",
"max_retries": 3
}
@@ -94,6 +97,7 @@
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "Tinode",
"universal_templ": "server/templ/sms-universal-en.templ",
"debug_response": "123456",
"max_retries": 3
}
+91 -15
View File
@@ -2,6 +2,7 @@ package main
import (
"container/heap"
crand "crypto/rand"
"math/rand"
"strings"
"time"
@@ -41,13 +42,17 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) {
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"}))
return
}
authSecret := msg.Acc.Secret
if account != secretAccount {
authSecret = registrationSecretForAccount(msg.Acc.Secret, account)
}
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
@@ -57,11 +62,24 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Check if login is unique and compliance with the policy (not too long or too short).
authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}
if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok {
logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
map[string]any{"what": "auth"}))
return
for attempts := 0; ; attempts++ {
if ok, err := authhdl.IsUnique(authCtx, authSecret); ok {
break
} else if account != secretAccount && err == types.ErrDuplicate && attempts < 8 {
account, err = randomRegistrationUsername()
if err != nil {
logs.Warn.Println("create user: failed to generate username", err, "sid=", s.sid)
s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp))
return
}
authSecret = registrationSecretForAccount(msg.Acc.Secret, account)
continue
} else {
logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
map[string]any{"what": "auth"}))
return
}
}
var user types.User
@@ -193,6 +211,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
private = msg.Acc.Desc.Private
}
}
user.Public = registrationPublicWithUsername(user.Public, user.Username)
// Create user record in the database.
if _, err := store.Users.Create(s.tenantID, &user, private); err != nil {
@@ -203,7 +222,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Add authentication record.
rec, err = authhdl.AddRecord(authCtx,
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, authSecret)
if err != nil {
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
@@ -299,6 +318,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
params := reply.Ctrl.Params.(map[string]any)
params["username"] = user.Username
params["desc"] = &MsgTopicDesc{
CreatedAt: &user.CreatedAt,
UpdatedAt: &user.UpdatedAt,
@@ -315,26 +335,43 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
pluginAccount(&user, plgActCreate)
}
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) {
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone, secretAccount string, phoneRegistration bool, err error) {
if phone != "" {
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
return "", "", false, types.ErrPolicy
return "", "", "", false, types.ErrPolicy
}
canonicalPhone, err = normalizeIMPhone(phone, "CN")
if err != nil {
return "", "", false, err
return "", "", "", false, err
}
return canonicalPhone, canonicalPhone, true, nil
if username == "" {
account, err = randomRegistrationUsername()
if err != nil {
return "", "", "", false, err
}
return account, canonicalPhone, canonicalPhone, true, nil
}
if err = validateRegistrationUsername(username); err != nil {
return "", "", "", false, err
}
return username, canonicalPhone, username, true, nil
}
if err = validateRegistrationUsername(username); err != nil {
return "", "", "", false, err
}
return username, "", username, false, nil
}
func validateRegistrationUsername(username string) error {
if username == "" || len(username) > 10 {
return "", "", false, types.ErrPolicy
return types.ErrPolicy
}
for i := range username {
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
return "", "", false, types.ErrPolicy
return types.ErrPolicy
}
}
return username, "", false, nil
return nil
}
func asciiRegistrationDigits(value string) bool {
@@ -351,6 +388,45 @@ func registrationSecretMatches(secret []byte, account string) bool {
return separator > 0 && string(secret[:separator]) == account
}
func registrationSecretForAccount(secret []byte, account string) []byte {
separator := strings.IndexByte(string(secret), ':')
if separator < 0 {
return secret
}
return []byte(account + string(secret[separator:]))
}
func randomRegistrationUsername() (string, error) {
const letters = "abcdefghijklmnopqrstuvwxyz"
const digits = "0123456789"
var raw [9]byte
if _, err := crand.Read(raw[:]); err != nil {
return "", err
}
out := make([]byte, 9)
for i := 0; i < 3; i++ {
out[i] = letters[int(raw[i])%len(letters)]
}
for i := 3; i < 9; i++ {
out[i] = digits[int(raw[i])%len(digits)]
}
return string(out), nil
}
func registrationPublicWithUsername(public any, username string) any {
if username == "" {
return public
}
if public == nil {
return map[string]any{"fn": username}
}
if values, ok := public.(map[string]any); ok {
values["fn"] = username
return values
}
return map[string]any{"fn": username}
}
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
if store.RegistrationInvites == nil {
return types.ErrInternal
+35 -6
View File
@@ -1,6 +1,7 @@
package main
import (
"regexp"
"testing"
"time"
@@ -15,12 +16,16 @@ func TestResolveRegistrationAccount(t *testing.T) {
phone string
wantAccount string
wantPhone string
wantSecret string
wantPhoneFlow bool
wantGenerated bool
wantErr bool
}{
{name: "username", username: "alice1", wantAccount: "alice1"},
{name: "numeric username", username: "12345", wantAccount: "12345"},
{name: "phone wins", username: "INVALID", phone: "13800138000", wantAccount: "+8613800138000", wantPhone: "+8613800138000", wantPhoneFlow: true},
{name: "username", username: "alice1", wantAccount: "alice1", wantSecret: "alice1"},
{name: "numeric username", username: "12345", wantAccount: "12345", wantSecret: "12345"},
{name: "username and phone", username: "alice1", phone: "13800138000", wantAccount: "alice1", wantPhone: "+8613800138000", wantSecret: "alice1", wantPhoneFlow: true},
{name: "phone only", phone: "13800138000", wantPhone: "+8613800138000", wantSecret: "+8613800138000", wantPhoneFlow: true, wantGenerated: true},
{name: "uppercase with phone rejected", username: "Alice", phone: "13800138000", wantErr: true},
{name: "uppercase rejected", username: "Alice", wantErr: true},
{name: "long username rejected", username: "abcdefghijk", wantErr: true},
{name: "malformed phone does not fall back", username: "alice", phone: "1380013800", wantErr: true},
@@ -28,9 +33,16 @@ func TestResolveRegistrationAccount(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
account, phone, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone)
if (err != nil) != tc.wantErr || account != tc.wantAccount || phone != tc.wantPhone || phoneFlow != tc.wantPhoneFlow {
t.Fatalf("got account=%q phone=%q phoneFlow=%v err=%v", account, phone, phoneFlow, err)
account, phone, secretAccount, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone)
if (err != nil) != tc.wantErr || phone != tc.wantPhone || secretAccount != tc.wantSecret || phoneFlow != tc.wantPhoneFlow {
t.Fatalf("got account=%q phone=%q secret=%q phoneFlow=%v err=%v", account, phone, secretAccount, phoneFlow, err)
}
if tc.wantGenerated {
if !regexp.MustCompile(`^[a-z]{3}[0-9]{6}$`).MatchString(account) {
t.Fatalf("generated account %q does not match expected format", account)
}
} else if account != tc.wantAccount {
t.Fatalf("got account=%q want %q", account, tc.wantAccount)
}
})
}
@@ -45,6 +57,23 @@ func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) {
}
}
func TestRegistrationSecretForAccount(t *testing.T) {
if got := string(registrationSecretForAccount([]byte("+8613800138000:secret"), "abcxyz123456")); got != "abcxyz123456:secret" {
t.Fatalf("got %q", got)
}
}
func TestRegistrationPublicWithUsername(t *testing.T) {
if got := registrationPublicWithUsername(nil, "alice").(map[string]any)["fn"]; got != "alice" {
t.Fatalf("got fn=%v", got)
}
public := map[string]any{"fn": "old", "photo": "file1"}
got := registrationPublicWithUsername(public, "bob").(map[string]any)
if got["fn"] != "bob" || got["photo"] != "file1" {
t.Fatalf("got public=%v", got)
}
}
func TestValidateRegistrationInviteStates(t *testing.T) {
oldInvites := store.RegistrationInvites
defer func() { store.RegistrationInvites = oldInvites }()