diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 269744c..4ebabb9 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -394,20 +394,21 @@ X-Tinode-APIKey: <预置的普通 API Key> 注册页面 -**状态:注册原型和服务端注册分流均已完成;手机号非空时优先走手机号方式,服务端已接入 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 diff --git a/docs/IM-注册、邀请码与认证数据模型改造需求文档.md b/docs/IM-注册、邀请码与认证数据模型改造需求文档.md index 715403b..cb0e7ca 100644 --- a/docs/IM-注册、邀请码与认证数据模型改造需求文档.md +++ b/docs/IM-注册、邀请码与认证数据模型改造需求文档.md @@ -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,不与数据库结构混淆。 diff --git a/server/main.go b/server/main.go index 09c81a7..c3a3430 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/session_test.go b/server/session_test.go index cd7c0c0..dd73afa 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -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.") diff --git a/server/tinode.conf b/server/tinode.conf index 35a6d69..8ba5d18 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -84,6 +84,9 @@ "required": [], "config": { "host_url": "http://64.118.137.182:6060/", + "sender": "Tinode ", + "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 } diff --git a/server/user.go b/server/user.go index 359c9a2..3f4e450 100644 --- a/server/user.go +++ b/server/user.go @@ -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 diff --git a/server/user_registration_test.go b/server/user_registration_test.go index ab18f33..2c25fba 100644 --- a/server/user_registration_test.go +++ b/server/user_registration_test.go @@ -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 }()