feat: complete registration invitation and auth redesign
This commit is contained in:
+7
-1
@@ -30,4 +30,10 @@ admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
|
||||
2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理;
|
||||
3. 熟悉群消息扩散策略,如读扩散、写扩散;
|
||||
4. 理解多端同步、消息漫游、消息推送等 IM 核心机制;
|
||||
5. 有 OpenIM 或类似 IM 项目经验优先。
|
||||
5. 有 OpenIM 或类似 IM 项目经验优先。
|
||||
|
||||
1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行
|
||||
2.验证码验证作为开关 关闭的情况下的话后台不想第三方发送请求,前端正常ui的倒计时,但是收不到验证码
|
||||
3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册
|
||||
|
||||
用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段
|
||||
@@ -0,0 +1,747 @@
|
||||
# IM HotGo 注册短信开关与邀请码管理改造交接文档
|
||||
|
||||
> 文档状态:已按数据库直连方案实现,作为现状与后续 Kafka 演进说明
|
||||
> 对应实施顺序:注册改造计划第 4 项(HotGo 后台)
|
||||
> HotGo 项目:`/Users/youshunlai/go_workspace/im_manager`
|
||||
> IM 服务项目:`/Users/youshunlai/go_workspace/im_server`
|
||||
> 编写日期:2026-08-18
|
||||
|
||||
## 1. 文档用途
|
||||
|
||||
本文供新的开发会话直接接手 HotGo 项目使用。阅读本文后,应能明确:
|
||||
|
||||
1. 为什么要在 HotGo 中增加注册短信开关和邀请码管理。
|
||||
2. 哪些数据属于 HotGo 控制库,哪些数据属于 IM 租户业务库。
|
||||
3. HotGo 后端、前端、菜单权限、统一租户身份、审计和配置需要修改什么。
|
||||
4. HotGo 与 IM 如何通过租户业务库协作,以及后续如何迁移到 Kafka。
|
||||
5. 如何验证实现没有破坏租户隔离,也没有泄露邀请码明文。
|
||||
|
||||
本文指导 HotGo 项目改造。HotGo 负责邀请码生成和管理;IM 仍独占客户端注册、短信验证码和邀请码使用记录事务。
|
||||
|
||||
## 2. 开发前必须阅读
|
||||
|
||||
在修改 `/Users/youshunlai/go_workspace/im_manager` 前,先阅读以下文档:
|
||||
|
||||
1. `/Users/youshunlai/go_workspace/im_server/docs/IM-注册、邀请码与认证数据模型改造需求文档.md`
|
||||
2. `/Users/youshunlai/go_workspace/im_server/docs/IM-前后端对接文档.md` 中 1.4、1.5 注册章节
|
||||
3. `/Users/youshunlai/go_workspace/im_server/docs/IM-后台需求文档.md` 中租户权限、用户管理和审计章节
|
||||
4. `/Users/youshunlai/go_workspace/im_server/docs/IM-独立库多租户改造方案.md`
|
||||
|
||||
规则冲突时按以下优先级处理:
|
||||
|
||||
1. 本文针对 HotGo 的落地边界和接口约定。
|
||||
2. 《IM 注册、邀请码与认证数据模型改造需求文档》。
|
||||
3. 《IM 独立库多租户改造方案》。
|
||||
4. 其他较早的需求和设计文档。
|
||||
|
||||
`IM-服务端多租户改造方案.md` 已废弃,不得作为实现依据。当前多租户方案是“一套 IM 服务、一个 HotGo 控制库、每租户一个独立 IM 业务库”。
|
||||
|
||||
## 3. 需求前因后果
|
||||
|
||||
### 3.1 注册页面最终规则
|
||||
|
||||
客户端注册页面同时展示用户名、手机号、短信验证码、密码和邀请码,但用户名与手机号是二选一关系:
|
||||
|
||||
1. 手机号非空时,以手机号方式为准,忽略用户名输入。
|
||||
2. 手机号方式注册后,`im_users.username` 和 `im_users.phone` 都保存规范化后的 `+86` 手机号。
|
||||
3. 手机号为空时使用普通用户名,用户名必须匹配 `^[a-z0-9]{1,10}$`,此时 `phone=NULL`。
|
||||
4. 普通用户名方式不能自助找回密码,只能由企业管理员重置。
|
||||
5. 邀请码固定为 5 位 ASCII 大小写英文字母,区分大小写、租户内唯一,有效期内可供多人使用。
|
||||
|
||||
### 3.2 为什么需要短信开关
|
||||
|
||||
短信服务在不同部署环境中可能暂未启用,因此需要平台级开关:
|
||||
|
||||
| 场景 | 短信开关开启 | 短信开关关闭 |
|
||||
| --- | --- | --- |
|
||||
| 点击获取验证码 | IM 服务真实调用短信服务商 | IM 服务返回正常挑战和倒计时,但不调用服务商 |
|
||||
| 手机号注册 | 必须验证短信;邀请码不能绕过 | 必须提供有效邀请码,可无验证码注册 |
|
||||
| 用户名注册 | 不发送短信,必须提供有效邀请码 | 不发送短信,必须提供有效邀请码 |
|
||||
| 客户端是否知道开关 | 不直接暴露 | 不直接暴露 |
|
||||
|
||||
HotGo 只负责管理开关值。是否调用短信服务商、是否允许注册,全部由 IM 服务端判定。
|
||||
|
||||
### 3.3 为什么当前改为 HotGo 直接访问租户库
|
||||
|
||||
邀请码属于 IM 租户业务数据:
|
||||
|
||||
1. 每个租户有独立业务库。
|
||||
2. 邀请码批次和主表位于 `hotgo` 控制库,使用明细位于对应租户业务库。
|
||||
3. HotGo 邀请码管理直接访问控制库;密码重置等租户业务功能仍通过 `im_tenant.id` 和服务端保存的 DSN 选择租户库。
|
||||
4. 当前部署中 HotGo 到 IM 的内部 HTTP 地址不可达,继续维护 HMAC 管理 API 会增加一套没有必要的调用链。
|
||||
|
||||
因此当前采用“控制库主数据 + 租户库使用事实”的混合边界:HotGo 负责邀请码生成、查询、导出和撤销;IM 从控制库校验邀请码,并在租户库记录使用关系和 outbox 事件。两端各自只写自己负责的数据。
|
||||
|
||||
密码重置等需要 IM 立即感知的操作写入 `im_business_events_outbox`。当前 IM 轮询消费,后续改为 Kafka 时保留事件契约,只替换传输层。
|
||||
|
||||
## 4. 系统边界
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["HotGo 管理后台浏览器"] -->|HotGo 登录 Token| B["HotGo 后端"]
|
||||
B -->|读写全局开关| C[("hotgo.hg_sys_config")]
|
||||
B -->|读取管理员 tenant_id| D[("hotgo.im_tenant")]
|
||||
B -->|管理邀请码| G[("hotgo.im_registration_invite_batches")]
|
||||
B -->|管理邀请码| H[("hotgo.im_registration_invite_codes")]
|
||||
B -->|其他租户业务按服务端 DSN| F[("租户 IM 业务库")]
|
||||
F --> I["im_registration_invite_uses"]
|
||||
J["IM 注册与发码接口"] -->|短时缓存读取| C
|
||||
J -->|按 tenant_id 校验| H
|
||||
J -->|记录使用关系与事件| F
|
||||
B -->|写 security.password_reset| K["im_business_events_outbox"]
|
||||
F --> K
|
||||
K -->|当前轮询,后续 Kafka| J
|
||||
```
|
||||
|
||||
### 4.1 HotGo 负责
|
||||
|
||||
1. 在 `hg_sys_config` 保存全局注册短信开关。
|
||||
2. 只允许平台超级管理员修改开关。
|
||||
3. 为邀请码提供管理页面、表单、权限和审计入口。
|
||||
4. 从当前登录管理员身份解析可信的 IM `tenant_id`。
|
||||
5. 通过 `imtenant.DB()` 直连所选租户业务库,不接受浏览器传入 DSN。
|
||||
6. 对邀请码明文查询、查看和导出执行独立权限校验。
|
||||
|
||||
### 4.2 IM 服务负责
|
||||
|
||||
1. 读取并缓存短信开关。
|
||||
2. 实际发送或抑制短信。
|
||||
3. 注册时大小写敏感地校验邀请码,并记录邀请码与新用户的使用关系。
|
||||
4. 邀请码使用后不改变 active 状态。
|
||||
5. 消费 `security.password_reset` 事件并清退在线会话。
|
||||
6. 登录时以 `auth_version` 拒绝密码重置前签发的旧 Token。
|
||||
7. 消费 `registration.invite_used`,将租户库使用统计幂等投影到控制库。
|
||||
|
||||
### 4.3 明确禁止
|
||||
|
||||
1. 浏览器直接连接租户数据库。
|
||||
2. 浏览器通过 Header、Query 或 Body 自报可信 `tenant_id`。
|
||||
3. HotGo 后端根据浏览器传入的 `db_name` 或 `db_dsn` 连接数据库。
|
||||
4. HotGo 修改 `im_registration_invite_uses`;该表只允许 IM 注册事务写入。
|
||||
5. HotGo 绕过 outbox 直接修改在线会话状态。
|
||||
6. 在普通访问日志、错误日志、埋点或 URL 中记录完整邀请码。
|
||||
|
||||
## 5. 当前 HotGo 项目现状
|
||||
|
||||
目标仓库 `/Users/youshunlai/go_workspace/im_manager` 基于 GoFrame 2、Vue 3 和 Naive UI。
|
||||
|
||||
已确认的现状:
|
||||
|
||||
1. 通用配置后端位于 `server/api/admin/config`、`server/internal/controller/admin/sys/config.go`、`server/internal/logic/sys/config.go`。
|
||||
2. 通用配置前端位于 `web/src/views/system/config` 和 `web/src/api/sys/config.ts`。
|
||||
3. `hg_sys_config` 支持 `group/key/type/value/default_value`,前端不能通过通用接口新增不存在的配置项。
|
||||
4. `sys_switch` 字典实际定义为“开启=`1`、关闭=`2`”。
|
||||
5. HotGo 管理接口权限按“路由路径 + HTTP Method”使用 Casbin 校验,前端按钮权限也使用路由路径,例如 `hasPermission(['/notice/edit'])`。
|
||||
6. `hotgo.im_tenant` 是全系统唯一租户注册表,由 HotGo 维护,不存在另一套需要映射的“IM 租户”。
|
||||
7. 后台管理员通过 `hg_admin_member.tenant_id` 直接引用 `im_tenant.id`,登录身份必须携带该字段;不再使用独立租户绑定表。
|
||||
8. `hg_sys_log` 会保存 GET Query 和 POST Body;现有逻辑只自动掩码名称包含 `password` 的 POST 字段,邀请码查询参数会泄漏,必须补充脱敏。
|
||||
|
||||
## 6. 建议开发顺序
|
||||
|
||||
### 阶段 A:先完成 HotGo 基础边界
|
||||
|
||||
1. 增加全局短信开关配置种子和专用读写接口。
|
||||
2. 增加 `hg_admin_member.tenant_id` 外键与登录身份解析,统一使用 `im_tenant.id`。
|
||||
3. 增加租户库直连、Snowflake ID 和数据库事件写入能力。
|
||||
4. 增加邀请码相关菜单、路由权限和后台代理接口。
|
||||
5. 增加邀请码管理页面。
|
||||
6. 增加敏感字段日志脱敏和操作审计。
|
||||
|
||||
### 阶段 B:与 IM 服务联调
|
||||
|
||||
1. 核对 IM 租户库迁移已执行且表结构一致。
|
||||
2. 核对服务凭证、租户上下文和操作人上下文可被 IM 验证。
|
||||
3. 联调单个生成、批量生成、分页查询、撤销、详情和导出。
|
||||
4. 联调短信开关在 IM 服务缓存时间内生效。
|
||||
|
||||
IM 管理接口未完成时,可以通过接口 Client 抽象和测试 Stub 开发页面,但不得用 HotGo 直写租户数据库作为临时方案。
|
||||
|
||||
## 7. 全局注册短信开关
|
||||
|
||||
### 7.1 数据定义
|
||||
|
||||
在 HotGo 控制库 `hg_sys_config` 中增加且只保留一条配置:
|
||||
|
||||
```text
|
||||
group = im
|
||||
name = 注册短信发送
|
||||
type = bool
|
||||
key = register_sms_enabled
|
||||
value = 1
|
||||
default_value = 1
|
||||
status = 1
|
||||
is_default = 1
|
||||
```
|
||||
|
||||
建议的 MySQL 幂等种子:
|
||||
|
||||
```sql
|
||||
INSERT INTO hg_sys_config
|
||||
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10,
|
||||
'控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM hg_sys_config
|
||||
WHERE `group` = 'im' AND `key` = 'register_sms_enabled'
|
||||
);
|
||||
```
|
||||
|
||||
同时为 `(group, key)` 增加唯一约束。加索引前先检查并清理重复配置,不能让 IM 服务随机读到多条值。
|
||||
|
||||
```sql
|
||||
ALTER TABLE hg_sys_config
|
||||
ADD UNIQUE KEY uk_hg_sys_config_group_key (`group`, `key`);
|
||||
```
|
||||
|
||||
新装数据库种子、实际部署迁移及项目保留的 MySQL/PostgreSQL/SQLite 初始化脚本应按项目发布策略同步,不能只改一份示例 SQL。
|
||||
|
||||
### 7.2 `sys_switch` 映射陷阱
|
||||
|
||||
`sys_switch` 字典值与真实配置值不相同:
|
||||
|
||||
| UI 字典值 | UI 含义 | `hg_sys_config.value` |
|
||||
| --- | --- | --- |
|
||||
| `1` | 开启 | `1` |
|
||||
| `2` | 关闭 | `0` |
|
||||
|
||||
实现要求:
|
||||
|
||||
1. 页面加载时将后端 bool 值映射为字典值:`true -> 1`,`false -> 2`。
|
||||
2. 页面提交时反向映射:`1 -> true`,`2 -> false`。
|
||||
3. 不得把字典值 `2` 原样写入 bool 配置;GoFrame 的 bool 转换可能把非零值当成 true。
|
||||
4. 数据库只允许保存可解释为 `1/0` 的值。
|
||||
|
||||
### 7.3 后端接口
|
||||
|
||||
不要直接让页面复用通用 `/config/update`。新增专用接口,以便强制执行平台管理员校验:
|
||||
|
||||
| 方法 | HotGo 路径 | 权限 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/imRegistration/config/get` | 平台超级管理员 |
|
||||
| POST | `/imRegistration/config/update` | 平台超级管理员 |
|
||||
|
||||
读取响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"registerSmsEnabled": true,
|
||||
"updatedAt": "2026-08-18T16:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
更新请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"registerSmsEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
后端必须在业务逻辑层再次调用 `service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))`,不能只依赖菜单是否可见。更新时只允许修改固定的 `group=im/key=register_sms_enabled`,不接受前端传 group 或 key。
|
||||
|
||||
### 7.4 页面位置
|
||||
|
||||
建议在 `web/src/views/system/config/system.vue` 增加“IM 注册”设置项,并新增:
|
||||
|
||||
```text
|
||||
web/src/views/system/config/ImRegistrationSetting.vue
|
||||
```
|
||||
|
||||
页面只展示一个“注册短信发送”开关和保存按钮。选项通过 `dict.loadOptions(['sys_switch'])` 加载,并按 7.2 做显式映射。
|
||||
|
||||
### 7.5 IM 服务读取约定
|
||||
|
||||
HotGo 不需要额外暴露公开配置接口。IM 服务直接从控制库读取:
|
||||
|
||||
```sql
|
||||
SELECT value
|
||||
FROM hg_sys_config
|
||||
WHERE `group` = 'im'
|
||||
AND `key` = 'register_sms_enabled'
|
||||
AND status = 1
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
1. 种子默认值为开启。
|
||||
2. IM 服务缓存建议为 30 秒,缓存时长可配置。
|
||||
3. 临时读取失败时优先使用最近一次成功值。
|
||||
4. 进程启动后无缓存且配置缺失、停用或非法时,按“开启”处理,防止邀请码绕过短信验证,并记录告警。
|
||||
5. HotGo 更新后不承诺立即生效,验收标准为 30 秒内生效。
|
||||
|
||||
## 8. 统一租户身份
|
||||
|
||||
### 8.1 唯一租户主键
|
||||
|
||||
`hotgo.im_tenant.id` 是 HotGo 和 IM 服务共同使用的唯一 `tenant_id`。HotGo 负责维护租户注册信息和业务库路由,IM 服务根据该 ID 选择租户业务库。不得再创建 `im_admin_tenant_binding` 或其他租户映射表。
|
||||
|
||||
### 8.2 管理员租户归属
|
||||
|
||||
后台管理员记录直接引用统一租户:
|
||||
|
||||
```sql
|
||||
ALTER TABLE hg_admin_member
|
||||
ADD COLUMN tenant_id BIGINT NULL COMMENT '统一租户ID,引用 im_tenant.id',
|
||||
ADD INDEX idx_hg_admin_member_tenant (tenant_id),
|
||||
ADD CONSTRAINT fk_hg_admin_member_im_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id);
|
||||
```
|
||||
|
||||
平台账号的 `tenant_id` 可以为空。租户管理员及其创建的下级后台账号必须继承同一个 `im_tenant.id`。只有平台超级管理员可以为后台账号选择或修改租户,租户域管理员提交的 `tenantId` 必须被后端忽略并以当前登录身份为准。
|
||||
|
||||
### 8.3 解析规则
|
||||
|
||||
租户管理员请求邀请码接口时:
|
||||
|
||||
1. 从 HotGo 登录上下文取得 `member_id`、`tenant_id` 和部门类型。
|
||||
2. 仅允许合法的租户级管理员角色进入;普通用户和商户角色不得管理邀请码。
|
||||
3. `tenant_id` 必须大于 0,并且对应的 `im_tenant` 必须存在且为 active。
|
||||
4. 将该 `tenant_id` 写入 IM 服务调用上下文。
|
||||
5. 忽略并拒绝浏览器提交的 `tenantId`。
|
||||
|
||||
平台超级管理员可以跨租户查看邀请码;列表和导出未选择租户时查询全部。创建、批次查看、撤销及其他写操作必须先选择一个 `im_tenant`。此选择只对 `VerifySuperId` 通过的管理员开放,HotGo 后端仍要检查目标租户存在且 active。
|
||||
|
||||
后台用户管理应提供仅平台超级管理员可见的租户选择控件。租户管理员未关联 `im_tenant.id` 时返回明确错误“当前管理员未关联租户”,不能回退到默认租户。
|
||||
|
||||
## 9. 租户库直连与事件通道
|
||||
|
||||
### 9.1 数据库选择
|
||||
|
||||
HotGo 从可信登录上下文取得 `im_tenant.id`。邀请码批次和邀请码主表直接通过 `imtenant.RegistryDB()` 访问 HotGo 控制库;只有密码重置等租户业务操作才通过 `imtenant.DB()` 打开租户业务库。浏览器传入的 `tenantId` 只允许平台超级管理员选择,租户管理员始终使用登录关系中的租户 ID;任何请求都不能提交或覆盖 DSN。
|
||||
|
||||
若配置了 `database.imRegistry`,租户注册表从该连接读取;未配置时回退到 HotGo 默认库。业务操作始终使用按租户动态建立的连接组。
|
||||
|
||||
### 9.2 Snowflake 配置
|
||||
|
||||
HotGo 直接新增 IM 表记录时使用与 IM 相同位布局的 Snowflake ID:
|
||||
|
||||
```yaml
|
||||
imDirectDb:
|
||||
snowflakeWorkerId: 1023
|
||||
```
|
||||
|
||||
也可由 `IM_DIRECT_DB_SNOWFLAKE_WORKER_ID` 覆盖。每个 HotGo 写库实例必须使用唯一 worker ID,并且不能与 IM 节点重叠。当前单实例默认预留 `1023`,多实例部署前必须分配 worker ID。
|
||||
|
||||
### 9.3 写入职责
|
||||
|
||||
HotGo 可以写:
|
||||
|
||||
1. HotGo 控制库中的 `im_registration_invite_batches`、`im_registration_invite_codes`。
|
||||
2. 管理员重置密码时的 `im_auth.secret` 和 `im_user_security`。
|
||||
3. 管理操作产生的 `im_business_events_outbox`。
|
||||
|
||||
IM 独占写入:
|
||||
|
||||
1. 客户端注册事务及用户创建。
|
||||
2. 租户业务库中的 `im_registration_invite_uses` 以及同事务的 `registration.invite_used` outbox 事件。
|
||||
3. 客户端登录、改密、短信找回等认证事务。
|
||||
|
||||
### 9.4 Kafka 演进
|
||||
|
||||
管理员重置密码在数据库事务内写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "security.password_reset",
|
||||
"payload": {
|
||||
"user_id": 198000000000000001,
|
||||
"auth_version": 2,
|
||||
"operator_id": 10001,
|
||||
"request_id": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当前 IM 在注册写入 outbox 后立即触发一次消费,并每秒按在线会话涉及的租户轮询 pending 事件。`security.password_reset` 消费后清退该用户会话;`registration.invite_used` 消费时从租户使用明细重新聚合 `COUNT/MAX(used_at)`,覆盖 HotGo 控制库的 `usage_count/last_used_at`,成功后标记 delivered。离线用户不依赖密码重置轮询,后续登录时由 `auth_version` 直接拒绝旧 Token。
|
||||
|
||||
邀请码使用事件契约:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "registration.invite_used",
|
||||
"payload": {
|
||||
"tenant_id": 1,
|
||||
"invite_code_id": 198000000000000001,
|
||||
"user_id": 198000000000000002,
|
||||
"request_id": "acc-request-id",
|
||||
"used_at": "2026-08-19T10:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
迁移 Kafka 时,生产者继续在业务事务内写 outbox,由独立投递器发布同一事件;消费者保持事件类型与 payload 不变。Kafka 投递确认后再标记 outbox delivered,避免数据库提交成功但消息丢失。
|
||||
|
||||
## 10. 邀请码接口契约
|
||||
|
||||
### 10.1 数据表契约
|
||||
|
||||
HotGo 在控制库直接维护带 `tenant_id` 的 `im_registration_invite_batches` 和 `im_registration_invite_codes`。邀请码状态仅为 `active/expired/revoked`,使用后仍为 active;`im_registration_invite_uses` 只由 IM 注册事务写入租户库。控制库中的 `usage_count/last_used_at` 由 outbox 消费器幂等投影,HotGo 列表不跨租户库做联表聚合。
|
||||
|
||||
平台超级管理员在列表和导出接口中可不传 `tenantId` 以查询全部租户;创建、批次查看、撤销等写操作必须明确选择一个 active 租户。租户管理员只能查询和操作登录关系绑定的 `im_tenant.id`。
|
||||
|
||||
邀请码明文不得放入 URL。查询完整邀请码时必须使用 HotGo POST Body,普通请求日志需对 `inviteCode` 脱敏。
|
||||
|
||||
### 10.2 HotGo 对浏览器接口
|
||||
|
||||
沿用 HotGo 现有命名风格,建议增加:
|
||||
|
||||
| 方法 | HotGo 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/imInvite/list` | 分页查询,过滤条件放在 JSON Body |
|
||||
| POST | `/imInvite/create` | 单个生成 |
|
||||
| POST | `/imInvite/batchCreate` | 批量生成 |
|
||||
| GET | `/imInvite/batchView` | 批次详情 |
|
||||
| POST | `/imInvite/revoke` | 单个撤销 |
|
||||
| POST | `/imInvite/batchRevoke` | 批量撤销 |
|
||||
| POST | `/imInvite/export` | 明文导出,过滤条件放在 JSON Body |
|
||||
|
||||
HotGo controller 只做 DTO 转换、调用 service 和包装响应。租户解析、平台管理员判断、权限复核、审计和租户库访问放在 logic/service 层。
|
||||
|
||||
### 10.3 单个生成
|
||||
|
||||
HotGo 请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"expiresAt": "2026-09-01T00:00:00+08:00",
|
||||
"remark": "新员工入职",
|
||||
"idempotencyKey": "7be3de87-994f-46c6-86bd-844ffb54f83f"
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
1. `expiresAt` 必填,必须晚于服务端当前时间。
|
||||
2. `remark` 最长 255 字符。
|
||||
3. 推荐单个生成也传幂等键,避免提交成功但响应超时时重复创建。
|
||||
4. 邀请码由 HotGo 使用 `crypto/rand` 生成并依靠租户库唯一索引兜底,HotGo 不接受用户自定义 code。
|
||||
|
||||
响应数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 198000000000000001,
|
||||
"code": "AbCdE",
|
||||
"status": "active",
|
||||
"expiresAt": "2026-09-01T00:00:00+08:00",
|
||||
"remark": "新员工入职",
|
||||
"createdBy": 10001,
|
||||
"createdAt": "2026-08-18T16:10:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 批量生成
|
||||
|
||||
HotGo 请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"quantity": 100,
|
||||
"expiresAt": "2026-09-01T00:00:00+08:00",
|
||||
"remark": "2026年8月新员工入职",
|
||||
"idempotencyKey": "4dc7f13e-448c-4b49-b056-57a2fde18f95"
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
1. `quantity` 为 1 到 1000,由 HotGo 校验。
|
||||
2. 同一个提交动作必须复用同一个幂等键,直到得到明确成功响应。
|
||||
3. 前端可在打开弹窗时生成 UUID,失败重试时复用;成功或关闭弹窗后废弃。
|
||||
4. HotGo 在批次表保存幂等键;相同参数重试返回原批次,不重复生成。
|
||||
|
||||
响应数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"batchId": 198000000000000100,
|
||||
"quantity": 100,
|
||||
"activeCount": 100,
|
||||
"usageCount": 0,
|
||||
"revokedCount": 0,
|
||||
"expiredCount": 0,
|
||||
"expiresAt": "2026-09-01T00:00:00+08:00",
|
||||
"createdAt": "2026-08-18T16:15:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 分页查询
|
||||
|
||||
列表请求使用 POST JSON Body,支持字段:
|
||||
|
||||
```text
|
||||
page, perPage, inviteCode, status, batchId, createdBy,
|
||||
createdAtStart, createdAtEnd, expiresAtStart, expiresAtEnd
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. `inviteCode` 仅支持完整 5 位精确查询,不做模糊查询,不转换大小写。
|
||||
2. `status` 为 `active/expired/revoked`,邀请码使用后仍保持 `active`。
|
||||
3. HotGo 查询前将 `expires_at <= now` 的 active 记录更新为 expired,不依赖定时任务。
|
||||
4. 默认按 `created_at DESC, id DESC` 排序。
|
||||
5. `perPage` 设置合理上限,建议不超过 100。
|
||||
|
||||
列表字段至少包括:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 198000000000000001,
|
||||
"batchId": 198000000000000100,
|
||||
"code": "AbCdE",
|
||||
"status": "active",
|
||||
"expiresAt": "2026-09-01T00:00:00+08:00",
|
||||
"usageCount": 12,
|
||||
"lastUsedAt": "2026-08-20T09:30:00+08:00",
|
||||
"createdBy": 10001,
|
||||
"remark": "新员工入职",
|
||||
"createdAt": "2026-08-18T16:10:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 10.6 撤销
|
||||
|
||||
1. 只有 `active` 且未过期的邀请码可撤销;已有使用记录不影响撤销。
|
||||
2. 已过期、已撤销的邀请码不能恢复为 active。
|
||||
3. 批量撤销只撤销批次内仍可撤销的码,并返回各状态统计。
|
||||
4. 页面操作前显示确认框,确认内容不必重复显示完整邀请码。
|
||||
5. 最终状态由租户库条件更新结果决定,不以前端状态为准。
|
||||
|
||||
### 10.7 导出
|
||||
|
||||
1. 导出必须具备独立 `/imInvite/export` 权限。
|
||||
2. 导出过滤条件与列表一致。
|
||||
3. HotGo 后端分页读取租户库并流式写出,不把整份文件加载到内存。
|
||||
4. 建议输出带 UTF-8 BOM 的 CSV,字段至少包含邀请码、状态、批次、到期时间、累计使用次数、最后使用时间、创建人、创建时间和备注。
|
||||
5. 文件名建议为 `registration-invite-codes-YYYYMMDD-HHmmss.csv`。
|
||||
6. 每次导出必须写审计,但审计详情不得包含邀请码明文列表。
|
||||
|
||||
## 11. 页面和交互
|
||||
|
||||
建议页面路径:
|
||||
|
||||
```text
|
||||
web/src/views/im/registrationInvite/index.vue
|
||||
web/src/views/im/registrationInvite/model.ts
|
||||
web/src/views/im/registrationInvite/CreateModal.vue
|
||||
web/src/views/im/registrationInvite/BatchCreateModal.vue
|
||||
web/src/views/im/registrationInvite/BatchViewModal.vue
|
||||
web/src/api/im/registrationInvite.ts
|
||||
```
|
||||
|
||||
页面放在“IM 管理 / 注册邀请码”菜单下。使用现有 `BasicForm`、`BasicTable`、`TableAction`、`usePermission` 和 Naive UI 组件。
|
||||
|
||||
页面必须具备:
|
||||
|
||||
1. 完整邀请码、状态、批次 ID、创建人、创建时间和到期时间筛选。
|
||||
2. 单个生成按钮。
|
||||
3. 批量生成按钮。
|
||||
4. 导出按钮。
|
||||
5. 列表展示完整邀请码、状态、到期时间、批次、使用情况、创建信息和备注。
|
||||
6. active 行提供撤销操作,其他状态不显示撤销操作。
|
||||
7. 批次详情展示总数、active、used、expired、revoked 统计。
|
||||
8. 所有日期时间明确按本地时区展示,提交时发送带时区的 RFC 3339 时间。
|
||||
9. 加载、空数据、接口错误、重复提交和无权限状态处理完整。
|
||||
|
||||
邀请码区分大小写,任何输入组件、复制操作和查询参数都不得自动转为大写或小写。
|
||||
|
||||
## 12. 菜单与权限
|
||||
|
||||
HotGo 的权限标识使用接口路径。建议创建以下按钮权限:
|
||||
|
||||
```text
|
||||
/imRegistration/config/get
|
||||
/imRegistration/config/update
|
||||
/imInvite/list
|
||||
/imInvite/create
|
||||
/imInvite/batchCreate
|
||||
/imInvite/batchView
|
||||
/imInvite/revoke
|
||||
/imInvite/batchRevoke
|
||||
/imInvite/export
|
||||
```
|
||||
|
||||
权限分配:
|
||||
|
||||
| 能力 | 平台超级管理员 | 租户管理员 | 其他角色 |
|
||||
| --- | --- | --- | --- |
|
||||
| 查看/修改短信开关 | 允许 | 禁止 | 禁止 |
|
||||
| 选择任意 IM 租户管理邀请码 | 允许 | 禁止 | 禁止 |
|
||||
| 管理当前绑定租户邀请码 | 允许 | 按权限允许 | 禁止 |
|
||||
| 查看邀请码明文 | 允许 | 需独立权限 | 禁止 |
|
||||
| 导出邀请码明文 | 允许 | 需独立权限 | 禁止 |
|
||||
|
||||
服务端 Casbin、业务逻辑层租户范围校验、前端按钮隐藏三层都要实现。前端隐藏按钮不是安全边界。
|
||||
|
||||
## 13. 日志脱敏与审计
|
||||
|
||||
### 13.1 必须修复现有访问日志泄露
|
||||
|
||||
`server/internal/logic/sys/log.go` 当前会保存 Query 和 Body,且只掩码 password。至少增加以下字段名的大小写不敏感脱敏:
|
||||
|
||||
```text
|
||||
inviteCode
|
||||
invite_code
|
||||
serviceSecret
|
||||
service_secret
|
||||
authorization
|
||||
```
|
||||
|
||||
建议抽成统一的递归敏感字段过滤器,同时处理 Query、JSON Body、Form 和 Header。Header 中已有简化逻辑也必须确认不会保存内部服务凭证。
|
||||
|
||||
对 `/imInvite/list` 的明文精确查询,邀请码只能放在 POST Body,`hg_sys_log.post_data` 中只能出现 `******`。`hg_sys_log.get_data`、Nginx URL 和其他访问日志中都不能出现真实邀请码。HTTP Client 的 debug 日志同样不得打印完整 URL、Query、请求体或响应体。
|
||||
|
||||
### 13.2 业务审计
|
||||
|
||||
仅靠普通访问日志不够。建议在 HotGo 控制库增加独立审计表,或者接入项目已有的等价不可变审计设施。最低字段为:
|
||||
|
||||
```sql
|
||||
CREATE TABLE im_admin_operation_audit (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
request_id VARCHAR(64) NOT NULL,
|
||||
tenant_id BIGINT NULL,
|
||||
operator_id BIGINT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NULL,
|
||||
result VARCHAR(16) NOT NULL,
|
||||
ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
detail_json JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_im_admin_audit_tenant_time (tenant_id, created_at),
|
||||
KEY idx_im_admin_audit_operator_time (operator_id, created_at),
|
||||
UNIQUE KEY uk_im_admin_audit_request_action (request_id, action)
|
||||
);
|
||||
```
|
||||
|
||||
必须审计:
|
||||
|
||||
1. 短信开关修改,记录旧值和新值。
|
||||
2. 单个生成和批量生成,记录 code ID 或 batch ID、数量和到期时间,但不记录 code 明文。
|
||||
3. 邀请码明文查询和详情查看,记录查询行为,但不记录搜索明文。
|
||||
4. 单个撤销和批量撤销。
|
||||
5. 明文导出,记录过滤条件摘要和导出数量,不记录明文内容。
|
||||
6. 后台管理员租户归属变更。
|
||||
|
||||
IM 服务也应写自己的业务审计。HotGo 审计记录后台操作者,IM 审计记录实际租户库操作,两者通过 `request_id` 关联。
|
||||
|
||||
## 14. 错误处理
|
||||
|
||||
HotGo 应将数据库错误稳定映射为后台可理解的错误,不向浏览器返回 DSN、SQL、堆栈或内部配置。
|
||||
|
||||
至少处理:
|
||||
|
||||
| 场景 | HotGo 行为 |
|
||||
| --- | --- |
|
||||
| 当前管理员未关联租户 | 返回明确业务错误,不使用默认租户 |
|
||||
| 目标租户停用 | 拒绝操作 |
|
||||
| 无查看/导出权限 | 403 |
|
||||
| 邀请码已过期或已撤销 | 显示不可撤销,不本地修改状态 |
|
||||
| 幂等键重复且请求相同 | 返回原结果 |
|
||||
| 幂等键重复但参数不同 | 返回幂等冲突 |
|
||||
| 租户库连接失败 | 返回“租户业务库暂不可用”,保留 Request ID |
|
||||
| 数据库写入失败 | 事务回滚,返回统一后台错误并记录脱敏日志 |
|
||||
| 导出数据过大 | 使用服务端限制或异步导出,不耗尽内存 |
|
||||
|
||||
## 15. 建议代码落点
|
||||
|
||||
遵循现有 HotGo 分层,建议新增或修改:
|
||||
|
||||
```text
|
||||
server/api/admin/imregistration/
|
||||
server/api/admin/iminvite/
|
||||
server/internal/controller/admin/admin/im_registration.go
|
||||
server/internal/controller/admin/admin/im_invite.go
|
||||
server/internal/model/input/adminin/im_registration.go
|
||||
server/internal/model/input/adminin/im_invite.go
|
||||
server/internal/service/im.go
|
||||
server/internal/logic/admin/im_registration.go
|
||||
server/internal/logic/admin/im_invite.go
|
||||
server/internal/library/imtenant/db.go
|
||||
server/internal/library/imtenant/snowflake.go
|
||||
server/internal/router/admin.go
|
||||
server/internal/logic/sys/log.go
|
||||
web/src/api/im/registrationInvite.ts
|
||||
web/src/views/im/registrationInvite/
|
||||
web/src/views/system/config/ImRegistrationSetting.vue
|
||||
web/src/views/system/config/system.vue
|
||||
```
|
||||
|
||||
如果新增 `im_admin_operation_audit`,按 HotGo 现有 GoFrame DAO 生成流程生成 `dao/do/entity`,不要手工修改标有 “Code generated” 的文件。禁止新增 `im_admin_tenant_binding`。
|
||||
|
||||
同时提交:
|
||||
|
||||
1. 生产数据库幂等迁移。
|
||||
2. 新装数据库初始化数据。
|
||||
3. 菜单和按钮权限种子。
|
||||
4. 默认角色授权或明确的部署授权步骤。
|
||||
5. Snowflake worker 配置示例,但不提交真实 DSN。
|
||||
|
||||
## 16. 测试清单
|
||||
|
||||
### 16.1 短信开关
|
||||
|
||||
1. 无配置时 IM 按开启处理并告警。
|
||||
2. 平台超级管理员可查看和修改。
|
||||
3. 租户管理员即使手工调用接口也不能修改。
|
||||
4. UI 选择“关闭”后数据库保存 `0`,不是 `2`。
|
||||
5. UI 重新加载时 `0` 正确显示为“关闭”。
|
||||
6. IM 服务在 30 秒内读取到新值。
|
||||
7. 开关审计包含操作者、旧值、新值和 Request ID。
|
||||
|
||||
### 16.2 租户隔离
|
||||
|
||||
1. 租户 A 管理员只能看到租户 A 邀请码。
|
||||
2. 修改 Query、Body 或 Header 中的 tenant ID 不能访问租户 B。
|
||||
3. 未绑定管理员不能回退到 default 租户。
|
||||
4. 停用租户不能生成、查询、撤销或导出。
|
||||
5. 平台超级管理员选择租户后只能操作所选租户,切换时列表立即清空并重新请求。
|
||||
|
||||
### 16.3 邀请码
|
||||
|
||||
1. 单个生成返回恰好 5 位 `[A-Za-z]`。
|
||||
2. 批量生成数量正确,最大值限制生效。
|
||||
3. `AbCdE` 和 `abcde` 可作为不同邀请码精确查询。
|
||||
4. 重复提交相同幂等键不产生第二个批次。
|
||||
5. active 可撤销,expired/revoked 不可撤销。
|
||||
6. 同一 active 邀请码可供多个不同用户注册,累计使用次数正确增长且状态保持 active。
|
||||
7. 列表、详情和导出权限相互独立。
|
||||
8. 导出文件数量、过滤条件和明文字段正确。
|
||||
|
||||
### 16.4 安全和日志
|
||||
|
||||
1. `hg_sys_log.get_data/post_data/header_data` 中找不到真实邀请码和服务 secret。
|
||||
2. 应用日志中找不到邀请码明文、内部 Token 和 Authorization。
|
||||
3. 每个敏感操作都有 HotGo 审计和 IM 审计,并可通过 Request ID 对应。
|
||||
4. 无权限接口返回 403,不能只依赖前端按钮隐藏。
|
||||
5. 数据库连接和 SQL 错误不泄露内部实现信息。
|
||||
|
||||
## 17. 完成标准
|
||||
|
||||
满足以下条件才能认为 HotGo 第 4 项完成:
|
||||
|
||||
1. 全局注册短信开关可由平台超级管理员配置,关闭值正确保存为 `0`。
|
||||
2. IM 服务能在约定缓存时间内读取开关,缺失和异常时采用安全默认值。
|
||||
3. HotGo 管理员身份能可靠映射到 `im_tenant.id`,不存在默认租户回退。
|
||||
4. 邀请码单个、批量、查询、详情、撤销和导出全部通过所选租户库完成。
|
||||
5. 平台与租户权限、跨租户隔离和服务端复核全部通过测试。
|
||||
6. 邀请码明文只出现在授权页面和授权导出中,不进入普通日志。
|
||||
7. 配置修改和邀请码敏感操作均有可关联的审计记录。
|
||||
8. 后端 Go 测试、前端类型检查/构建和核心页面联调通过。
|
||||
|
||||
## 18. 新会话可直接使用的任务说明
|
||||
|
||||
在新的开发会话中可直接发送:
|
||||
|
||||
```text
|
||||
请修改 HotGo 项目 /Users/youshunlai/go_workspace/im_manager。
|
||||
先完整阅读 /Users/youshunlai/go_workspace/im_server/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md,
|
||||
再阅读其中列出的上游 IM 文档。按照交接文档的开发顺序完成 HotGo 的注册短信全局开关、
|
||||
统一租户身份、租户库直连、邀请码管理后台、菜单权限、日志脱敏和审计。
|
||||
只允许通过服务端 `im_tenant` 注册信息选择租户库,不要信任浏览器自报 tenant_id 或 DSN,
|
||||
不要修改 `im_registration_invite_uses`,不要把邀请码明文写入普通日志。
|
||||
开始前先检查 im_manager 当前工作区已有改动,并保留与本任务无关的用户修改。
|
||||
```
|
||||
+14
-4
@@ -24,7 +24,7 @@
|
||||
|
||||
功能说明:
|
||||
|
||||
展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。
|
||||
展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。登录成功后安全保存 Token 和过期时间;应用重启或断线重连时优先使用未过期 Token 自动登录,并用服务端返回的新 Token 覆盖旧值。Token 过期或失效后返回账号密码登录。
|
||||
|
||||
### 1.3 登录页面-密码错误.png
|
||||
|
||||
@@ -40,7 +40,17 @@
|
||||
|
||||
功能说明:
|
||||
|
||||
提供用户名/手机号、短信验证码、密码和确认密码输入。需支持获取验证码、密码显隐、两次密码一致性校验、已有账号返回登录以及协议勾选。
|
||||
提供独立的用户名和手机号输入框,两个字段二选一,允许同时填写但手机号优先。需支持获取验证码、密码显隐、已有账号返回登录以及协议确认。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 普通用户名只允许 1~10 位小写英文字母或数字,允许纯数字,不允许大写、中文、空格和符号。
|
||||
2. 手机号只允许 11 位数字,服务端固定补 `+86`。
|
||||
3. 手机号非空时走手机号方式并忽略用户名输入;注册后 `username` 和 `phone` 均保存规范化手机号。手机号为空时才走用户名方式,`phone=NULL`。
|
||||
4. 邀请码固定为 5 位大小写英文字母,区分大小写,不允许数字和符号。
|
||||
5. 用户名方式不发送短信,必须填写有效邀请码;注册前提示“未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置”。
|
||||
6. 短信开关开启时,手机号方式必须完成验证码验证,邀请码不能绕过验证码。
|
||||
7. 短信开关关闭时,点击获取验证码仍显示正常倒计时,但服务端不发送短信;手机号方式必须填写有效邀请码才能无验证码注册。
|
||||
|
||||
### 1.5 注册页面-倒计时.png
|
||||
|
||||
@@ -48,7 +58,7 @@
|
||||
|
||||
功能说明:
|
||||
|
||||
展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。
|
||||
展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。短信开关关闭时前端交互和倒计时保持不变,但用户不会收到短信,最终是否允许注册由服务端结合邀请码判断。
|
||||
|
||||
### 1.6 忘记密码.png
|
||||
|
||||
@@ -56,7 +66,7 @@
|
||||
|
||||
功能说明:
|
||||
|
||||
通过手机号、验证码、新密码和再次确认完成密码重置。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录。
|
||||
通过手机号、验证码、新密码和再次确认完成密码重置,仅适用于已保存手机号的账号。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录;无手机号的用户名账号应提示联系当前企业管理员重置密码。
|
||||
|
||||
### 1.7 身份验证(安卓).png
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+40
-17
@@ -165,7 +165,9 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
|
||||
{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}}
|
||||
```
|
||||
|
||||
`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。
|
||||
`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。
|
||||
|
||||
当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。
|
||||
|
||||
`hi` 成功示例:
|
||||
|
||||
@@ -295,7 +297,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。
|
||||
2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。
|
||||
3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。
|
||||
3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。
|
||||
4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。
|
||||
5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。
|
||||
|
||||
@@ -350,10 +352,11 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
|
||||
|
||||
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
|
||||
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。
|
||||
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
|
||||
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
|
||||
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
|
||||
- 待改造:后续可演进为独立短期 access token 与可轮换 refresh token;当前版本先复用 Tinode 登录 Token,并已绑定 `tenant_id/user_id/auth_version`。修改密码、封禁账号后旧 Token 应失效。
|
||||
- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
|
||||
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
|
||||
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
|
||||
|
||||
登录成功示例:
|
||||
@@ -391,18 +394,35 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
<img src="images/注册页面.png" alt="注册页面" width="360">
|
||||
|
||||
**状态:Tinode `{acc}` 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 `123456`。**
|
||||
**状态:注册原型和服务端注册分流均已完成;手机号非空时优先走手机号方式,服务端已接入 username、邀请码和短信开关逻辑。**
|
||||
|
||||
- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。
|
||||
- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。
|
||||
- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会把已校验手机号写入 `im_users.phone`,并保留验证凭证记录;用户标签已废弃,客户端设置 `tags` 会返回 `403 params.what=tags`。
|
||||
- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须统一使用 E.164 格式,如中国手机号 `+86138...`。服务端默认按 `CN` 解析未带国家码的号码并规范化,但客户端不得依赖该兼容行为;数据库 `im_users.phone` 始终保存带 `+` 和国家码的 E.164 值。
|
||||
- 页面规则:手机号非空时以手机号方式为准并忽略用户名输入;只有手机号为空时才使用用户名。两个字段同时为空时拒绝注册。
|
||||
- 用户名格式:普通用户名必须匹配 `^[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`。手机号非空时忽略用户名输入,也不校验被忽略用户名的格式。
|
||||
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
|
||||
- 手机号方式:前端只接受 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 位数字推断注册方式。
|
||||
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
|
||||
|
||||
用户名方式:
|
||||
|
||||
```json
|
||||
{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}}
|
||||
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}}
|
||||
```
|
||||
|
||||
手机号验证方式:
|
||||
|
||||
```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"}}]}}
|
||||
```
|
||||
- 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
|
||||
- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。
|
||||
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
|
||||
|
||||
### 1.5 注册页面-倒计时.png
|
||||
|
||||
@@ -420,11 +440,14 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:新增。**
|
||||
|
||||
- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。
|
||||
- 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。
|
||||
- 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。
|
||||
- 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。
|
||||
- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。
|
||||
- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。
|
||||
- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。
|
||||
- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。
|
||||
- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。
|
||||
|
||||
### 1.7 身份验证(安卓).png
|
||||
|
||||
@@ -889,7 +912,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:新增。**
|
||||
|
||||
- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。
|
||||
- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。
|
||||
- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。
|
||||
- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。
|
||||
|
||||
### 5.5 添加好友-输入精准用户ID.png
|
||||
@@ -1363,7 +1386,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。
|
||||
- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。
|
||||
- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。
|
||||
- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。
|
||||
|
||||
### 7.11 文件-下载完成.png
|
||||
|
||||
@@ -1727,7 +1750,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:菜单纯客户端;数据接口部分新增。**
|
||||
|
||||
- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。
|
||||
- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。
|
||||
- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。
|
||||
- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。
|
||||
|
||||
### 10.8 设置(安卓).png
|
||||
@@ -1930,7 +1953,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| 优先级 | 改造项 | 阻塞图片章节 |
|
||||
| --- | --- | --- |
|
||||
| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 |
|
||||
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 |
|
||||
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 |
|
||||
| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 |
|
||||
| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 |
|
||||
| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 |
|
||||
@@ -1954,7 +1977,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 |
|
||||
| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 |
|
||||
| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 |
|
||||
| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 |
|
||||
| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 |
|
||||
| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 |
|
||||
| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 |
|
||||
|
||||
|
||||
+6
-3
@@ -223,10 +223,11 @@
|
||||
|
||||
1. 用户列表、搜索、详情。
|
||||
2. 创建、启用、停用、离职、恢复用户。
|
||||
3. 重置密码、撤销 Token、踢下线。
|
||||
3. 重置密码、撤销 Token、踢下线;无手机号账号忘记密码时只能由当前租户管理员执行重置。
|
||||
4. 查看登录设备、登录 Session、安全事件。
|
||||
5. 查看用户实名状态、青少年状态、隐私设置摘要。
|
||||
6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。
|
||||
7. 支持注册邀请码单个/批量生成、有效期设置、撤销、明文精确查询和导出;邀请码固定为 5 位大小写英文字母,区分大小写且租户内不可重复。
|
||||
|
||||
建议接口:
|
||||
|
||||
@@ -244,8 +245,10 @@
|
||||
验收点:
|
||||
|
||||
1. 停用用户不影响其他租户相同手机号或相同设备用户。
|
||||
2. 重置密码必须递增 `auth_version` 并撤销旧 Token。
|
||||
3. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。
|
||||
2. 管理员只能重置当前租户用户,不得根据用户名跨租户查找或操作。
|
||||
3. 重置密码必须递增 `auth_version`,撤销旧 Token 和在线 Session,并写入包含操作者、目标用户、时间和结果的安全审计。
|
||||
4. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。
|
||||
5. 邀请码明文查看、查询和导出必须校验独立权限并写审计;普通日志不得记录邀请码。
|
||||
|
||||
### 5.3 组织架构管理
|
||||
|
||||
|
||||
+78
-4
@@ -581,13 +581,87 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
|
||||
|
||||
-- Existing database migration (the application performs the same migration
|
||||
-- idempotently at startup):
|
||||
-- 1. Add im_users.phone VARCHAR(32) NULL and a UNIQUE index.
|
||||
-- 2. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''.
|
||||
-- 3. Backfill phone/email from validated im_credentials first, then use
|
||||
-- 1. Add im_users.phone VARCHAR(32) NULL and im_users.username VARCHAR(32).
|
||||
-- 2. Backfill username from im_auth.uname after removing the basic: prefix;
|
||||
-- use phone or reserved_<id> only when no Basic login exists.
|
||||
-- 3. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''.
|
||||
-- 4. Backfill phone/email from validated im_credentials first, then use
|
||||
-- tel:/email: rows in im_usertags as a legacy fallback.
|
||||
-- 4. Drop im_usertags and im_users.tags only after the backfill is verified.
|
||||
-- 5. Verify uniqueness, then make username NOT NULL and add UNIQUE(username).
|
||||
-- 6. Drop im_auth.uname and im_auth.scheme; add UNIQUE(userid).
|
||||
-- 7. Drop im_usertags and im_users.tags only after the backfill is verified.
|
||||
-- im_topics.tags and im_topictags are topic discovery data and must be retained.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hotgo.im_registration_invite_batches (
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
quantity INT UNSIGNED NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key),
|
||||
INDEX idx_im_registration_invite_batch_tenant_created (tenant_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hotgo.im_registration_invite_codes (
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
batch_id BIGINT,
|
||||
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_used_at DATETIME(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code),
|
||||
INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at),
|
||||
INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id),
|
||||
INDEX idx_im_registration_invite_tenant_created (tenant_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_registration_invite_uses (
|
||||
id BIGINT NOT NULL,
|
||||
invite_code_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
request_id VARCHAR(64) NOT NULL,
|
||||
used_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_im_registration_invite_use_code (invite_code_id),
|
||||
UNIQUE INDEX uk_im_registration_invite_use_user (user_id),
|
||||
INDEX idx_im_registration_invite_use_request (request_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- Legacy default-tenant migration. For additional tenant databases, repeat
|
||||
-- this copy with that database name and its hotgo.im_tenant.id before drop.
|
||||
SET @im_sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_registration_invite_batches') > 0,
|
||||
'INSERT INTO hotgo.im_registration_invite_batches (id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at) SELECT id,1,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at FROM tinode.im_registration_invite_batches ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_registration_invite_codes') > 0,
|
||||
'INSERT INTO hotgo.im_registration_invite_codes (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) SELECT c.id,1,c.batch_id,c.code,CASE WHEN c.status=''used'' THEN IF(c.expires_at<=NOW(3),''expired'',''active'') ELSE c.status END,c.expires_at,COUNT(u.id),MAX(u.used_at),c.created_by,c.remark,c.created_at,c.updated_at FROM tinode.im_registration_invite_codes c LEFT JOIN tinode.im_registration_invite_uses u ON u.invite_code_id=c.id GROUP BY c.id ON DUPLICATE KEY UPDATE usage_count=VALUES(usage_count),last_used_at=VALUES(last_used_at),updated_at=VALUES(updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
DROP TABLE IF EXISTS tinode.im_registration_invite_codes;
|
||||
DROP TABLE IF EXISTS tinode.im_registration_invite_batches;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
|
||||
id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
# IM 注册、邀请码与认证数据模型改造需求文档
|
||||
|
||||
> 版本:V1.0 评审稿
|
||||
> 编写日期:2026-08-18
|
||||
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
|
||||
> 关联文档:IM-前后端对接文档.md 1.4、IM-系统设计文档.md
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文统一定义以下改造:
|
||||
|
||||
1. 注册账号支持“用户名或手机号”。
|
||||
2. 手机号注册支持短信验证,后端固定补中国国家号 +86。
|
||||
3. HotGo 后台增加注册短信发送开关。
|
||||
4. 邀请码作为无短信验证时的注册放行凭证。
|
||||
5. 邀请码支持单个生成、批量生成、自定义有效期、撤销和使用审计。
|
||||
6. im_users 增加非空 username,phone 改为可空。
|
||||
7. im_auth 删除数据库字段 scheme 和 uname,只按 userid 保存 Basic 密码凭据。
|
||||
|
||||
## 2. 核心业务定义
|
||||
|
||||
| 名称 | 定义 |
|
||||
| --- | --- |
|
||||
| 用户名输入 | 注册页面独立的用户名输入框 |
|
||||
| 手机号输入 | 注册页面独立的手机号输入框,只接受 11 位 ASCII 数字 |
|
||||
| 选择优先级 | 手机号非空时优先走手机号方式;只有手机号为空时才使用用户名 |
|
||||
| 已验证手机号 | 已成功消费 register 场景 verification_token,并建立已完成 tel credential 的手机号 |
|
||||
| 用户名注册 | 不产生已验证 phone,依靠有效邀请码放行的注册 |
|
||||
| 手机号注册 | 将规范化手机号同时写入 username 和 phone;短信验证状态由 tel credential 区分 |
|
||||
| 注册短信开关 | 控制服务端是否真正调用短信服务商的全局后台配置 |
|
||||
| 邀请码 | 租户内生成的 5 位大小写英文字母明文码,区分大小写、不可重复,有效期内可供多人使用、可撤销 |
|
||||
|
||||
## 3. 用户账号数据规则
|
||||
|
||||
### 3.1 im_users 字段
|
||||
|
||||
目标字段:
|
||||
|
||||
| 字段 | 约束 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| username | 非空、租户内唯一 | 主登录账号;普通用户名必须匹配 `^[a-z0-9]{1,10}$`,手机号方式保存 E.164 号码 |
|
||||
| phone | 可空、租户内唯一 | 手机号方式的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 |
|
||||
|
||||
落库规则:
|
||||
|
||||
| 注册方式 | username | phone |
|
||||
| --- | --- | --- |
|
||||
| 手机号验证注册 | +8613800138000 | +8613800138000 |
|
||||
| 普通用户名加邀请码注册 | alice | NULL |
|
||||
| 短信关闭,手机号加邀请码注册 | +8613800138000 | +8613800138000 |
|
||||
|
||||
普通用户名注册规则:
|
||||
|
||||
1. 必须匹配 `^[a-z0-9]{1,10}$`,长度为 1~10 位。
|
||||
2. 只允许 ASCII 小写英文字母 `a-z` 和数字 `0-9`,允许纯字母、字母数字组合或纯数字。
|
||||
3. 大写字母、中文、空格、点、下划线、冒号及其他符号均不允许;服务端校验原始输入,不通过静默转小写来放行不合规注册。
|
||||
4. 前端按相同规则限制输入并提示,服务端必须再次校验,不能只依赖客户端。
|
||||
5. 该规则只约束用户填写的普通用户名。手机号方式写入 `username` 的 E.164 号码,以及存量迁移生成的内部保留账号,不适用此正则,也不能作为普通用户名注册。
|
||||
|
||||
### 3.2 选择优先级和手机号规范化
|
||||
|
||||
1. 页面分别展示用户名输入框和手机号输入框。
|
||||
2. 手机号字段非空时,无论用户名是否填写,服务端都选择手机号方式并忽略用户名输入。
|
||||
3. 只有手机号字段为空时,服务端才使用用户名方式。
|
||||
4. 手机号字段使用 ^[0-9]{11}$ 校验;手机号非空但格式错误时直接拒绝,不能降级为用户名方式。
|
||||
5. 发送短信和落库前,后端把 11 位数字规范化为 +86xxxxxxxxxxx。
|
||||
6. 手机号方式将规范化手机号同时写入 username 和 phone。
|
||||
7. 用户名方式只写 username,phone 保持 NULL。
|
||||
8. 不提供国家和区号选择,本期固定中国 +86。
|
||||
9. 登录页面仍使用统一的“用户名/手机号”输入框;先精确查询 username,未命中时将 11 位输入补 +86 后查询 phone。
|
||||
10. 注册、绑定手机号时必须检查 username 和 phone 两个命名空间,防止同一输入命中两个用户。
|
||||
11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;手机号字段非空时忽略用户名输入,不因同时填写的用户名格式错误而拒绝手机号注册。
|
||||
|
||||
## 4. 注册放行规则
|
||||
|
||||
### 4.1 决策表
|
||||
|
||||
| 用户名字段 | 手机号字段 | 短信开关 | 有效验证票据 | 邀请码 | 服务端结果 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,按用户名注册,phone=NULL |
|
||||
| 已填 | 空 | 任意 | 不适用 | 未填或无效 | 拒绝 |
|
||||
| 任意 | 11 位 | 开 | 有 | 未填 | 放行,按手机号注册,手机号覆盖用户名输入 |
|
||||
| 任意 | 11 位 | 开 | 有 | 有效 | 放行,按手机号注册并记录邀请码使用关系 |
|
||||
| 任意 | 11 位 | 开 | 无 | 任意 | 拒绝,邀请码不能绕过已开启的短信验证 |
|
||||
| 任意 | 11 位 | 关 | 无 | 有效 | 放行,按手机号注册,username 和 phone 均写手机号 |
|
||||
| 任意 | 11 位 | 关 | 无 | 未填或无效 | 拒绝 |
|
||||
| 已填 | 格式错误 | 任意 | 任意 | 任意 | 拒绝,不能降级为用户名方式 |
|
||||
| 空 | 空 | 任意 | 任意 | 任意 | 拒绝,必须填写用户名或手机号 |
|
||||
|
||||
补充规则:
|
||||
|
||||
1. 有效 verification_token 的优先级高于当前短信开关。开关关闭不能让已验证票据失效。
|
||||
2. 短信开关开启时,邀请码不能绕过手机号验证。
|
||||
3. 用户主动填写邀请码后,服务端必须校验;不能在邀请码无效时静默忽略。
|
||||
4. 手机号验证注册时邀请码可不填;用户名方式以及短信关闭的手机号方式必须填写有效邀请码。
|
||||
5. 短信关闭时邀请码替代短信验证作为注册授权,但不建立已完成的 tel credential。
|
||||
6. 用户创建、租户用户配额校验、邀请码使用记录、手机号票据消费应原子完成。
|
||||
|
||||
### 4.2 客户端页面行为
|
||||
|
||||
1. 页面分别显示“请输入用户名”和“请输入手机号”。
|
||||
2. 手机号非空时以手机号方式为准,用户名输入不参与注册。
|
||||
3. 手机号为空时才使用用户名方式。
|
||||
4. 手机号为 11 位数字时允许点击“获取验证码”。
|
||||
5. 用户名方式不发送短信,验证码字段不作为提交项。
|
||||
6. 邀请码字段始终显示,文案为“请输入邀请码(非必填)”。
|
||||
7. 短信开关关闭时,发码接口仍返回正常倒计时,前端进入与开关开启时相同的倒计时状态。
|
||||
8. 前端不自行决定最终是否可以免验证码注册,最终放行完全由服务端判断。
|
||||
9. 用户名方式注册前提示:未绑定手机号时无法自助找回密码,只能联系企业管理员重置。
|
||||
|
||||
## 5. 注册短信发送开关
|
||||
|
||||
### 5.1 HotGo 配置
|
||||
|
||||
HotGo 后台增加全局配置:
|
||||
|
||||
~~~text
|
||||
group = im
|
||||
key = register_sms_enabled
|
||||
type = bool
|
||||
value = 1 / 0
|
||||
~~~
|
||||
|
||||
实现约定:
|
||||
|
||||
1. 管理界面的开关选项复用 HotGo 已有 sys_switch 字典。
|
||||
2. 当前开关值保存到 hg_sys_config;字典只负责提供开/关选项,不作为配置值真源。
|
||||
3. 只有平台管理员可以修改。
|
||||
4. 修改操作写 HotGo 后台审计日志。
|
||||
5. IM 服务端在可控缓存时间内读取最新值。
|
||||
6. 本期为全局开关;未来需要按企业控制时再迁移到 im_tenant_config。
|
||||
|
||||
### 5.2 开关开启
|
||||
|
||||
1. 服务端生成强随机验证码。
|
||||
2. 创建 register 场景挑战并执行限频。
|
||||
3. 调用短信服务商发送验证码。
|
||||
4. 短信服务商失败时返回稳定错误 SMS_DELIVERY_FAILED。
|
||||
5. 禁止继续使用固定测试码 123456。
|
||||
|
||||
### 5.3 开关关闭
|
||||
|
||||
1. 发码接口仍创建限频挑战。
|
||||
2. 返回 challenge_id、expires_in_seconds 和 resend_after_seconds。
|
||||
3. 不调用短信服务商。
|
||||
4. 不生成客户端可猜测或可验证的固定验证码。
|
||||
5. 前端仍正常显示倒计时,但用户不会收到短信。
|
||||
6. 记录内部 delivery_suppressed 审计事件,不向客户端暴露开关状态。
|
||||
|
||||
## 6. 邀请码管理
|
||||
|
||||
### 6.1 基本规则
|
||||
|
||||
1. 邀请码按租户隔离,不能跨企业使用。
|
||||
2. 邀请码固定为 5 位 ASCII 大小写英文字母,格式为 `^[A-Za-z]{5}$`,不包含数字和符号。
|
||||
3. 邀请码区分大小写,`AbCdE` 与 `abcde` 是两个不同的邀请码;客户端和服务端不得自动转换大小写。
|
||||
4. 邀请码由服务端使用密码学安全随机数从 `A-Z`、`a-z` 字符集中生成,不能使用递增数字、用户 ID 或批次 ID。
|
||||
5. 同一租户内邀请码不能重复,数据库唯一索引是最终约束;单个或批量生成发生冲突时必须重新生成,不得返回重复码。
|
||||
6. 邀请码以明文落库,并使用完整明文进行大小写敏感的精确查询和注册校验。
|
||||
7. 每个邀请码在有效期内可以成功注册多个账号,不设置使用次数上限。
|
||||
8. 管理员创建时自行设置 expires_at,必须晚于当前时间。
|
||||
9. 状态包括 active、expired、revoked,不存在 used 状态。
|
||||
10. expired、revoked 状态不可恢复为 active。
|
||||
11. 邀请码校验必须同时检查租户、完整明文、状态和到期时间,不因已有其他用户使用记录而失效。
|
||||
|
||||
### 6.2 单个生成
|
||||
|
||||
请求示例:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"expires_at": "2026-09-01T00:00:00+08:00",
|
||||
"remark": "新员工入职"
|
||||
}
|
||||
~~~
|
||||
|
||||
服务端返回一个 5 位邀请码。具备邀请码查看权限的租户管理员可在后续列表和详情中查看完整明文。
|
||||
|
||||
### 6.3 批量生成
|
||||
|
||||
请求示例:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"quantity": 100,
|
||||
"expires_at": "2026-09-01T00:00:00+08:00",
|
||||
"remark": "2026年8月新员工入职"
|
||||
}
|
||||
~~~
|
||||
|
||||
规则:
|
||||
|
||||
1. 一次批量生成共用 batch_id 和 expires_at。
|
||||
2. 每个邀请码均为独立生成的 5 位大小写英文字母,租户内唯一且区分大小写。
|
||||
3. 批量请求必须支持幂等键,重试不能生成重复批次。
|
||||
4. 单批数量由后台配置限制,建议默认上限 1000。
|
||||
5. 生成成功后支持明文导出;后续再次查询或导出必须校验邀请码查看/导出权限并写审计。
|
||||
|
||||
### 6.4 查询、撤销和审计
|
||||
|
||||
管理后台支持:
|
||||
|
||||
1. 按完整邀请码进行大小写敏感的精确查询。
|
||||
2. 按状态、批次、创建人、创建时间、到期时间查询。
|
||||
3. 列表和详情返回邀请码明文,接口必须校验当前租户的邀请码查看权限。
|
||||
4. 单个撤销仍处于 active 的邀请码;撤销不删除历史使用记录。
|
||||
5. 按 batch_id 批量撤销仍处于 active 的邀请码。
|
||||
6. 查看邀请码的累计使用次数、使用用户、使用时间和请求 ID。
|
||||
7. 所有生成、查询、查看、导出、撤销操作写后台审计日志。
|
||||
|
||||
### 6.5 HotGo 管理接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | /imInvite/create | 单个生成 |
|
||||
| POST | /imInvite/batchCreate | 批量生成 |
|
||||
| POST | /imInvite/list | 分页或明文精确查询 |
|
||||
| POST | /imInvite/revoke | 单个撤销 |
|
||||
| POST | /imInvite/batchRevoke | 批量撤销 |
|
||||
| GET | /imInvite/batchView | 批次详情 |
|
||||
| POST | /imInvite/export | 按过滤条件导出 CSV |
|
||||
|
||||
HotGo 根据可信登录上下文取得 `im_tenant.id`,通过 `im_tenant.db_dsn/db_name` 选择租户业务库并直接维护邀请码管理数据。浏览器不能提交可信 DSN 或决定实际租户库。
|
||||
|
||||
IM 不再提供 `/admin/v1/im/*` HMAC 管理接口。注册校验和 `im_registration_invite_uses` 写入仍由 IM 在用户注册事务中完成,HotGo 不参与注册事务。
|
||||
|
||||
管理员重置密码时,HotGo 在同一个租户库事务中更新 `im_auth.secret`、递增 `im_user_security.auth_version`,并写入 `im_business_events_outbox` 的 `security.password_reset` 事件。IM 当前轮询此事件并清退在线会话;后续迁移 Kafka 时保持事件类型和 payload 不变,只替换投递与消费通道。
|
||||
|
||||
## 7. 客户端注册协议
|
||||
|
||||
### 7.1 发码
|
||||
|
||||
~~~http
|
||||
POST /v1/im/auth/verification-codes
|
||||
~~~
|
||||
|
||||
~~~json
|
||||
{
|
||||
"scene": "register",
|
||||
"phone": "13800138000"
|
||||
}
|
||||
~~~
|
||||
|
||||
后端补 +86 后发送并保存手机号哈希。开关关闭时返回字段和倒计时保持一致。
|
||||
|
||||
### 7.2 提交注册
|
||||
|
||||
在现有 WebSocket acc 消息中增加 username、phone 和 invite_code:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"acc": {
|
||||
"id": "acc-1",
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": "alice",
|
||||
"phone": "",
|
||||
"secret": "base64(account:password)",
|
||||
"invite_code": "AbCdE",
|
||||
"login": true,
|
||||
"cred": []
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
用户名方式时,phone 为空,secret 使用 username:password,cred 为空且 invite_code 必须有效。
|
||||
|
||||
手机号方式时,phone 非空,secret 使用 phone:password,服务端忽略同时提交的 username。短信验证成功时 cred 传递一次性 verification_token;短信关闭时 cred 为空,必须提供有效 invite_code。两种手机号路径都把规范化手机号同时保存到 username 和 phone;短信关闭路径不建立已完成的 tel credential。
|
||||
|
||||
服务端必须校验 secret 中的登录名与最终选择值一致:phone 非空时必须匹配规范化手机号,phone 为空时必须匹配 username。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。
|
||||
|
||||
客户端不得提交 phone_account、sms_enabled、skip_verification 等服务端决策字段。
|
||||
|
||||
## 8. im_auth 改造
|
||||
|
||||
### 8.1 改造边界
|
||||
|
||||
当前只有 Basic 密码凭据写入 im_auth:
|
||||
|
||||
1. token 是无状态签名,不写 im_auth。
|
||||
2. code 使用临时缓存,不写 im_auth。
|
||||
3. anonymous 不保存凭据。
|
||||
4. rest 调用外部认证服务。
|
||||
|
||||
因此数据库中的 scheme=basic 是冗余信息。登录账号真源迁移到 im_users 后,uname 和 basic: 前缀同样冗余。
|
||||
|
||||
协议层仍保留 acc.scheme 和 login.scheme,用于选择 basic 或 token 认证处理器。本次只删除数据库字段。
|
||||
|
||||
这里删除的 uname 仅指 `im_auth.uname` 数据库列。Basic 协议仍从 `secret` 中解析登录账号和密码,登录账号改为查询 `im_users.username` 或 `im_users.phone`;除非后续同时改造客户端和协议消息结构,否则 `account:password` 格式及用户名禁止冒号的技术约束继续保留。
|
||||
|
||||
### 8.2 目标表结构
|
||||
|
||||
~~~sql
|
||||
CREATE TABLE im_auth (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
userid BIGINT NOT NULL,
|
||||
authlvl INT NOT NULL,
|
||||
secret VARCHAR(255) NOT NULL,
|
||||
expires DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX auth_userid (userid),
|
||||
CONSTRAINT fk_im_auth_user
|
||||
FOREIGN KEY (userid) REFERENCES im_users(id)
|
||||
);
|
||||
~~~
|
||||
|
||||
变更内容:
|
||||
|
||||
1. 删除 scheme。
|
||||
2. 删除 uname。
|
||||
3. 删除 auth_uname 和 auth_userid_scheme。
|
||||
4. 增加 UNIQUE(userid)。
|
||||
5. 保留 authlvl,以兼容普通用户和 ROOT 账号。
|
||||
6. 保留 secret 和 expires。
|
||||
|
||||
### 8.3 登录和密码处理
|
||||
|
||||
1. Basic 登录先规范化账号输入。
|
||||
2. 先从 im_users.username 查询 userid。
|
||||
3. 未命中且输入为 11 位数字时,补 +86 后从 im_users.phone 查询 userid。
|
||||
4. 按 userid 从 im_auth 读取 bcrypt 密码摘要。
|
||||
5. 修改密码、重置密码、删除认证和 ROOT 提升都按 userid 操作。
|
||||
6. Store 和 Adapter 接口不再传递 scheme、unique 或 basic: 前缀。
|
||||
|
||||
### 8.4 密码找回
|
||||
|
||||
1. im_users.phone 非空的账号可以使用短信找回密码。
|
||||
2. 找回流程向 im_users.phone 发送 reset_password 场景验证码,成功消费验证码后才允许设置新密码。
|
||||
3. 如果该手机号注册时未建立完成状态的 tel credential,成功完成密码找回验证后可以补建该凭证。
|
||||
4. im_users.phone 为 NULL 的用户名账号不提供短信或其他自助找回方式。
|
||||
5. 无手机号账号忘记密码后,只能联系当前租户管理员执行后台密码重置。
|
||||
6. 管理员重置密码必须递增 auth_version、撤销旧 Token 和在线 Session,并写安全审计。
|
||||
7. 管理员只能重置当前租户用户,不能通过用户名跨租户查找或操作。
|
||||
|
||||
### 8.5 密码与登录 Token
|
||||
|
||||
1. 客户端按现有 Basic 协议提交 `base64(account + ":" + password)`。Base64 只是协议编码,不是密码加密;账号注册和登录必须使用 HTTPS/WSS。
|
||||
2. 服务端解析出密码后使用 bcrypt 单向哈希,当前成本参数为 `bcrypt.DefaultCost=10`;`im_auth.secret` 只保存 bcrypt 哈希,不保存可逆密文或明文。
|
||||
3. 登录 Token 继续沿用现有 Tinode HMAC 签名 Token,默认有效期为 14 天,服务端不保存 Token 明文。
|
||||
4. 客户端保存登录响应中的 `params.token` 和 `params.expires`,Token 必须放入 iOS Keychain 或 Android Keystore,不得写入普通数据库、日志或埋点。
|
||||
5. 每次 Basic 登录或有效 Token 登录成功后,服务端都返回新的 14 天 Token;客户端必须用新 Token 覆盖旧 Token。
|
||||
6. 当前不增加 Refresh Token 和独立刷新接口。Token 未过期时可通过 `login.scheme=token` 登录并取得新 Token;Token 已过期或校验失败时必须重新进行账号密码登录。
|
||||
7. Token 内绑定 `tenant_id`、`uid`、`auth_version` 和过期时间。密码重置、管理员重置或账号安全状态变化后递增 `auth_version`,使旧 Token 失效。
|
||||
|
||||
## 9. 数据库设计
|
||||
|
||||
### 9.1 邀请码批次
|
||||
|
||||
~~~sql
|
||||
CREATE TABLE im_registration_invite_batches (
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
quantity INT UNSIGNED NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key)
|
||||
);
|
||||
~~~
|
||||
|
||||
### 9.2 邀请码
|
||||
|
||||
~~~sql
|
||||
CREATE TABLE im_registration_invite_codes (
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
batch_id BIGINT,
|
||||
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_used_at DATETIME(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code),
|
||||
INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at),
|
||||
INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id)
|
||||
);
|
||||
~~~
|
||||
|
||||
### 9.3 邀请码使用记录
|
||||
|
||||
~~~sql
|
||||
CREATE TABLE im_registration_invite_uses (
|
||||
id BIGINT NOT NULL,
|
||||
invite_code_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
request_id VARCHAR(64) NOT NULL,
|
||||
used_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_im_registration_invite_use_code (invite_code_id),
|
||||
UNIQUE INDEX uk_im_registration_invite_use_user (user_id)
|
||||
);
|
||||
~~~
|
||||
|
||||
`im_registration_invite_batches` 和 `im_registration_invite_codes` 位于 HotGo 控制库,使用 `tenant_id` 隔离租户,便于平台统一查询、导出和管理。`usage_count`、`last_used_at` 是租户使用明细的查询投影,不是使用事实源。
|
||||
|
||||
`im_registration_invite_uses` 位于对应租户业务库,是邀请码使用事实源。注册成功时,IM 在同一租户库事务中写入使用明细和 `registration.invite_used` outbox 事件;当前由 IM 数据库轮询器按该邀请码的租户明细重新聚合并覆盖控制库统计,重试不会重复计数。后续切换 Kafka 时保持事件类型和 payload 不变。三张表的 BIGINT 主键均使用服务端雪花 ID。
|
||||
|
||||
邀请码以明文保存在 HotGo 控制库中。`ascii_bin` 保证查询和唯一索引区分大小写;所有校验必须同时使用 `tenant_id` 和完整 5 位邀请码精确匹配,禁止 `LOWER(code)`、`UPPER(code)` 或模糊查询。
|
||||
|
||||
## 10. 主要错误码
|
||||
|
||||
| 业务码 | 含义 |
|
||||
| --- | --- |
|
||||
| ACCOUNT_REQUIRED | 账号输入为空 |
|
||||
| ACCOUNT_POLICY_VIOLATION | 账号违反最低技术约束 |
|
||||
| ACCOUNT_ALREADY_EXISTS | 用户名或手机号命名空间冲突 |
|
||||
| PHONE_VERIFICATION_REQUIRED | 手机号注册缺少有效验证票据 |
|
||||
| INVITE_CODE_REQUIRED | 用户名注册缺少邀请码 |
|
||||
| INVITE_CODE_INVALID | 邀请码不存在 |
|
||||
| INVITE_CODE_EXPIRED | 邀请码已过期 |
|
||||
| INVITE_CODE_REVOKED | 邀请码已撤销 |
|
||||
| INVITE_CODE_RATE_LIMITED | 邀请码校验过于频繁 |
|
||||
| SMS_DELIVERY_FAILED | 短信服务商发送失败 |
|
||||
| USERNAME_INVALID_FORMAT | 用户名不是 1~10 位小写英文字母或数字 |
|
||||
|
||||
邀请码无效、过期、已撤销可对客户端统一显示“邀请码无效”,详细原因仅写安全审计,降低枚举风险。
|
||||
|
||||
## 11. 存量迁移
|
||||
|
||||
采用“扩容、回填、切换、收缩”方式:
|
||||
|
||||
1. 为 im_users 增加可空 username。
|
||||
2. 去除 im_auth.uname 中的 basic: 前缀后回填 im_users.username。
|
||||
3. 将现有 phone 统一规范化为 E.164。
|
||||
4. 对无 Basic 认证记录的内部、已删除或异常用户生成 reserved_<userid>,并输出迁移报告供人工复核。
|
||||
5. 检查重复 username、重复 phone 和跨字段冲突。
|
||||
6. 新版本短期双读旧 im_auth 和新 im_users 账号字段。
|
||||
7. 切换注册、登录、修改密码、密码重置和 ROOT 工具链。
|
||||
8. 将 username 改为非空并增加唯一索引。
|
||||
9. 观察期结束后删除 im_auth.scheme、im_auth.uname 和旧索引。
|
||||
|
||||
回滚时在观察期内继续保留旧列和旧读路径,不允许通过重新拼接 basic: 猜测恢复数据。
|
||||
|
||||
## 12. 安全与已知风险
|
||||
|
||||
1. 5 位邀请码属于短授权码,必须使用密码学安全随机数生成、设置有效期、可撤销并可审计;注册校验必须严格限频以降低枚举风险。邀请码允许多人使用意味着泄露后的影响面更大,应缩短有效期并及时撤销异常邀请码。
|
||||
2. 邀请码校验按租户、IP、设备和账号输入限频。
|
||||
3. 邀请码虽按业务要求明文落库和授权查询,但不得写入普通日志、埋点或错误响应;密码、短信验证码和 verification_token 同样禁止记录。
|
||||
4. 邀请码使用记录、用户创建、票据消费和租户配额扣减应在同一事务完成;邀请码本身不核销、不切换为 used。
|
||||
5. 短信开关关闭时不能保留固定测试验证码。
|
||||
6. 批量生成、批量导出和批量撤销必须限制数量并单独授权。
|
||||
|
||||
### 12.1 邀请码手机号注册风险
|
||||
|
||||
短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号同时写入 username 和 phone。
|
||||
|
||||
为避免把“已填写手机号”和“已验证手机号”混为一谈:
|
||||
|
||||
1. im_users.phone 保存手机号方式注册时的号码。
|
||||
2. 只有成功消费 verification_token 时才建立完成状态的 tel credential。
|
||||
3. 短信关闭加邀请码注册不建立完成状态的 tel credential。
|
||||
4. phone 非空账号可以通过 reset_password 短信挑战证明当前持有手机号后自助重置密码。
|
||||
5. phone=NULL 的账号不能自助找回密码,只能由当前租户管理员重置。
|
||||
6. 其他要求“已验证手机号”的敏感操作必须检查 tel credential,不能只检查 phone 非空。
|
||||
7. 后续完成短信验证后,可为同一用户补建 tel credential。
|
||||
8. 邀请码不证明手机号所有权,允许该路径意味着平台接受手机号被提前占用的业务风险。
|
||||
|
||||
## 13. 验收用例
|
||||
|
||||
### 13.1 注册
|
||||
|
||||
1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 和 phone 均为规范化手机号。
|
||||
2. 只填写手机号,短信开启但没有验证票据,即使邀请码有效也不能注册。
|
||||
3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 和 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. 用户名格式不合法但手机号合法且非空时,仍按手机号方式注册并忽略用户名输入。
|
||||
|
||||
### 13.2 短信开关
|
||||
|
||||
1. 开关关闭后发码接口不调用短信服务商。
|
||||
2. 开关关闭后发码接口仍返回正常倒计时。
|
||||
3. 开关从关闭改为开启后,新请求恢复调用短信服务商。
|
||||
4. 开关修改在规定缓存时间内对 IM 服务生效。
|
||||
|
||||
### 13.3 邀请码管理
|
||||
|
||||
1. 单个生成可设置有效期。
|
||||
2. 单个和批量生成的邀请码均匹配 `^[A-Za-z]{5}$`,不包含数字或符号。
|
||||
3. 批量生成数量正确,同一租户所有邀请码互不重复;并发生成冲突时由唯一索引拦截并重试。
|
||||
4. `AbCdE` 和 `abcde` 按两个不同邀请码精确查询和校验,不得自动转换大小写。
|
||||
5. 有权限的管理员可以按完整邀请码查询,并在列表、详情和导出中取得明文。
|
||||
6. 批量请求重试不会生成第二批数据。
|
||||
7. 单个和批量撤销立即生效,历史使用用户不受影响。
|
||||
8. 无权限用户不能查看、查询或导出邀请码明文。
|
||||
9. 管理员可以查看批次使用统计和单码使用记录。
|
||||
|
||||
### 13.4 认证回归
|
||||
|
||||
1. 存量 Basic 用户迁移后可使用原账号和密码登录。
|
||||
2. 登录、注册、修改密码、密码重置和 ROOT 提升不再依赖 im_auth.scheme、im_auth.uname 或 basic: 前缀。
|
||||
3. 同一 username 或 phone 在不同租户仍可独立注册。
|
||||
4. im_auth 每个 userid 最多保留一条记录。
|
||||
|
||||
## 14. 评审结论
|
||||
|
||||
本方案的核心决策为:
|
||||
|
||||
1. 注册页的用户名和手机号是两个独立输入框,手机号非空时优先使用手机号方式。
|
||||
2. username 是所有用户非空的主登录账号。
|
||||
3. 手机号方式同时写 username 和 phone,用户名方式只写 username。
|
||||
4. 用户名和手机号同时填写时忽略用户名输入,以手机号方式为准。
|
||||
5. 短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册,但不建立已完成的 tel credential。
|
||||
6. 邀请码为 5 位大小写英文字母,区分大小写,以明文落库并在租户内唯一;有效期内可供多人使用,支持单个、批量、自定义有效期、明文查询/导出、撤销和使用审计。
|
||||
7. im_auth 只按 userid 保存 Basic 密码凭据,数据库删除 scheme、uname 和 basic: 前缀。
|
||||
8. 协议层仍保留 basic/token scheme,不与数据库结构混淆。
|
||||
9. phone=NULL 的用户名账号不能自助找回密码,只能由租户管理员重置。
|
||||
10. HotGo 与 IM 当前以共享租户业务库集成,不再使用内部 HMAC 管理 HTTP;跨进程安全事件先经数据库 outbox,后续迁移 Kafka。
|
||||
|
||||
评审通过后,实施工作拆分为数据库迁移、IM 注册与认证、HotGo 后台、客户端对接和全量回归五个任务。
|
||||
+5
-1
@@ -236,7 +236,9 @@ HTTP API 通用规则:
|
||||
|
||||
MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键。`im_user_security`、`im_kvmeta`、用户资料/隐私、设备 Push Token、好友/黑名单、青少年模式以及群设置/公告已读/禁言等原先使用自然键或复合主键的表,统一改为服务端雪花算法生成的 `BIGINT id` 主键;原业务键保留唯一索引,查询和幂等语义不变。服务启动时会对当前租户库执行幂等迁移,为存量行补齐雪花 ID 后再替换主键。迁移期间不得同时运行不写入 `id` 的旧版本服务。
|
||||
|
||||
用户身份字段约定:`im_users.phone` 是注册、密码找回和敏感操作验证使用的手机号真源,新注册必须提供已验证手机号;历史无手机号账号迁移后允许暂时为 `NULL`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。
|
||||
用户身份字段约定:`im_users.username` 是非空的主登录账号,`im_users.phone` 是手机号方式的注册号码,允许为 `NULL`;是否完成短信验证由已完成的 `tel credential` 判断。用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`,服务端按原始输入校验;手机号方式写入的 E.164 账号和迁移生成的内部保留账号属于系统值例外。注册页面分别展示用户名和手机号,手机号非空时优先走手机号方式并忽略用户名输入:规范化后的 E.164 手机号同时写入 `username` 和 `phone`;只有手机号为空时才使用用户名方式并只写 `username`。短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册并写两个字段,但不建立已完成的 `tel credential`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。
|
||||
|
||||
注册邀请码固定为 5 位 ASCII 大小写英文字母,使用密码学安全随机数生成并区分大小写。邀请码明文存入租户业务库,`code` 字段使用 `ascii_bin` 排序规则和唯一索引;注册时按完整明文精确查询,不允许大小写转换或模糊匹配。邀请码查看和导出必须授权并审计,普通日志不得记录邀请码。
|
||||
|
||||
### 7.3 企业码与登录流程
|
||||
|
||||
@@ -250,6 +252,8 @@ MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键
|
||||
|
||||
验证码按 `register`、`login`、`reset_password`、`sensitive_action` 用途隔离,相同验证码不能跨用途使用。数据库只保存验证码强哈希和手机号规范化哈希,明文只在发送时短暂存在。
|
||||
|
||||
`im_users.phone` 非空的账号可以通过 `reset_password` 短信挑战自助找回密码;成功验证即证明当前持有该号码,不要求注册时已经存在完成状态的 `tel credential`,并可在重置成功后补建凭证。`phone=NULL` 的用户名账号不提供自助找回,只能由当前租户管理员重置。管理员不得跨租户操作,重置行为必须进入安全审计。
|
||||
|
||||
密码重置必须在同一事务中完成:
|
||||
|
||||
1. 消费有效验证码或二次验证挑战。
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
+1
-1
@@ -278,7 +278,7 @@ type AuthHandler interface {
|
||||
// continue the authentication process to the next step, or return an error code.
|
||||
// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for
|
||||
// additional validation. The stock authenticators don't use it.
|
||||
// store.Users.GetAuthRecord("scheme", "unique")
|
||||
// The selected handler resolves its account through the tenant-scoped auth store.
|
||||
// Returns: user auth record, challenge, error.
|
||||
Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// Define default constraints on login and password
|
||||
const (
|
||||
defaultMinLoginLength = 2
|
||||
defaultMinLoginLength = 1
|
||||
defaultMaxLoginLength = 32
|
||||
|
||||
defaultMinPasswordLength = 3
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
// Token suitable as a login: either an E.164 phone number or a name which starts
|
||||
// and ends with a Unicode letter (class L) or number (class N), and contains
|
||||
// Unicode letters, numbers, dot and underscore.
|
||||
var loginPattern = regexp.MustCompile(`^(\+[\pN]+|[\pL\pN][_.\pL\pN]*[\pL\pN]+)$`)
|
||||
var loginPattern = regexp.MustCompile(`^(\+[0-9]+|[\pL\pN](?:[_.\pL\pN]*[\pL\pN])?)$`)
|
||||
|
||||
// authenticator is the type to map authentication methods to.
|
||||
type authenticator struct {
|
||||
@@ -143,7 +143,7 @@ func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []
|
||||
authLevel = auth.LevelAuth
|
||||
}
|
||||
|
||||
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name)
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
|
||||
uname = login
|
||||
} else if err = a.checkLoginPolicy(uname); err != nil {
|
||||
return nil, err
|
||||
} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil {
|
||||
} else if uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname); err != nil {
|
||||
return nil, err
|
||||
} else if !uid.IsZero() {
|
||||
// The (new) user name already exists. Report an error.
|
||||
@@ -198,7 +198,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
|
||||
if rec.Lifetime > 0 {
|
||||
expires = types.TimeNow().Add(time.Duration(rec.Lifetime))
|
||||
}
|
||||
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, uname, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
uid, authLvl, passhash, expires, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
|
||||
// DelRecords deletes saved authentication records of the given user.
|
||||
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return store.Users.DelAuthRecords(tenantID, uid, a.name)
|
||||
return store.Users.DeleteAuthRecord(tenantID, uid)
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.
|
||||
@@ -319,7 +319,7 @@ func (a *authenticator) RestrictedTags() ([]string, error) {
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler.
|
||||
func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name)
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func TestAuthenticateScopesLoginByTenant(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice").
|
||||
users.EXPECT().GetAuthByLogin(types.TenantID(7), "alice").
|
||||
Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice").
|
||||
users.EXPECT().GetAuthByLogin(types.TenantID(8), "alice").
|
||||
Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
|
||||
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
|
||||
@@ -73,7 +73,7 @@ func TestAddRecordAcceptsE164PhoneLogin(t *testing.T) {
|
||||
})
|
||||
|
||||
users.EXPECT().AddAuthRecord(types.TenantID(7), types.Uid(42), auth.LevelAuth,
|
||||
"basic", "+8615521120255", gomock.Any(), time.Time{}).Return(nil)
|
||||
gomock.Any(), time.Time{}).Return(nil)
|
||||
|
||||
a := &authenticator{name: "basic", addToTags: true, minLoginLength: defaultMinLoginLength,
|
||||
minPasswordLength: 6}
|
||||
|
||||
@@ -146,6 +146,12 @@ type MsgClientAcc struct {
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
// Shared secret
|
||||
Secret []byte `json:"secret,omitempty"`
|
||||
// Registration login name. Ignored when Phone is provided.
|
||||
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.
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
// Authenticate session with the newly created account
|
||||
Login bool `json:"login,omitempty"`
|
||||
// Indexable tags for user discovery
|
||||
|
||||
+26
-6
@@ -216,6 +216,11 @@ type TenantAdapter interface {
|
||||
TenantGetByID(id t.TenantID) (*t.Tenant, error)
|
||||
}
|
||||
|
||||
// RegistrationConfigAdapter reads global registration policy from the control database.
|
||||
type RegistrationConfigAdapter interface {
|
||||
RegistrationSMSEnabled() (bool, error)
|
||||
}
|
||||
|
||||
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
|
||||
// "one tenant, one database" deployment model.
|
||||
type TenantRoutedAdapter interface {
|
||||
@@ -233,15 +238,29 @@ type TenantHTTPInfrastructureAdapter interface {
|
||||
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
|
||||
}
|
||||
|
||||
// TenantRegistrationInviteAdapter stores invitation definitions in the
|
||||
// registry database and usage details in the selected tenant database.
|
||||
type TenantRegistrationInviteAdapter interface {
|
||||
TenantRegistrationInviteCreate(t.TenantID, *t.RegistrationInviteCode) error
|
||||
TenantRegistrationInviteCreateBatch(t.TenantID, *t.RegistrationInviteBatch, []t.RegistrationInviteCode) error
|
||||
TenantRegistrationInviteGetByCode(t.TenantID, string) (*t.RegistrationInviteCode, error)
|
||||
TenantRegistrationInviteList(t.TenantID, t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error)
|
||||
TenantRegistrationInviteBatchGet(t.TenantID, int64) (*t.RegistrationInviteBatch, error)
|
||||
TenantRegistrationInviteBatchGetByIdempotency(t.TenantID, string) (*t.RegistrationInviteBatch, error)
|
||||
TenantRegistrationInviteRevoke(t.TenantID, int64, time.Time) error
|
||||
TenantRegistrationInviteBatchRevoke(t.TenantID, int64, time.Time) (int, error)
|
||||
TenantRegistrationInviteRecordUse(t.TenantID, string, t.Uid, string, time.Time) error
|
||||
TenantRegistrationInviteSyncUsage(t.TenantID, int64) error
|
||||
}
|
||||
|
||||
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
|
||||
// It is separate from Adapter while the remaining persistence domains are migrated.
|
||||
type TenantAuthAdapter interface {
|
||||
TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error)
|
||||
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
|
||||
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
|
||||
secret []byte, expires time.Time) error
|
||||
TenantAuthDelScheme(tenantID t.TenantID, user t.Uid, scheme string) error
|
||||
TenantAuthUpdRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
|
||||
TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid, auth.Level, []byte, time.Time, error)
|
||||
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid) (string, auth.Level, []byte, time.Time, error)
|
||||
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
|
||||
TenantAuthDeleteRecord(tenantID t.TenantID, user t.Uid) error
|
||||
TenantAuthUpdateRecord(tenantID t.TenantID, user t.Uid, username string, authLvl auth.Level,
|
||||
secret []byte, expires time.Time) error
|
||||
TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error)
|
||||
TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error)
|
||||
@@ -263,6 +282,7 @@ type TenantBusinessAdapter interface {
|
||||
TenantUserCreate(t.TenantID, *t.User) error
|
||||
TenantUserGet(t.TenantID, t.Uid) (*t.User, error)
|
||||
TenantUserGetAll(t.TenantID, ...t.Uid) ([]t.User, error)
|
||||
TenantUserGetByPhone(t.TenantID, string) (t.Uid, error)
|
||||
TenantUserDelete(t.TenantID, t.Uid, bool) error
|
||||
TenantUserUpdate(t.TenantID, t.Uid, map[string]any) error
|
||||
TenantUserUpdateTags(t.TenantID, t.Uid, []string, []string, []string) ([]string, error)
|
||||
|
||||
@@ -30,6 +30,7 @@ func initUsers(now time.Time) []*types.User {
|
||||
Id: "3ysxkod5hNM",
|
||||
},
|
||||
UserAgent: "SomeAgent v1.2.3",
|
||||
Username: "alice",
|
||||
Tags: []string{"alice"},
|
||||
})
|
||||
users = append(users, &types.User{
|
||||
@@ -37,6 +38,7 @@ func initUsers(now time.Time) []*types.User {
|
||||
Id: "9AVDamaNCRY",
|
||||
},
|
||||
UserAgent: "Tinode Web v111.222.333",
|
||||
Username: "bob",
|
||||
Tags: []string{"bob"},
|
||||
})
|
||||
users = append(users, &types.User{
|
||||
@@ -44,6 +46,7 @@ func initUsers(now time.Time) []*types.User {
|
||||
Id: "0QLrX3WPS2o",
|
||||
},
|
||||
UserAgent: "Tindroid v1.2.3",
|
||||
Username: "carol",
|
||||
Tags: []string{"carol"},
|
||||
})
|
||||
for _, user := range users {
|
||||
|
||||
+129
-53
@@ -239,6 +239,15 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
return errors.New("mysql adapter failed to open tenant registry database: " + err.Error())
|
||||
}
|
||||
}
|
||||
if a.registryDB != nil || !databaseMissing {
|
||||
controlDB := a.db
|
||||
if a.registryDB != nil {
|
||||
controlDB = a.registryDB
|
||||
}
|
||||
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
|
||||
}
|
||||
}
|
||||
if !databaseMissing {
|
||||
if err = ensureIMGroupBusinessTables(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure group business schema: " + err.Error())
|
||||
@@ -252,6 +261,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
if err = ensureUserIdentitySchema(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error())
|
||||
}
|
||||
if err = ensureIMRegistrationInviteUseTable(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure registration invite usage schema: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
@@ -454,8 +466,17 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = ensureIMRegistrationInviteUseTable(db); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
registryDB := a.registryDB
|
||||
if registryDB == nil {
|
||||
registryDB = a.db
|
||||
}
|
||||
tenantAdp := &adapter{
|
||||
db: db,
|
||||
registryDB: registryDB,
|
||||
dsn: mysqlDefaultString(dsn, a.dsn),
|
||||
dbName: resolvedDBName,
|
||||
maxResults: a.maxResults,
|
||||
@@ -629,6 +650,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE im_users(
|
||||
id BIGINT NOT NULL,
|
||||
username VARCHAR(32) NOT NULL,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
state SMALLINT NOT NULL DEFAULT 0,
|
||||
@@ -640,6 +662,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
public JSON,
|
||||
trusted JSON,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX users_username(username),
|
||||
UNIQUE INDEX users_phone(phone),
|
||||
INDEX users_state_stateat(state, stateat),
|
||||
INDEX users_lastseen_updatedat(lastseen, updatedat)
|
||||
@@ -667,6 +690,14 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if err = createIMNotificationTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ensureIMRegistrationInviteUseTable(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.registryDB == nil {
|
||||
if err = ensureIMRegistrationInviteControlTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Indexed devices. Normalized into a separate table.
|
||||
if _, err = tx.Exec(
|
||||
@@ -689,16 +720,13 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE im_auth(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
uname VARCHAR(32) NOT NULL,
|
||||
userid BIGINT NOT NULL,
|
||||
scheme VARCHAR(16) NOT NULL,
|
||||
authlvl INT NOT NULL,
|
||||
secret VARCHAR(255) NOT NULL,
|
||||
expires DATETIME,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES im_users(id),
|
||||
UNIQUE INDEX auth_uname(uname),
|
||||
UNIQUE INDEX auth_userid_scheme(userid, scheme)
|
||||
UNIQUE INDEX auth_userid(userid)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1659,7 +1687,7 @@ func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) e
|
||||
// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
|
||||
// false for any other error
|
||||
func (a *adapter) UserCreate(user *t.User) error {
|
||||
if user == nil {
|
||||
if user == nil || strings.TrimSpace(user.Username) == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContextForTx()
|
||||
@@ -1691,8 +1719,9 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
user.Phone = canonicalPhone
|
||||
phone = canonicalPhone
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)",
|
||||
if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
decoded_uid,
|
||||
user.Username,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
user.State,
|
||||
@@ -1700,6 +1729,9 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
phone,
|
||||
common.ToJSON(user.Public),
|
||||
common.ToJSON(user.Trusted)); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1712,46 +1744,57 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TenantAuthAddRecord adds an authentication record scoped to a tenant.
|
||||
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
|
||||
// TenantAuthAddRecord adds the user's Basic authentication record.
|
||||
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
|
||||
authLvl auth.Level, secret []byte, expires time.Time) error {
|
||||
tenantAdp, err := a.routedTenantAdapter(tenantID)
|
||||
if err != nil {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
|
||||
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
|
||||
}
|
||||
return a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
|
||||
var username string
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return a.AuthAddRecord(uid, "basic", username, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// TenantAuthDelScheme deletes an authentication scheme only in the selected tenant.
|
||||
func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme string) error {
|
||||
// TenantAuthDeleteRecord deletes the Basic authentication record.
|
||||
func (a *adapter) TenantAuthDeleteRecord(tenantID t.TenantID, uid t.Uid) error {
|
||||
tenantAdp, err := a.routedTenantAdapter(tenantID)
|
||||
if err != nil {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.AuthDelScheme(uid, scheme)
|
||||
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
|
||||
}
|
||||
return a.AuthDelScheme(uid, scheme)
|
||||
return a.AuthDelScheme(uid, "basic")
|
||||
}
|
||||
|
||||
// TenantAuthUpdRecord updates an authentication record only in the selected tenant.
|
||||
func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
|
||||
// TenantAuthUpdateRecord updates the Basic authentication record and optional username.
|
||||
func (a *adapter) TenantAuthUpdateRecord(tenantID t.TenantID, uid t.Uid, username string,
|
||||
authLvl auth.Level, secret []byte, expires time.Time) error {
|
||||
tenantAdp, err := a.routedTenantAdapter(tenantID)
|
||||
if err != nil {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires)
|
||||
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
|
||||
}
|
||||
return a.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires)
|
||||
return a.AuthUpdRecord(uid, "basic", username, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// TenantAuthGetRecord retrieves a user's authentication record in one tenant.
|
||||
func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme string) (string,
|
||||
// TenantAuthGetRecord retrieves a user's Basic authentication record.
|
||||
func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid) (string,
|
||||
auth.Level, []byte, time.Time, error) {
|
||||
var expires time.Time
|
||||
tenantAdp, err := a.routedTenantAdapter(tenantID)
|
||||
@@ -1759,13 +1802,13 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str
|
||||
return "", auth.LevelNone, nil, expires, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.AuthGetRecord(uid, scheme)
|
||||
return tenantAdp.TenantAuthGetRecord(tenantID, uid)
|
||||
}
|
||||
return a.AuthGetRecord(uid, scheme)
|
||||
return a.AuthGetRecord(uid, "basic")
|
||||
}
|
||||
|
||||
// TenantAuthGetUniqueRecord retrieves an authentication record by tenant-local login.
|
||||
func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid,
|
||||
// TenantAuthGetByLogin retrieves a Basic authentication record by tenant-local login.
|
||||
func (a *adapter) TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid,
|
||||
auth.Level, []byte, time.Time, error) {
|
||||
var expires time.Time
|
||||
tenantAdp, err := a.routedTenantAdapter(tenantID)
|
||||
@@ -1773,9 +1816,9 @@ func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string)
|
||||
return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.AuthGetUniqueRecord(unique)
|
||||
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
|
||||
}
|
||||
return a.AuthGetUniqueRecord(unique)
|
||||
return a.AuthGetUniqueRecord(login)
|
||||
}
|
||||
|
||||
// Add user's authentication record
|
||||
@@ -1791,8 +1834,17 @@ func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.L
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)",
|
||||
unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {
|
||||
var storedUsername string
|
||||
if err := a.db.GetContext(ctx, &storedUsername, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return err
|
||||
} else if unique != storedUsername {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(userid,authLvl,secret,expires) VALUES(?,?,?,?)",
|
||||
store.DecodeUid(uid), authLvl, secret, exp); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
@@ -1807,7 +1859,7 @@ func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme)
|
||||
_, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1829,14 +1881,8 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {
|
||||
// Update user's authentication unique, secret, auth level.
|
||||
func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
|
||||
secret []byte, expires time.Time) error {
|
||||
|
||||
params := []string{"authLvl=?"}
|
||||
args := []any{authLvl}
|
||||
|
||||
if unique != "" {
|
||||
params = append(params, "uname=?")
|
||||
args = append(args, unique)
|
||||
}
|
||||
if len(secret) > 0 {
|
||||
params = append(params, "secret=?")
|
||||
args = append(args, secret)
|
||||
@@ -1845,23 +1891,46 @@ func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.L
|
||||
params = append(params, "expires=?")
|
||||
args = append(args, expires)
|
||||
}
|
||||
args = append(args, store.DecodeUid(uid), scheme)
|
||||
decodedUID := store.DecodeUid(uid)
|
||||
args = append(args, decodedUID)
|
||||
|
||||
ctx, cancel := a.getContext()
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
sql := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?"
|
||||
resp, err := a.db.ExecContext(ctx, sql, args...)
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if unique != "" {
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE id=?", unique, t.TimeNow(), decodedUID); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
query := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=?"
|
||||
resp, err := tx.ExecContext(ctx, query, args...)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
|
||||
if count, _ := resp.RowsAffected(); count <= 0 {
|
||||
return t.ErrNotFound
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
if count, _ := resp.RowsAffected(); count <= 0 {
|
||||
var exists int
|
||||
if err = tx.GetContext(ctx, &exists, "SELECT COUNT(*) FROM im_auth WHERE userid=?", decodedUID); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Retrieve user's authentication record
|
||||
@@ -1869,18 +1938,18 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [
|
||||
var expires time.Time
|
||||
|
||||
var record struct {
|
||||
Uname string
|
||||
Authlvl auth.Level
|
||||
Secret []byte
|
||||
Expires *time.Time
|
||||
Username string
|
||||
Authlvl auth.Level
|
||||
Secret []byte
|
||||
Expires *time.Time
|
||||
}
|
||||
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=? AND scheme=?",
|
||||
store.DecodeUid(uid), scheme); err != nil {
|
||||
if err := a.db.GetContext(ctx, &record, `SELECT u.username,a.secret,a.expires,a.authlvl
|
||||
FROM im_auth AS a JOIN im_users AS u ON u.id=a.userid WHERE a.userid=?`, store.DecodeUid(uid)); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// Nothing found - use standard error.
|
||||
err = t.ErrNotFound
|
||||
@@ -1892,7 +1961,7 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [
|
||||
expires = *record.Expires
|
||||
}
|
||||
|
||||
return record.Uname, record.Authlvl, record.Secret, expires, nil
|
||||
return record.Username, record.Authlvl, record.Secret, expires, nil
|
||||
}
|
||||
|
||||
// Retrieve user's authentication record
|
||||
@@ -1910,7 +1979,14 @@ func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte,
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=?", unique); err != nil {
|
||||
lookup := unique
|
||||
if len(unique) == 11 && asciiDigits(unique) {
|
||||
lookup = "+86" + unique
|
||||
}
|
||||
if err := a.db.GetContext(ctx, &record, `SELECT a.userid,a.secret,a.expires,a.authlvl
|
||||
FROM im_users AS u JOIN im_auth AS a ON a.userid=u.id
|
||||
WHERE u.username=? OR (u.phone IS NOT NULL AND u.phone=?) ORDER BY (u.username=?) DESC LIMIT 1`,
|
||||
unique, lookup, unique); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// Nothing found - clear the error
|
||||
err = nil
|
||||
@@ -1932,7 +2008,7 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
|
||||
defer cancel()
|
||||
}
|
||||
var user t.User
|
||||
err := a.db.GetContext(ctx, &user, `SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
|
||||
if err == nil {
|
||||
user.SetUid(uid)
|
||||
@@ -1963,7 +2039,7 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
q, uids, _ := sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
q, uids, _ := sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted)
|
||||
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build mysql
|
||||
// +build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func (a *adapter) RegistrationSMSEnabled() (bool, error) {
|
||||
db := a.registryDB
|
||||
if db == nil {
|
||||
db = a.db
|
||||
}
|
||||
if db == nil {
|
||||
return true, t.ErrInternal
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var value string
|
||||
err := db.GetContext(ctx, &value, `SELECT value FROM hg_sys_config
|
||||
WHERE `+"`group`"+`='im' AND `+"`key`"+`='register_sms_enabled' AND status=1 LIMIT 1`)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return true, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
switch value {
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
case "0", "false":
|
||||
return false, nil
|
||||
default:
|
||||
if parsed, parseErr := strconv.ParseBool(value); parseErr == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return true, t.ErrMalformed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build mysql
|
||||
// +build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS im_registration_invite_batches(
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
quantity INT UNSIGNED NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency(tenant_id,idempotency_key),
|
||||
INDEX idx_im_registration_invite_batch_tenant_created(tenant_id,created_at)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS im_registration_invite_codes(
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
batch_id BIGINT,
|
||||
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_used_at DATETIME(3) NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_registration_invite_code_tenant(tenant_id,code),
|
||||
INDEX idx_im_registration_invite_tenant_status_expires(tenant_id,status,expires_at),
|
||||
INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id),
|
||||
INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at)
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := exec.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureIMRegistrationInviteUseTable(exec sqlExecutor) error {
|
||||
if _, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_registration_invite_uses(
|
||||
id BIGINT NOT NULL,
|
||||
invite_code_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
request_id VARCHAR(64) NOT NULL,
|
||||
used_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
INDEX idx_im_registration_invite_use_code(invite_code_id),
|
||||
UNIQUE INDEX uk_im_registration_invite_use_user(user_id),
|
||||
INDEX idx_im_registration_invite_use_request(request_id)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, migration := range []struct {
|
||||
statement string
|
||||
ignored uint16
|
||||
}{
|
||||
{`ALTER TABLE im_registration_invite_uses DROP INDEX uk_im_registration_invite_use_code`, 1091},
|
||||
{`ALTER TABLE im_registration_invite_uses ADD INDEX idx_im_registration_invite_use_code(invite_code_id)`, 1061},
|
||||
} {
|
||||
if _, err := exec.Exec(migration.statement); err != nil && !isMySQLErrorNumber(err, migration.ignored) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMySQLErrorNumber(err error, number uint16) bool {
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == number
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//go:build mysql
|
||||
// +build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/tinode/chat/server/store"
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func (a *adapter) routedRegistrationInviteAdapter(id t.TenantID) (*adapter, error) {
|
||||
if err := validTenant(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.tenantScopedDB {
|
||||
return a, nil
|
||||
}
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp, nil
|
||||
}
|
||||
|
||||
func (a *adapter) registrationInviteControlDB() *sqlx.DB {
|
||||
if a.registryDB != nil {
|
||||
return a.registryDB
|
||||
}
|
||||
return a.db
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteCreate(id t.TenantID, code *t.RegistrationInviteCode) error {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteCreate(id, code)
|
||||
}
|
||||
if code == nil || len(code.Code) != 5 || code.ExpiresAt.IsZero() || code.CreatedBy <= 0 {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if code.ID == 0 {
|
||||
code.ID, err = nextSnowflakeID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := t.TimeNow()
|
||||
code.TenantID = id
|
||||
code.Status = t.RegistrationInviteActive
|
||||
code.CreatedAt, code.UpdatedAt = now, now
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.registrationInviteControlDB().ExecContext(ctx, `INSERT INTO im_registration_invite_codes
|
||||
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt,
|
||||
code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteCreateBatch(id t.TenantID, batch *t.RegistrationInviteBatch, codes []t.RegistrationInviteCode) (err error) {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteCreateBatch(id, batch, codes)
|
||||
}
|
||||
if batch == nil || batch.Quantity != len(codes) || len(codes) == 0 || strings.TrimSpace(batch.IdempotencyKey) == "" ||
|
||||
batch.ExpiresAt.IsZero() || batch.CreatedBy <= 0 {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if batch.ID == 0 {
|
||||
batch.ID, err = nextSnowflakeID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := t.TimeNow()
|
||||
batch.TenantID = id
|
||||
batch.CreatedAt, batch.UpdatedAt = now, now
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.registrationInviteControlDB().BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_batches
|
||||
(id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, batch.ID, id, batch.Quantity, batch.IdempotencyKey, batch.ExpiresAt,
|
||||
batch.CreatedBy, batch.Remark, batch.CreatedAt, batch.UpdatedAt); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
for i := range codes {
|
||||
if len(codes[i].Code) != 5 {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
codes[i].ID, err = nextSnowflakeID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
codes[i].BatchID = &batch.ID
|
||||
codes[i].TenantID = id
|
||||
codes[i].Status = t.RegistrationInviteActive
|
||||
codes[i].ExpiresAt = batch.ExpiresAt
|
||||
codes[i].CreatedBy = batch.CreatedBy
|
||||
codes[i].Remark = batch.Remark
|
||||
codes[i].CreatedAt, codes[i].UpdatedAt = now, now
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_codes
|
||||
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status,
|
||||
codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, now, now); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteGetByCode(id t.TenantID, value string) (*t.RegistrationInviteCode, error) {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteGetByCode(id, value)
|
||||
}
|
||||
if len(value) != 5 {
|
||||
return nil, t.ErrInviteInvalid
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var code t.RegistrationInviteCode
|
||||
err = a.registrationInviteControlDB().GetContext(ctx, &code, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,
|
||||
c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at
|
||||
FROM im_registration_invite_codes AS c
|
||||
WHERE c.tenant_id=? AND c.code=? LIMIT 1`, id, value)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code.Status == t.RegistrationInviteActive && !t.TimeNow().Before(code.ExpiresAt) {
|
||||
code.Status = t.RegistrationInviteExpired
|
||||
}
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteList(id t.TenantID, opts t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error) {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteList(id, opts)
|
||||
}
|
||||
if opts.Limit <= 0 || opts.Limit > 100 {
|
||||
opts.Limit = 20
|
||||
}
|
||||
if opts.Offset < 0 {
|
||||
opts.Offset = 0
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
controlDB := a.registrationInviteControlDB()
|
||||
_, _ = controlDB.ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
|
||||
WHERE tenant_id=? AND status=? AND expires_at<=?`, t.RegistrationInviteExpired, now, id, t.RegistrationInviteActive, now)
|
||||
where := []string{"c.tenant_id=?"}
|
||||
args := []any{id}
|
||||
if opts.Code != "" {
|
||||
where, args = append(where, "c.code=?"), append(args, opts.Code)
|
||||
}
|
||||
if opts.Status != "" {
|
||||
where, args = append(where, "c.status=?"), append(args, opts.Status)
|
||||
}
|
||||
if opts.BatchID > 0 {
|
||||
where, args = append(where, "c.batch_id=?"), append(args, opts.BatchID)
|
||||
}
|
||||
if opts.CreatedBy > 0 {
|
||||
where, args = append(where, "c.created_by=?"), append(args, opts.CreatedBy)
|
||||
}
|
||||
if opts.CreatedAtStart != nil {
|
||||
where, args = append(where, "c.created_at>=?"), append(args, *opts.CreatedAtStart)
|
||||
}
|
||||
if opts.CreatedAtEnd != nil {
|
||||
where, args = append(where, "c.created_at<=?"), append(args, *opts.CreatedAtEnd)
|
||||
}
|
||||
if opts.ExpiresAtStart != nil {
|
||||
where, args = append(where, "c.expires_at>=?"), append(args, *opts.ExpiresAtStart)
|
||||
}
|
||||
if opts.ExpiresAtEnd != nil {
|
||||
where, args = append(where, "c.expires_at<=?"), append(args, *opts.ExpiresAtEnd)
|
||||
}
|
||||
filter := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err = controlDB.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_registration_invite_codes AS c WHERE "+filter, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queryArgs := append(append([]any{}, args...), opts.Limit, opts.Offset)
|
||||
var items []t.RegistrationInviteCode
|
||||
err = controlDB.SelectContext(ctx, &items, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,
|
||||
c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at
|
||||
FROM im_registration_invite_codes AS c
|
||||
WHERE `+filter+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t.RegistrationInvitePage{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) registrationInviteBatchGet(id t.TenantID, where string, arg any) (*t.RegistrationInviteBatch, error) {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
if where == "b.id=?" {
|
||||
return tenantAdp.TenantRegistrationInviteBatchGet(id, arg.(int64))
|
||||
}
|
||||
return tenantAdp.TenantRegistrationInviteBatchGetByIdempotency(id, arg.(string))
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var batch t.RegistrationInviteBatch
|
||||
err = a.registrationInviteControlDB().GetContext(ctx, &batch, `SELECT b.id,b.tenant_id,b.quantity,b.idempotency_key,b.expires_at,b.created_by,b.remark,b.created_at,b.updated_at,
|
||||
COALESCE(SUM(CASE WHEN c.status='active' AND c.expires_at>? THEN 1 ELSE 0 END),0) AS active_count,
|
||||
COALESCE(SUM(c.usage_count),0) AS usage_count,
|
||||
COALESCE(SUM(CASE WHEN (c.status='active' AND c.expires_at<=?) OR c.status='expired' THEN 1 ELSE 0 END),0) AS expired_count,
|
||||
COALESCE(SUM(CASE WHEN c.status='revoked' THEN 1 ELSE 0 END),0) AS revoked_count
|
||||
FROM im_registration_invite_batches AS b
|
||||
LEFT JOIN im_registration_invite_codes AS c ON c.tenant_id=b.tenant_id AND c.batch_id=b.id
|
||||
WHERE b.tenant_id=? AND `+where+` GROUP BY b.id`, t.TimeNow(), t.TimeNow(), id, arg)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return &batch, err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteBatchGet(id t.TenantID, batchID int64) (*t.RegistrationInviteBatch, error) {
|
||||
if batchID <= 0 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
return a.registrationInviteBatchGet(id, "b.id=?", batchID)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteBatchGetByIdempotency(id t.TenantID, key string) (*t.RegistrationInviteBatch, error) {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
return a.registrationInviteBatchGet(id, "b.idempotency_key=?", key)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteRevoke(id t.TenantID, codeID int64, now time.Time) error {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteRevoke(id, codeID, now)
|
||||
}
|
||||
if codeID <= 0 || now.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
|
||||
WHERE tenant_id=? AND id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked, now,
|
||||
id, codeID, t.RegistrationInviteActive, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := res.RowsAffected(); count == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteBatchRevoke(id t.TenantID, batchID int64, now time.Time) (int, error) {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteBatchRevoke(id, batchID, now)
|
||||
}
|
||||
if batchID <= 0 || now.IsZero() {
|
||||
return 0, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
|
||||
WHERE tenant_id=? AND batch_id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked,
|
||||
now, id, batchID, t.RegistrationInviteActive, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count, _ := res.RowsAffected()
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, uid t.Uid, requestID string, now time.Time) (err error) {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteRecordUse(id, value, uid, requestID, now)
|
||||
}
|
||||
if len(value) != 5 || uid.IsZero() || strings.TrimSpace(requestID) == "" || now.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
var row struct {
|
||||
ID int64 `db:"id"`
|
||||
Status t.RegistrationInviteStatus `db:"status"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
err = a.registrationInviteControlDB().GetContext(ctx, &row, `SELECT id,status,expires_at
|
||||
FROM im_registration_invite_codes WHERE tenant_id=? AND code=? LIMIT 1`, id, value)
|
||||
if err == sql.ErrNoRows {
|
||||
return t.ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Status == t.RegistrationInviteRevoked {
|
||||
return t.ErrInviteRevoked
|
||||
}
|
||||
if row.Status == t.RegistrationInviteExpired || !now.Before(row.ExpiresAt) {
|
||||
return t.ErrInviteExpired
|
||||
}
|
||||
if row.Status != t.RegistrationInviteActive {
|
||||
return t.ErrInviteInvalid
|
||||
}
|
||||
|
||||
txCtx, txCancel := a.getContextForTx()
|
||||
if txCancel != nil {
|
||||
defer txCancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(txCtx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
useID, err := nextSnowflakeID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(txCtx, `INSERT INTO im_registration_invite_uses
|
||||
(id,invite_code_id,user_id,request_id,used_at) VALUES(?,?,?,?,?)`, useID, row.ID,
|
||||
store.DecodeUid(uid), requestID, now); err != nil {
|
||||
if isDupe(err) {
|
||||
var existingCodeID int64
|
||||
if lookupErr := tx.GetContext(txCtx, &existingCodeID,
|
||||
"SELECT invite_code_id FROM im_registration_invite_uses WHERE user_id=?", store.DecodeUid(uid)); lookupErr == nil && existingCodeID == row.ID {
|
||||
err = nil
|
||||
} else {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
eventID := t.StableEventID("registration-invite-use", id, row.ID, store.DecodeUid(uid))
|
||||
if err = insertBusinessEventTx(tx, eventID, "user", uid.UserId(), uid, "registration.invite_used", map[string]any{
|
||||
"tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid),
|
||||
"request_id": requestID, "used_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TenantRegistrationInviteSyncUsage projects the tenant usage aggregate to the
|
||||
// control database. The overwrite is idempotent, so outbox retries are safe.
|
||||
func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error {
|
||||
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantRegistrationInviteSyncUsage(id, codeID)
|
||||
}
|
||||
if codeID <= 0 {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var aggregate struct {
|
||||
UsageCount int `db:"usage_count"`
|
||||
LastUsedAt *time.Time `db:"last_used_at"`
|
||||
}
|
||||
if err = a.db.GetContext(ctx, &aggregate, `SELECT COUNT(*) AS usage_count,MAX(used_at) AS last_used_at
|
||||
FROM im_registration_invite_uses WHERE invite_code_id=?`, codeID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes
|
||||
SET usage_count=?,last_used_at=? WHERE tenant_id=? AND id=?`, aggregate.UsageCount, aggregate.LastUsedAt, id, codeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := res.RowsAffected(); count == 0 {
|
||||
var exists int
|
||||
if lookupErr := a.registrationInviteControlDB().GetContext(ctx, &exists,
|
||||
"SELECT 1 FROM im_registration_invite_codes WHERE tenant_id=? AND id=?", id, codeID); lookupErr != nil {
|
||||
if lookupErr == sql.ErrNoRows {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return lookupErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
|
||||
defer cancel()
|
||||
}
|
||||
var user t.User
|
||||
err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`,
|
||||
id, decodeUID(uid), t.StateDeleted)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -165,6 +165,43 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserGetByPhone(id t.TenantID, phone string) (t.Uid, error) {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil || phone == "" {
|
||||
return t.ZeroUid, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a || a.tenantScopedDB {
|
||||
ctx, cancel := tenantAdp.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var decoded int64
|
||||
err = tenantAdp.db.GetContext(ctx, &decoded,
|
||||
"SELECT id FROM im_users WHERE phone=? AND state!=?", phone, t.StateDeleted)
|
||||
if err == sql.ErrNoRows {
|
||||
return t.ZeroUid, nil
|
||||
}
|
||||
if err != nil {
|
||||
return t.ZeroUid, err
|
||||
}
|
||||
return store.EncodeUid(decoded), nil
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var decoded int64
|
||||
err = a.db.GetContext(ctx, &decoded,
|
||||
"SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", id, phone, t.StateDeleted)
|
||||
if err == sql.ErrNoRows {
|
||||
return t.ZeroUid, nil
|
||||
}
|
||||
if err != nil {
|
||||
return t.ZeroUid, err
|
||||
}
|
||||
return store.EncodeUid(decoded), nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
@@ -201,10 +238,10 @@ func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.Tena
|
||||
var q string
|
||||
var params []any
|
||||
if a.tenantScopedDB {
|
||||
q, params, _ = sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
q, params, _ = sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted)
|
||||
} else {
|
||||
q, params, _ = sqlx.In(`SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
q, params, _ = sqlx.In(`SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
||||
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?`, id, args, t.StateDeleted)
|
||||
}
|
||||
query := a.db.Rebind(q)
|
||||
@@ -7052,8 +7089,7 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
|
||||
where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)")
|
||||
args = append(args, like, like, like, userIDNumericArg(queryText))
|
||||
case "exact_account":
|
||||
joins = append(joins, "LEFT JOIN im_auth AS account_auth ON account_auth.userid=u.id AND account_auth.scheme='basic'")
|
||||
where = append(where, "(profile.public_account=? OR account_auth.uname=? OR u.id=?)")
|
||||
where = append(where, "(profile.public_account=? OR u.username=? OR u.id=?)")
|
||||
args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText))
|
||||
case "phone":
|
||||
addMethod = "phone"
|
||||
@@ -7072,7 +7108,7 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
|
||||
COALESCE(u.phone,'') AS phone,u.public,u.trusted FROM im_users AS u `+strings.Join(joins, " ")+`
|
||||
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -126,6 +126,20 @@ func TestUserIdentitySchema(t *testing.T) {
|
||||
if count != 1 {
|
||||
t.Fatal("im_users.phone is missing")
|
||||
}
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='username' AND is_nullable='NO'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("im_users.username is missing or nullable")
|
||||
}
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='im_auth' AND column_name IN ('uname','scheme')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("legacy im_auth.uname or im_auth.scheme still exists")
|
||||
}
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='im_user_profiles' AND column_name='email'`); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -275,12 +289,12 @@ func TestTenantAuthIsolation(t *testing.T) {
|
||||
userBID := userAID + 1
|
||||
for index, userID := range []int64{userAID, userBID} {
|
||||
tenantDB := quoteIdent(dbNames[index])
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,phone,public,trusted) "+
|
||||
"VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+
|
||||
"VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)",
|
||||
"basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil {
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)",
|
||||
userID, auth.LevelAuth, []byte("hash")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
|
||||
@@ -289,11 +303,11 @@ func TestTenantAuthIsolation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
|
||||
uidA, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantA, "alice")
|
||||
if err != nil || uidA != store.EncodeUid(userAID) {
|
||||
t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err)
|
||||
}
|
||||
uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
|
||||
uidB, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantB, "alice")
|
||||
if err != nil || uidB != store.EncodeUid(userBID) {
|
||||
t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err)
|
||||
}
|
||||
@@ -306,7 +320,7 @@ func TestTenantAuthIsolation(t *testing.T) {
|
||||
t.Fatalf("tenant B credential lookup: uid=%v err=%v", credB, err)
|
||||
}
|
||||
|
||||
if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
|
||||
if err = tenantAdp.TenantAuthUpdateRecord(tenantA, uidB, "changed",
|
||||
auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
|
||||
t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound)
|
||||
}
|
||||
@@ -382,7 +396,7 @@ func TestCredUpsert(t *testing.T) {
|
||||
|
||||
func TestAuthAddRecord(t *testing.T) {
|
||||
for _, rec := range testData.Recs {
|
||||
err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
|
||||
err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"),
|
||||
rec.AuthLvl, rec.Secret, rec.Expires)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -390,12 +404,74 @@ func TestAuthAddRecord(t *testing.T) {
|
||||
}
|
||||
//Test duplicate
|
||||
err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
|
||||
testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
|
||||
strings.TrimPrefix(testData.Recs[0].Unique, "basic:"), testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
|
||||
if err != types.ErrDuplicate {
|
||||
t.Fatal("Should be duplicate error but got", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationInviteLifecycle(t *testing.T) {
|
||||
inviteAdapter, ok := adp.(adapter.TenantRegistrationInviteAdapter)
|
||||
if !ok {
|
||||
t.Fatal("MySQL adapter does not implement TenantRegistrationInviteAdapter")
|
||||
}
|
||||
var tenantID types.TenantID
|
||||
if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := types.TimeNow()
|
||||
invite := &types.RegistrationInviteCode{
|
||||
Code: "AbCdE",
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
CreatedBy: 10001,
|
||||
Remark: "integration test",
|
||||
}
|
||||
if err := inviteAdapter.TenantRegistrationInviteCreate(tenantID, invite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE")
|
||||
if err != nil || stored.Code != "AbCdE" || stored.Status != types.RegistrationInviteActive {
|
||||
t.Fatalf("stored invite=%#v err=%v", stored, err)
|
||||
}
|
||||
if _, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "abcde"); err != types.ErrInviteInvalid {
|
||||
t.Fatalf("case-insensitive invite lookup was accepted: %v", err)
|
||||
}
|
||||
firstUID := types.ParseUserId("usr" + testData.Users[0].Id)
|
||||
if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", firstUID, "req-invite-test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondUID := types.ParseUserId("usr" + testData.Users[1].Id)
|
||||
if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", secondUID, "req-invite-second-user", now); err != nil {
|
||||
t.Fatalf("second user could not reuse invite: %v", err)
|
||||
}
|
||||
if err = inviteAdapter.TenantRegistrationInviteSyncUsage(tenantID, invite.ID); err != nil {
|
||||
t.Fatalf("invite usage projection failed: %v", err)
|
||||
}
|
||||
stored, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE")
|
||||
if err != nil || stored.Status != types.RegistrationInviteActive || stored.UsageCount != 2 {
|
||||
t.Fatalf("reusable invite=%#v err=%v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationInviteReusableSchema(t *testing.T) {
|
||||
var count int
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='im_registration_invite_codes' AND column_name IN ('used_by','used_at')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("single-use columns still exist: %d", count)
|
||||
}
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='im_registration_invite_uses'
|
||||
AND index_name='idx_im_registration_invite_use_code' AND non_unique=1`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("reusable invite use index count: got %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicCreate(t *testing.T) {
|
||||
err := adp.TopicCreate(testData.Topics[0])
|
||||
if err != nil {
|
||||
@@ -610,7 +686,7 @@ func TestCredGetAll(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthGetUniqueRecord(t *testing.T) {
|
||||
uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
|
||||
uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -636,13 +712,14 @@ func TestAuthGetRecord(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recId != testData.Recs[0].Unique ||
|
||||
wantLogin := strings.TrimPrefix(testData.Recs[0].Unique, "basic:")
|
||||
if recId != wantLogin ||
|
||||
authLvl != testData.Recs[0].AuthLvl ||
|
||||
!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
|
||||
expires != testData.Recs[0].Expires {
|
||||
|
||||
got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
|
||||
want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
|
||||
want := fmt.Sprintf("%v %v %v %v", wantLogin, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
|
||||
t.Error(mismatchErrorString("Auth record", got, want))
|
||||
}
|
||||
|
||||
@@ -957,13 +1034,13 @@ func TestCredConfirm(t *testing.T) {
|
||||
func TestAuthUpdRecord(t *testing.T) {
|
||||
rec := testData.Recs[1]
|
||||
newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
|
||||
err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
|
||||
err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"),
|
||||
rec.AuthLvl, newSecret, rec.Expires)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []byte
|
||||
err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got)
|
||||
err = db.QueryRow("SELECT secret FROM im_auth WHERE userid=?", decodeUid(rec.UserId)).Scan(&got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -972,7 +1049,7 @@ func TestAuthUpdRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test with auth ID (unique) change
|
||||
newId := "basic:bob12345"
|
||||
newId := "bob12345"
|
||||
err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
|
||||
rec.AuthLvl, newSecret, rec.Expires)
|
||||
if err != nil {
|
||||
@@ -980,7 +1057,7 @@ func TestAuthUpdRecord(t *testing.T) {
|
||||
}
|
||||
// Test if old ID deleted
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count)
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=? AND username=?", decodeUid(rec.UserId), strings.TrimPrefix(rec.Unique, "basic:")).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"reset_db_data": true,
|
||||
"adapters": {
|
||||
"mysql": {
|
||||
"dsn": "root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
|
||||
"mysql": {
|
||||
"dsn": "root:root@tcp(127.0.0.1:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
|
||||
// Name of the main database.
|
||||
"database": "tinode_test"
|
||||
"database": "tinode_test",
|
||||
"tenant_registry_db": "tinode_test",
|
||||
"tenant_db_routing": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,13 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if exists, err := identityColumnExists(ctx, conn, "im_users", "username"); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN username VARCHAR(32) NULL AFTER id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if profilesExist {
|
||||
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
|
||||
return err
|
||||
@@ -94,6 +101,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
if err = backfillUserPhones(ctx, conn, usersScoped); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = backfillUsernames(ctx, conn, usersScoped); err != nil {
|
||||
return err
|
||||
}
|
||||
if profilesExist {
|
||||
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
|
||||
return err
|
||||
@@ -115,6 +125,25 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
return fmt.Errorf("create unique phone index: %w", err)
|
||||
}
|
||||
}
|
||||
usernameIndex := "users_username"
|
||||
usernameColumns := "username"
|
||||
if usersScoped {
|
||||
usernameIndex = "uk_im_users_tenant_username"
|
||||
usernameColumns = "tenant_id,username"
|
||||
}
|
||||
if exists, err := identityIndexExists(ctx, conn, "im_users", usernameIndex); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(usernameIndex)+" ON im_users("+usernameColumns+")"); err != nil {
|
||||
return fmt.Errorf("create unique username index: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
|
||||
return err
|
||||
@@ -133,6 +162,154 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
|
||||
prefix := "0 AS tenant_id,u.id AS user_id,"
|
||||
join := "a.userid=u.id"
|
||||
orderBy := "u.id"
|
||||
if scoped {
|
||||
prefix = "u.tenant_id AS tenant_id,u.id AS user_id,"
|
||||
join = "a.tenant_id=u.tenant_id AND a.userid=u.id"
|
||||
orderBy = "u.tenant_id,u.id"
|
||||
}
|
||||
authExists, err := identityTableExists(ctx, conn, "im_auth")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selectAuth := "NULL"
|
||||
authJoin := ""
|
||||
if authExists {
|
||||
hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasUname {
|
||||
selectAuth = "CASE WHEN a.uname LIKE 'basic:%' THEN SUBSTRING(a.uname,7) ELSE a.uname END"
|
||||
authJoin = " LEFT JOIN im_auth AS a ON " + join
|
||||
if hasScheme {
|
||||
authJoin += " AND a.scheme='basic'"
|
||||
}
|
||||
}
|
||||
}
|
||||
var rows []identityValue
|
||||
query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " ORDER BY " + orderBy
|
||||
if err = conn.SelectContext(ctx, &rows, query); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
value := strings.TrimSpace(row.Value)
|
||||
if strings.HasPrefix(value, "+") || (len(value) == 11 && asciiDigits(value)) {
|
||||
if canonical, normalizeErr := normalizeUserPhone(value); normalizeErr == nil {
|
||||
value = canonical
|
||||
}
|
||||
}
|
||||
if value == "" || len(value) > 32 || seen[identityUniqueKey(row.TenantID, value, scoped)] {
|
||||
value = fmt.Sprintf("reserved_%d", row.UserID)
|
||||
}
|
||||
seen[identityUniqueKey(row.TenantID, value, scoped)] = true
|
||||
update := "UPDATE im_users SET username=? WHERE id=?"
|
||||
args := []any{value, row.UserID}
|
||||
if scoped {
|
||||
update = "UPDATE im_users SET username=? WHERE tenant_id=? AND id=?"
|
||||
args = []any{value, row.TenantID, row.UserID}
|
||||
}
|
||||
if _, err = conn.ExecContext(ctx, update, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateBasicAuthSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
|
||||
exists, err := identityTableExists(ctx, conn, "im_auth")
|
||||
if err != nil || !exists {
|
||||
return err
|
||||
}
|
||||
if hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme"); err != nil {
|
||||
return err
|
||||
} else if hasScheme {
|
||||
var unsupported int
|
||||
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM im_auth WHERE scheme<>'basic'").Scan(&unsupported); err != nil {
|
||||
return err
|
||||
}
|
||||
if unsupported > 0 {
|
||||
return fmt.Errorf("mysql: im_auth contains %d non-basic records", unsupported)
|
||||
}
|
||||
}
|
||||
legacyComposite, err := identityIndexExists(ctx, conn, "im_auth", "auth_userid_scheme")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyComposite {
|
||||
// The legacy composite index may currently support the userid foreign key.
|
||||
// Give the FK a replacement before dropping that index.
|
||||
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err = conn.ExecContext(ctx, "CREATE INDEX auth_userid_fk ON im_auth(userid)"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, index := range []string{"auth_uname", "auth_userid_scheme"} {
|
||||
if exists, err = identityIndexExists(ctx, conn, "im_auth", index); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX "+quoteIdent(index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, column := range []string{"uname", "scheme"} {
|
||||
if exists, err = identityColumnExists(ctx, conn, "im_auth", column); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP COLUMN "+quoteIdent(column)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
indexName := "auth_userid"
|
||||
columns := "userid"
|
||||
if scoped {
|
||||
indexName = "auth_tenant_userid"
|
||||
columns = "tenant_id,userid"
|
||||
}
|
||||
if exists, err = identityIndexExists(ctx, conn, "im_auth", indexName); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_auth("+columns+")"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !scoped {
|
||||
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid_fk"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asciiDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for i := range value {
|
||||
if value[i] < '0' || value[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
|
||||
candidates, err := identityCandidates(ctx, conn, scoped, "tel")
|
||||
if err != nil {
|
||||
@@ -277,6 +454,17 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho
|
||||
if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil {
|
||||
return nil, err
|
||||
} else if exists {
|
||||
hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasUname || !hasScheme {
|
||||
return out, nil
|
||||
}
|
||||
authJoin := "a.userid=u.id"
|
||||
if scoped {
|
||||
authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id"
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
@@ -10,6 +11,10 @@ import (
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
const imExternalBusinessEventPollInterval = time.Second
|
||||
|
||||
var imBusinessEventDispatchLocks sync.Map
|
||||
|
||||
func stableIMEventID(parts ...any) string {
|
||||
return types.StableEventID(parts...)
|
||||
}
|
||||
@@ -87,6 +92,11 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
||||
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
|
||||
return
|
||||
}
|
||||
lockValue, _ := imBusinessEventDispatchLocks.LoadOrStore(tenantID, &sync.Mutex{})
|
||||
lock := lockValue.(*sync.Mutex)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
|
||||
if err != nil {
|
||||
@@ -94,6 +104,39 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
||||
return
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.EventType == "registration.invite_used" {
|
||||
var payload struct {
|
||||
InviteCodeID int64 `json:"invite_code_id"`
|
||||
}
|
||||
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.InviteCodeID <= 0 {
|
||||
logs.Warn.Printf("business outbox: malformed invite usage event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
if err = store.RegistrationInvites.SyncUsage(tenantID, payload.InviteCodeID); err != nil {
|
||||
logs.Warn.Printf("business outbox: sync invite usage event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if event.EventType == "security.password_reset" {
|
||||
var payload struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.UserID <= 0 {
|
||||
logs.Warn.Printf("business outbox: malformed password reset event=%s: %v", event.EventID, err)
|
||||
} else if globals.sessionStore != nil {
|
||||
globals.sessionStore.EvictUser(store.EncodeUid(payload.UserID), "")
|
||||
}
|
||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
from := ""
|
||||
if !event.ActorUserID.IsZero() {
|
||||
from = event.ActorUserID.UserId()
|
||||
@@ -118,3 +161,34 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startIMExternalBusinessEventPoller bridges direct database writers until the
|
||||
// same event contract is transported by Kafka.
|
||||
func startIMExternalBusinessEventPoller() func() {
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(imExternalBusinessEventPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if globals.sessionStore == nil {
|
||||
continue
|
||||
}
|
||||
tenantIDs := make(map[types.TenantID]struct{})
|
||||
globals.sessionStore.Range(func(_ string, session *Session) bool {
|
||||
if session != nil && !session.tenantID.IsZero() {
|
||||
tenantIDs[session.tenantID] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for tenantID := range tenantIDs {
|
||||
dispatchIMBusinessEvents(tenantID)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(stop) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
type passwordResetEventStore struct {
|
||||
testHTTPInfrastructureStore
|
||||
delivered []string
|
||||
}
|
||||
|
||||
func (s *passwordResetEventStore) MarkEventDelivered(_ types.TenantID, eventID string, _ time.Time) error {
|
||||
s.delivered = append(s.delivered, eventID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatchPasswordResetEvent(t *testing.T) {
|
||||
oldInfrastructure := store.HTTPInfrastructure
|
||||
oldHub, oldSessions := globals.hub, globals.sessionStore
|
||||
defer func() {
|
||||
store.HTTPInfrastructure = oldInfrastructure
|
||||
globals.hub, globals.sessionStore = oldHub, oldSessions
|
||||
}()
|
||||
|
||||
infrastructure := &passwordResetEventStore{}
|
||||
infrastructure.events = []types.BusinessEvent{{
|
||||
EventID: "password-reset-1", EventType: "security.password_reset",
|
||||
TargetType: "user", Target: "42", Payload: []byte(`{"user_id":42}`),
|
||||
}}
|
||||
store.HTTPInfrastructure = infrastructure
|
||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
||||
globals.sessionStore = nil
|
||||
|
||||
dispatchIMBusinessEvents(types.TenantID(1))
|
||||
|
||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "password-reset-1" {
|
||||
t.Fatalf("password reset event was not marked delivered: %v", infrastructure.delivered)
|
||||
}
|
||||
if len(globals.hub.routeSrv) != 0 {
|
||||
t.Fatal("password reset event was routed as a regular client event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRegistrationInviteUsageEvent(t *testing.T) {
|
||||
oldInfrastructure, oldInvites := store.HTTPInfrastructure, store.RegistrationInvites
|
||||
oldHub := globals.hub
|
||||
defer func() {
|
||||
store.HTTPInfrastructure, store.RegistrationInvites = oldInfrastructure, oldInvites
|
||||
globals.hub = oldHub
|
||||
}()
|
||||
|
||||
infrastructure := &passwordResetEventStore{}
|
||||
infrastructure.events = []types.BusinessEvent{{
|
||||
EventID: "invite-use-1", EventType: "registration.invite_used",
|
||||
TargetType: "user", Target: "42", Payload: []byte(`{"invite_code_id":99}`),
|
||||
}}
|
||||
invites := &testRegistrationInviteStore{}
|
||||
store.HTTPInfrastructure = infrastructure
|
||||
store.RegistrationInvites = invites
|
||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
||||
|
||||
dispatchIMBusinessEvents(types.TenantID(1))
|
||||
|
||||
if invites.syncedCode != 99 {
|
||||
t.Fatalf("invite usage projection code=%d, want 99", invites.syncedCode)
|
||||
}
|
||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "invite-use-1" {
|
||||
t.Fatalf("invite usage event was not marked delivered: %v", infrastructure.delivered)
|
||||
}
|
||||
if len(globals.hub.routeSrv) != 0 {
|
||||
t.Fatal("invite usage event was routed as a regular client event")
|
||||
}
|
||||
}
|
||||
+117
-15
@@ -37,6 +37,7 @@ const (
|
||||
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
|
||||
imReasonCodeInvalid = "CODE_INVALID"
|
||||
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
|
||||
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
|
||||
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
|
||||
imReasonAuthRequired = "AUTH_REQUIRED"
|
||||
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
||||
@@ -51,7 +52,6 @@ const (
|
||||
|
||||
const (
|
||||
imVerificationCodeLength = 6
|
||||
imVerificationTestCode = "123456"
|
||||
imVerificationExpiresInSeconds = 300
|
||||
imVerificationResendAfterSeconds = 60
|
||||
imVerificationMaxAttempts = 5
|
||||
@@ -523,6 +523,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
endpoints := imPublicEndpoints(req)
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"tenant": map[string]any{
|
||||
"code": tenant.Code,
|
||||
@@ -530,11 +531,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
"tenant_desc": tenant.TenantDesc,
|
||||
"branding": map[string]any{},
|
||||
},
|
||||
"endpoints": map[string]any{
|
||||
"websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels",
|
||||
"api": strings.TrimRight(globals.servingAt, "/") + "/v1/im",
|
||||
"file": strings.TrimRight(globals.servingAt, "/") + "/v0/file",
|
||||
},
|
||||
"endpoints": endpoints,
|
||||
"auth_methods": []string{"password", "sms"},
|
||||
"password_policy": map[string]any{
|
||||
"min_length": 8,
|
||||
@@ -544,6 +541,75 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func imPublicEndpoints(req *http.Request) map[string]any {
|
||||
base := imPublicHTTPBase(req)
|
||||
wsBase := base
|
||||
if strings.HasPrefix(wsBase, "https://") {
|
||||
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
|
||||
} else if strings.HasPrefix(wsBase, "http://") {
|
||||
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
|
||||
}
|
||||
return map[string]any{
|
||||
"websocket": strings.TrimRight(wsBase, "/") + "/v0/channels",
|
||||
"api": strings.TrimRight(base, "/") + "/v1/im",
|
||||
"file": strings.TrimRight(base, "/") + "/v0/file",
|
||||
}
|
||||
}
|
||||
|
||||
func imPublicHTTPBase(req *http.Request) string {
|
||||
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
|
||||
parsed, _ := url.Parse(configured)
|
||||
basePath := ""
|
||||
if parsed != nil {
|
||||
basePath = strings.TrimRight(parsed.Path, "/")
|
||||
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
|
||||
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
|
||||
}
|
||||
}
|
||||
if basePath == "" && req != nil && req.URL != nil {
|
||||
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
|
||||
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
|
||||
}
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if req != nil && req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if req != nil {
|
||||
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
|
||||
scheme = forwarded
|
||||
}
|
||||
}
|
||||
|
||||
host := ""
|
||||
if req != nil {
|
||||
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(req.Host)
|
||||
}
|
||||
}
|
||||
if host == "" && parsed != nil {
|
||||
host = parsed.Host
|
||||
}
|
||||
if host == "" {
|
||||
return configured
|
||||
}
|
||||
return strings.TrimRight(scheme+"://"+host+basePath, "/")
|
||||
}
|
||||
|
||||
func isPublicEndpointHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
|
||||
}
|
||||
|
||||
func firstForwardedHeader(value string) string {
|
||||
if idx := strings.Index(value, ","); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
return strings.TrimSpace(strings.ToLower(value))
|
||||
}
|
||||
|
||||
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -598,8 +664,12 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
// Temporary fixed code for app integration until the SMS provider is connected.
|
||||
code := imVerificationTestCode
|
||||
code, err := randomIMNumericCode(imVerificationCodeLength)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
deliveryEnabled := scene != "register" || registrationSMSEnabled(now)
|
||||
vc := &types.VerificationCode{
|
||||
TenantID: tenant.ID,
|
||||
ChallengeID: challengeID,
|
||||
@@ -613,8 +683,8 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
|
||||
}
|
||||
if scene == "reset_password" {
|
||||
if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil {
|
||||
logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err)
|
||||
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
|
||||
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
} else {
|
||||
@@ -626,8 +696,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
if deliveryEnabled {
|
||||
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
|
||||
_ = store.Verifications.Fail(tenant.ID, vc.ChallengeID, types.VerificationStateExpired)
|
||||
logs.Warn.Println("im verification delivery failed:", tenant.Code, scene, vc.PhoneMask, err)
|
||||
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
|
||||
}
|
||||
|
||||
logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask)
|
||||
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"challenge_id": vc.ChallengeID,
|
||||
"phone_mask": vc.PhoneMask,
|
||||
@@ -794,7 +874,7 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
||||
return
|
||||
}
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic")
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
|
||||
if err != nil || login == "" {
|
||||
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
||||
return
|
||||
@@ -830,6 +910,19 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
||||
return
|
||||
}
|
||||
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
|
||||
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
||||
User: vc.TargetUser.String(),
|
||||
Method: "tel",
|
||||
Value: account.Phone,
|
||||
Done: true,
|
||||
}); credErr != nil {
|
||||
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
|
||||
}
|
||||
} else if getErr != nil {
|
||||
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
|
||||
}
|
||||
if globals.sessionStore != nil {
|
||||
globals.sessionStore.EvictUser(vc.TargetUser, "")
|
||||
}
|
||||
@@ -4892,8 +4985,11 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
// Temporary fixed code for app integration until the SMS provider is connected.
|
||||
code := imVerificationTestCode
|
||||
code, err := randomIMNumericCode(imVerificationCodeLength)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
vc := &types.VerificationCode{
|
||||
TenantID: principal.TenantID,
|
||||
ChallengeID: challengeID,
|
||||
@@ -4912,7 +5008,13 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
logs.Info.Println("im security verification code generated:", action, vc.PhoneMask)
|
||||
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
|
||||
_ = store.Verifications.Fail(principal.TenantID, vc.ChallengeID, types.VerificationStateExpired)
|
||||
logs.Warn.Println("im security verification delivery failed:", action, vc.PhoneMask, err)
|
||||
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
|
||||
return
|
||||
}
|
||||
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"challenge_id": vc.ChallengeID,
|
||||
"phone_mask": vc.PhoneMask,
|
||||
|
||||
+162
-1
@@ -1074,11 +1074,48 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
|
||||
t.Fatalf("tenant payload must not expose internal id: %#v", tenant)
|
||||
}
|
||||
endpoints := data["endpoints"].(map[string]any)
|
||||
if endpoints["websocket"] != "https://im.example.com/v0/channels" {
|
||||
if endpoints["websocket"] != "wss://im.example.com/v0/channels" {
|
||||
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldServingAt := globals.servingAt
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
ID: 7,
|
||||
Code: "acme",
|
||||
Name: "Acme Corp",
|
||||
State: types.TenantStateActive,
|
||||
}}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
globals.servingAt = oldServingAt
|
||||
}()
|
||||
globals.servingAt = "http://:6060/"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
|
||||
req.Host = "internal:6060"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "api.118im.com")
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMTenantResolve(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)
|
||||
endpoints := data["endpoints"].(map[string]any)
|
||||
if endpoints["websocket"] != "wss://api.118im.com/v0/channels" ||
|
||||
endpoints["api"] != "https://api.118im.com/v1/im" ||
|
||||
endpoints["file"] != "https://api.118im.com/v0/file" {
|
||||
t.Fatalf("unexpected endpoints: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
@@ -1204,6 +1241,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
|
||||
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
ID: 7,
|
||||
Code: "acme",
|
||||
@@ -1212,9 +1250,20 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
setRegistrationSMSConfigForTest(true)
|
||||
var deliveredCode string
|
||||
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
|
||||
if recipient != "+8613800138000" || scene != "register" {
|
||||
t.Fatalf("unexpected delivery: recipient=%q scene=%q", recipient, scene)
|
||||
}
|
||||
deliveredCode = code
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
resetRegistrationSMSConfigForTest()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
@@ -1237,6 +1286,11 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) {
|
||||
t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String())
|
||||
}
|
||||
if len(deliveredCode) != imVerificationCodeLength ||
|
||||
verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID,
|
||||
verifications.created.PhoneHash, deliveredCode) {
|
||||
t.Fatalf("stored challenge does not match delivered code %q", deliveredCode)
|
||||
}
|
||||
resp := decodeIMHTTPResponse(t, rec)
|
||||
data := resp.Data.(map[string]any)
|
||||
if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 {
|
||||
@@ -1244,6 +1298,113 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
setRegistrationSMSConfigForTest(false)
|
||||
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
|
||||
t.Fatal("SMS sender must not be called when registration SMS is disabled")
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
resetRegistrationSMSConfigForTest()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || verifications.created == nil {
|
||||
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldUsers := store.Users
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil)
|
||||
store.Users = users
|
||||
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
|
||||
if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength {
|
||||
t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
store.Users = oldUsers
|
||||
sendIMVerificationCode = oldSender
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) {
|
||||
t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
setRegistrationSMSConfigForTest(true)
|
||||
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal }
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
resetRegistrationSMSConfigForTest()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadGateway || !verifications.failed {
|
||||
t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String())
|
||||
}
|
||||
if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed {
|
||||
t.Fatalf("reason=%q", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
|
||||
+2
-1
@@ -450,7 +450,6 @@ func main() {
|
||||
|
||||
// API key signing secret
|
||||
globals.apiKeySalt = config.APIKeySalt
|
||||
|
||||
err = store.InitAuthLogicalNames(config.Auth["logical_names"])
|
||||
if err != nil {
|
||||
logs.Err.Fatal(err)
|
||||
@@ -674,6 +673,8 @@ func main() {
|
||||
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
|
||||
// The hub (the main message router)
|
||||
globals.hub = newHub()
|
||||
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
|
||||
defer stopExternalBusinessEvents()
|
||||
|
||||
// Start accepting cluster traffic.
|
||||
if globals.cluster != nil {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"github.com/tinode/chat/server/validate"
|
||||
)
|
||||
|
||||
const registrationSMSConfigCacheTTL = 30 * time.Second
|
||||
|
||||
var registrationSMSConfigCache struct {
|
||||
sync.Mutex
|
||||
value bool
|
||||
loaded bool
|
||||
loadedAt time.Time
|
||||
loadValue func() (bool, error)
|
||||
}
|
||||
|
||||
func registrationSMSEnabled(now time.Time) bool {
|
||||
registrationSMSConfigCache.Lock()
|
||||
defer registrationSMSConfigCache.Unlock()
|
||||
if registrationSMSConfigCache.loaded && now.Sub(registrationSMSConfigCache.loadedAt) < registrationSMSConfigCacheTTL {
|
||||
return registrationSMSConfigCache.value
|
||||
}
|
||||
loader := registrationSMSConfigCache.loadValue
|
||||
if loader == nil {
|
||||
loader = func() (bool, error) {
|
||||
if store.RegistrationConfig == nil {
|
||||
return true, types.ErrUnsupported
|
||||
}
|
||||
return store.RegistrationConfig.SMSEnabled()
|
||||
}
|
||||
}
|
||||
value, err := loader()
|
||||
if err != nil {
|
||||
if registrationSMSConfigCache.loaded {
|
||||
logs.Warn.Println("registration SMS config read failed; using cached value:", err)
|
||||
return registrationSMSConfigCache.value
|
||||
}
|
||||
logs.Warn.Println("registration SMS config read failed; defaulting to enabled:", err)
|
||||
return true
|
||||
}
|
||||
registrationSMSConfigCache.value = value
|
||||
registrationSMSConfigCache.loaded = true
|
||||
registrationSMSConfigCache.loadedAt = now
|
||||
return value
|
||||
}
|
||||
|
||||
func isValidIMInviteCode(value string) bool {
|
||||
if len(value) != 5 {
|
||||
return false
|
||||
}
|
||||
for i := range value {
|
||||
if (value[i] < 'A' || value[i] > 'Z') && (value[i] < 'a' || value[i] > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var sendIMVerificationCode = func(tenantID types.TenantID, recipient, lang, scene, code string) error {
|
||||
if store.Store == nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
validatorImpl := store.Store.GetValidator("tel")
|
||||
sender, ok := validatorImpl.(validate.CodeSender)
|
||||
if !ok || !validatorImpl.IsInitialized() {
|
||||
return types.ErrUnsupported
|
||||
}
|
||||
if err := sender.SendCode(tenantID, recipient, lang, scene, code); err != nil {
|
||||
return fmt.Errorf("send verification code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
type testRegistrationInviteStore struct {
|
||||
invite *types.RegistrationInviteCode
|
||||
created *types.RegistrationInviteCode
|
||||
page *types.RegistrationInvitePage
|
||||
batch *types.RegistrationInviteBatch
|
||||
err error
|
||||
useRecorded bool
|
||||
syncedCode int64
|
||||
}
|
||||
|
||||
func (s *testRegistrationInviteStore) Create(_ types.TenantID, code *types.RegistrationInviteCode) error {
|
||||
s.created = code
|
||||
return s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) CreateBatch(types.TenantID, *types.RegistrationInviteBatch, []types.RegistrationInviteCode) error {
|
||||
return s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) GetByCode(types.TenantID, string) (*types.RegistrationInviteCode, error) {
|
||||
return s.invite, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) List(types.TenantID, types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
|
||||
return s.page, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) GetBatch(types.TenantID, int64) (*types.RegistrationInviteBatch, error) {
|
||||
return s.batch, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) GetBatchByIdempotency(types.TenantID, string) (*types.RegistrationInviteBatch, error) {
|
||||
return s.batch, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) Revoke(types.TenantID, int64, time.Time) error { return s.err }
|
||||
func (s *testRegistrationInviteStore) RevokeBatch(types.TenantID, int64, time.Time) (int, error) {
|
||||
return 0, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) RecordUse(types.TenantID, string, types.Uid, string, time.Time) error {
|
||||
s.useRecorded = true
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testRegistrationInviteStore) SyncUsage(_ types.TenantID, codeID int64) error {
|
||||
s.syncedCode = codeID
|
||||
return s.err
|
||||
}
|
||||
|
||||
func setRegistrationSMSConfigForTest(enabled bool) {
|
||||
registrationSMSConfigCache.Lock()
|
||||
defer registrationSMSConfigCache.Unlock()
|
||||
registrationSMSConfigCache.value = false
|
||||
registrationSMSConfigCache.loaded = false
|
||||
registrationSMSConfigCache.loadedAt = time.Time{}
|
||||
registrationSMSConfigCache.loadValue = func() (bool, error) { return enabled, nil }
|
||||
}
|
||||
|
||||
func resetRegistrationSMSConfigForTest() {
|
||||
registrationSMSConfigCache.Lock()
|
||||
defer registrationSMSConfigCache.Unlock()
|
||||
registrationSMSConfigCache.value = false
|
||||
registrationSMSConfigCache.loaded = false
|
||||
registrationSMSConfigCache.loadedAt = time.Time{}
|
||||
registrationSMSConfigCache.loadValue = nil
|
||||
}
|
||||
+17
-6
@@ -1237,20 +1237,26 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
uid := types.Uid(1)
|
||||
oldSecurity := store.Security
|
||||
oldVerifications := store.Verifications
|
||||
oldInvites := store.RegistrationInvites
|
||||
store.Store = ss
|
||||
store.Users = uu
|
||||
store.Security = &testSecurityStore{version: 1}
|
||||
store.Verifications = nil
|
||||
invites := &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
|
||||
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}}
|
||||
store.RegistrationInvites = invites
|
||||
defer func() {
|
||||
store.Store = nil
|
||||
store.Users = nil
|
||||
store.Security = oldSecurity
|
||||
store.Verifications = oldVerifications
|
||||
store.RegistrationInvites = oldInvites
|
||||
ctrl.Finish()
|
||||
}()
|
||||
|
||||
remoteAddr := "192.168.0.1"
|
||||
secret := "<==auth-secret==>"
|
||||
secret := "alice:password"
|
||||
authRec := &auth.Rec{
|
||||
TenantID: 1,
|
||||
Uid: uid,
|
||||
@@ -1289,11 +1295,13 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
public := "public name"
|
||||
msg := &ClientComMessage{
|
||||
Acc: &MsgClientAcc{
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: public},
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
InviteCode: "AbCdE",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: public},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1304,6 +1312,9 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
if len(r.messages) != 1 {
|
||||
t.Errorf("responses: expected 1, received %d.", len(r.messages))
|
||||
}
|
||||
if !invites.useRecorded {
|
||||
t.Error("registration invite use was not recorded")
|
||||
}
|
||||
resp := r.messages[0].(*ServerComMessage)
|
||||
if resp == nil {
|
||||
t.Fatal("Response must be ServerComMessage")
|
||||
|
||||
@@ -354,17 +354,17 @@ func (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceM
|
||||
}
|
||||
|
||||
// AddAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
|
||||
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, secret, expires)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AddAuthRecord indicates an expected call of AddAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, secret, expires interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// ConfirmCred mocks base method.
|
||||
@@ -396,18 +396,18 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Create(tenantID, user, priv
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), tenantID, user, private)
|
||||
}
|
||||
|
||||
// DelAuthRecords mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
|
||||
// DeleteAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DelAuthRecords", tenantID, uid, scheme)
|
||||
ret := m.ctrl.Call(m, "DeleteAuthRecord", tenantID, uid)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DelAuthRecords indicates an expected call of DelAuthRecords.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(tenantID, uid, scheme interface{}) *gomock.Call {
|
||||
// DeleteAuthRecord indicates an expected call of DeleteAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) DeleteAuthRecord(tenantID, uid interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelAuthRecords", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), tenantID, uid, scheme)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DeleteAuthRecord), tenantID, uid)
|
||||
}
|
||||
|
||||
// DelCred mocks base method.
|
||||
@@ -532,6 +532,21 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(tenantID interface{}
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), varargs...)
|
||||
}
|
||||
|
||||
// GetByPhone mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetByPhone", tenantID, phone)
|
||||
ret0, _ := ret[0].(types.Uid)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetByPhone indicates an expected call of GetByPhone.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetByPhone(tenantID, phone interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByPhone", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByPhone), tenantID, phone)
|
||||
}
|
||||
|
||||
// GetAllCreds mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -548,9 +563,9 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(tenantID, id, m
|
||||
}
|
||||
|
||||
// GetAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
|
||||
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user, scheme)
|
||||
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(auth.Level)
|
||||
ret2, _ := ret[2].([]byte)
|
||||
@@ -560,15 +575,15 @@ func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, u
|
||||
}
|
||||
|
||||
// GetAuthRecord indicates an expected call of GetAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user, scheme interface{}) *gomock.Call {
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user, scheme)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user)
|
||||
}
|
||||
|
||||
// GetAuthUniqueRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
// GetAuthByLogin mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAuthUniqueRecord", tenantID, scheme, unique)
|
||||
ret := m.ctrl.Call(m, "GetAuthByLogin", tenantID, login)
|
||||
ret0, _ := ret[0].(types.Uid)
|
||||
ret1, _ := ret[1].(auth.Level)
|
||||
ret2, _ := ret[2].([]byte)
|
||||
@@ -577,10 +592,10 @@ func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.Tenan
|
||||
return ret0, ret1, ret2, ret3, ret4
|
||||
}
|
||||
|
||||
// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(tenantID, scheme, unique interface{}) *gomock.Call {
|
||||
// GetAuthByLogin indicates an expected call of GetAuthByLogin.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthByLogin(tenantID, login interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthUniqueRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), tenantID, scheme, unique)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthByLogin", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthByLogin), tenantID, login)
|
||||
}
|
||||
|
||||
// GetByCred mocks base method.
|
||||
@@ -723,17 +738,17 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Update(tenantID, uid, updat
|
||||
}
|
||||
|
||||
// UpdateAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
|
||||
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, username, secret, expires)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateAuthRecord indicates an expected call of UpdateAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, username, secret, expires interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, username, secret, expires)
|
||||
}
|
||||
|
||||
// UpdateLastSeen mocks base method.
|
||||
|
||||
+172
-28
@@ -137,6 +137,24 @@ type tenantsMapper struct{}
|
||||
// Tenants is the tenant registry access object.
|
||||
var Tenants TenantsPersistenceInterface
|
||||
|
||||
// RegistrationConfigPersistenceInterface exposes global registration policy.
|
||||
type RegistrationConfigPersistenceInterface interface {
|
||||
SMSEnabled() (bool, error)
|
||||
}
|
||||
|
||||
type registrationConfigMapper struct{}
|
||||
|
||||
// RegistrationConfig reads registration policy from the control database.
|
||||
var RegistrationConfig RegistrationConfigPersistenceInterface
|
||||
|
||||
func (registrationConfigMapper) SMSEnabled() (bool, error) {
|
||||
configAdp, ok := adp.(adapter.RegistrationConfigAdapter)
|
||||
if !ok {
|
||||
return true, types.ErrUnsupported
|
||||
}
|
||||
return configAdp.RegistrationSMSEnabled()
|
||||
}
|
||||
|
||||
// GetByCode resolves a tenant by its public enterprise code.
|
||||
func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
|
||||
tenantAdp, ok := adp.(adapter.TenantAdapter)
|
||||
@@ -368,13 +386,14 @@ func (s storeObj) DbStats() func() any {
|
||||
// UsersPersistenceInterface is an interface which defines methods for persistent storage of user records.
|
||||
type UsersPersistenceInterface interface {
|
||||
Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error)
|
||||
GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
|
||||
GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error)
|
||||
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
|
||||
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
|
||||
DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error
|
||||
GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error)
|
||||
GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error)
|
||||
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
|
||||
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error
|
||||
DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error
|
||||
Get(tenantID types.TenantID, uid types.Uid) (*types.User, error)
|
||||
GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error)
|
||||
GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error)
|
||||
GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error)
|
||||
Delete(tenantID types.TenantID, id types.Uid, hard bool) error
|
||||
UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error
|
||||
@@ -455,61 +474,50 @@ func (usersMapper) Create(tenantID types.TenantID, user *types.User, private any
|
||||
|
||||
// GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and
|
||||
// authentication secret.
|
||||
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
|
||||
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return "", auth.LevelNone, nil, time.Time{}, err
|
||||
}
|
||||
unique, authLvl, secret, expires, err := tenantAdp.TenantAuthGetRecord(tenantID, user, scheme)
|
||||
if err == nil {
|
||||
parts := strings.Split(unique, ":")
|
||||
if len(parts) > 1 {
|
||||
unique = parts[1]
|
||||
} else {
|
||||
err = types.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
return unique, authLvl, secret, expires, err
|
||||
return tenantAdp.TenantAuthGetRecord(tenantID, user)
|
||||
}
|
||||
|
||||
// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and
|
||||
// authentication secret.
|
||||
func (usersMapper) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
// GetAuthByLogin fetches a Basic authentication record by username or phone.
|
||||
func (usersMapper) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return types.ZeroUid, auth.LevelNone, nil, time.Time{}, err
|
||||
}
|
||||
return tenantAdp.TenantAuthGetUniqueRecord(tenantID, scheme+":"+unique)
|
||||
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
|
||||
}
|
||||
|
||||
// AddAuthRecord creates a new authentication record for the given user.
|
||||
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte,
|
||||
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte,
|
||||
expires time.Time) error {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantAuthAddRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
|
||||
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// UpdateAuthRecord updates authentication record with a new secret and expiration time.
|
||||
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string,
|
||||
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string,
|
||||
secret []byte, expires time.Time) error {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantAuthUpdRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
|
||||
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// DelAuthRecords deletes user's auth records of the given scheme.
|
||||
func (usersMapper) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
|
||||
// DeleteAuthRecord deletes the user's Basic authentication record.
|
||||
func (usersMapper) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantAuthDelScheme(tenantID, uid, scheme)
|
||||
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
|
||||
}
|
||||
|
||||
// Get returns a user object for the given user ID or nil if the user is not found.
|
||||
@@ -552,6 +560,16 @@ func (usersMapper) GetByCred(tenantID types.TenantID, method, value string) (typ
|
||||
return tenantAdp.TenantUserGetByCred(tenantID, method, value)
|
||||
}
|
||||
|
||||
// GetByPhone resolves an account by the phone identity stored on im_users.
|
||||
// It intentionally does not require a completed tel credential.
|
||||
func (usersMapper) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return types.ZeroUid, err
|
||||
}
|
||||
return tenantAdp.TenantUserGetByPhone(tenantID, phone)
|
||||
}
|
||||
|
||||
// Delete deletes user records.
|
||||
func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
@@ -3062,6 +3080,25 @@ type verificationMapper struct{}
|
||||
// Verifications is a singleton for product verification challenges.
|
||||
var Verifications VerificationPersistenceInterface
|
||||
|
||||
// RegistrationInvitePersistenceInterface manages tenant-scoped registration invitations.
|
||||
type RegistrationInvitePersistenceInterface interface {
|
||||
Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error
|
||||
CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error
|
||||
GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error)
|
||||
List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error)
|
||||
GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error)
|
||||
GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error)
|
||||
Revoke(tenantID types.TenantID, codeID int64, now time.Time) error
|
||||
RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error)
|
||||
RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error
|
||||
SyncUsage(tenantID types.TenantID, codeID int64) error
|
||||
}
|
||||
|
||||
type registrationInviteMapper struct{}
|
||||
|
||||
// RegistrationInvites is the tenant-scoped registration invitation store.
|
||||
var RegistrationInvites RegistrationInvitePersistenceInterface
|
||||
|
||||
// UserSecurityPersistenceInterface defines user security state used to
|
||||
// invalidate previously issued login tokens.
|
||||
type UserSecurityPersistenceInterface interface {
|
||||
@@ -3267,6 +3304,111 @@ func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHas
|
||||
return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt)
|
||||
}
|
||||
|
||||
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
|
||||
if !tenantID.IsValid() {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
if router, ok := adp.(adapter.TenantRoutedAdapter); ok {
|
||||
business, _, err := router.TenantAdapterFor(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inviteAdp, ok := business.(adapter.TenantRegistrationInviteAdapter); ok {
|
||||
return inviteAdp, nil
|
||||
}
|
||||
}
|
||||
if inviteAdp, ok := adp.(adapter.TenantRegistrationInviteAdapter); ok {
|
||||
return inviteAdp, nil
|
||||
}
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error {
|
||||
if code == nil {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteCreate(tenantID, code)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error {
|
||||
if batch == nil || len(codes) == 0 {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteCreateBatch(tenantID, batch, codes)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteGetByCode(tenantID, code)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteList(tenantID, opts)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteBatchGet(tenantID, batchID)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteBatchGetByIdempotency(tenantID, key)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) Revoke(tenantID types.TenantID, codeID int64, now time.Time) error {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteRevoke(tenantID, codeID, now)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteBatchRevoke(tenantID, batchID, now)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteRecordUse(tenantID, code, uid, requestID, now)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) SyncUsage(tenantID types.TenantID, codeID int64) error {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteSyncUsage(tenantID, codeID)
|
||||
}
|
||||
|
||||
// Get reads a persistent cache entry.
|
||||
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
@@ -3310,6 +3452,7 @@ func SetTestUidGenerator(g types.UidGenerator) {
|
||||
func init() {
|
||||
Store = storeObj{}
|
||||
Tenants = tenantsMapper{}
|
||||
RegistrationConfig = registrationConfigMapper{}
|
||||
Users = usersMapper{}
|
||||
Topics = topicsMapper{}
|
||||
Subs = subsMapper{}
|
||||
@@ -3330,6 +3473,7 @@ func init() {
|
||||
Files = fileMapper{}
|
||||
PCache = pcacheMapper{}
|
||||
Verifications = verificationMapper{}
|
||||
RegistrationInvites = registrationInviteMapper{}
|
||||
Security = userSecurityMapper{}
|
||||
Relations = relationsMapper{}
|
||||
Favorites = favoritesMapper{}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInviteInvalid = errors.New("registration invite invalid")
|
||||
ErrInviteExpired = errors.New("registration invite expired")
|
||||
ErrInviteRevoked = errors.New("registration invite revoked")
|
||||
ErrInviteIdempotencyConflict = errors.New("registration invite idempotency conflict")
|
||||
)
|
||||
|
||||
type RegistrationInviteStatus string
|
||||
|
||||
const (
|
||||
RegistrationInviteActive RegistrationInviteStatus = "active"
|
||||
RegistrationInviteExpired RegistrationInviteStatus = "expired"
|
||||
RegistrationInviteRevoked RegistrationInviteStatus = "revoked"
|
||||
)
|
||||
|
||||
type RegistrationInviteBatch struct {
|
||||
ID int64 `db:"id" json:"batch_id"`
|
||||
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
|
||||
Quantity int `db:"quantity" json:"quantity"`
|
||||
IdempotencyKey string `db:"idempotency_key" json:"-"`
|
||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||
CreatedBy int64 `db:"created_by" json:"created_by"`
|
||||
Remark string `db:"remark" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ActiveCount int `db:"active_count" json:"active_count"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
ExpiredCount int `db:"expired_count" json:"expired_count"`
|
||||
RevokedCount int `db:"revoked_count" json:"revoked_count"`
|
||||
}
|
||||
|
||||
type RegistrationInviteCode struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
|
||||
BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Status RegistrationInviteStatus `db:"status" json:"status"`
|
||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
|
||||
CreatedBy int64 `db:"created_by" json:"created_by"`
|
||||
Remark string `db:"remark" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type RegistrationInviteListOptions struct {
|
||||
Code string
|
||||
Status RegistrationInviteStatus
|
||||
BatchID int64
|
||||
CreatedBy int64
|
||||
CreatedAtStart *time.Time
|
||||
CreatedAtEnd *time.Time
|
||||
ExpiresAtStart *time.Time
|
||||
ExpiresAtEnd *time.Time
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type RegistrationInvitePage struct {
|
||||
Items []RegistrationInviteCode
|
||||
Total int
|
||||
}
|
||||
@@ -521,6 +521,8 @@ type User struct {
|
||||
UserAgent string
|
||||
// Canonical mobile number used for registration and account recovery.
|
||||
Phone string
|
||||
// Primary login account. Ordinary accounts are lowercase ASCII names; phone accounts use E.164.
|
||||
Username string
|
||||
|
||||
Public any
|
||||
Trusted any
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@
|
||||
"logical_names": [],
|
||||
"basic": {
|
||||
"add_to_tags": false,
|
||||
"min_login_length": 4,
|
||||
"min_login_length": 1,
|
||||
"min_password_length": 6
|
||||
},
|
||||
"token": {
|
||||
|
||||
+154
-6
@@ -34,13 +34,27 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
|
||||
// Find authenticator for the requested scheme.
|
||||
authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)
|
||||
if authhdl == nil {
|
||||
if authhdl == nil || msg.Acc.Scheme != "basic" {
|
||||
// New accounts must have an authentication scheme
|
||||
s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
|
||||
logs.Warn.Println("create user: unknown auth handler, sid=", s.sid)
|
||||
return
|
||||
}
|
||||
|
||||
account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
|
||||
if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) {
|
||||
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
|
||||
}
|
||||
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
|
||||
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -52,6 +66,8 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
|
||||
var user types.User
|
||||
user.TenantID = s.tenantID
|
||||
user.Username = account
|
||||
user.Phone = phone
|
||||
var private any
|
||||
|
||||
// If account state is being assigned, make sure the sender is a root user.
|
||||
@@ -85,9 +101,15 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
// consequently cannot check presence of required credentials. Must do that later.
|
||||
creds := normalizeCredentials(msg.Acc.Cred, true)
|
||||
var registrationPhone string
|
||||
var registrationTelCredential bool
|
||||
for i := range creds {
|
||||
cr := &creds[i]
|
||||
vld := store.Store.GetValidator(cr.Method)
|
||||
if vld == nil || !vld.IsInitialized() {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": cr.Method}))
|
||||
return
|
||||
}
|
||||
normalized, err := vld.PreCheck(cr.Value, cr.Params)
|
||||
if err != nil {
|
||||
logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid)
|
||||
@@ -105,15 +127,40 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
return
|
||||
}
|
||||
registrationPhone = phone
|
||||
registrationTelCredential = true
|
||||
}
|
||||
}
|
||||
if registrationPhone == "" && store.Verifications != nil {
|
||||
logs.Warn.Println("create user: verified phone is required, sid=", s.sid)
|
||||
if !phoneRegistration && registrationTelCredential {
|
||||
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"creds": []string{"tel"}}))
|
||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
|
||||
return
|
||||
}
|
||||
user.Phone = registrationPhone
|
||||
if phoneRegistration && registrationTelCredential && 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 {
|
||||
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"}))
|
||||
return
|
||||
}
|
||||
inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential)
|
||||
if inviteRequired && inviteCode == "" {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"}))
|
||||
return
|
||||
}
|
||||
if inviteCode != "" {
|
||||
if err := validateRegistrationInvite(s.tenantID, inviteCode, time.Now().UTC()); err != nil {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Assign default access values in case the acc creator has not provided them
|
||||
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) |
|
||||
@@ -155,7 +202,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
}
|
||||
|
||||
// Add authentication record.
|
||||
rec, err := authhdl.AddRecord(authCtx,
|
||||
rec, err = authhdl.AddRecord(authCtx,
|
||||
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
|
||||
if err != nil {
|
||||
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
|
||||
@@ -201,6 +248,34 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if registrationTelCredential && !slices.Contains(validated, "tel") {
|
||||
logs.Warn.Println("create user: phone credential was not validated, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
|
||||
if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil {
|
||||
logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid)
|
||||
}
|
||||
return
|
||||
}
|
||||
if inviteCode != "" {
|
||||
requestID := strings.TrimSpace(msg.Id)
|
||||
if requestID == "" {
|
||||
requestID = "acc:" + s.sid
|
||||
}
|
||||
if len(requestID) > 64 {
|
||||
requestID = requestID[:64]
|
||||
}
|
||||
if err = store.RegistrationInvites.RecordUse(s.tenantID, inviteCode, user.Uid(), requestID, time.Now().UTC()); err != nil {
|
||||
logs.Warn.Println("create user: record invite use failed", err, "sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
|
||||
if deleteErr := store.Users.Delete(s.tenantID, user.Uid(), true); deleteErr != nil {
|
||||
logs.Warn.Println("create user: failed to delete incomplete user record", deleteErr, "sid=", s.sid)
|
||||
}
|
||||
return
|
||||
}
|
||||
dispatchIMBusinessEvents(s.tenantID)
|
||||
}
|
||||
|
||||
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
|
||||
if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil {
|
||||
@@ -240,6 +315,79 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
pluginAccount(&user, plgActCreate)
|
||||
}
|
||||
|
||||
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) {
|
||||
if phone != "" {
|
||||
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
|
||||
return "", "", false, types.ErrPolicy
|
||||
}
|
||||
canonicalPhone, err = normalizeIMPhone(phone, "CN")
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return canonicalPhone, canonicalPhone, true, nil
|
||||
}
|
||||
if username == "" || len(username) > 10 {
|
||||
return "", "", false, types.ErrPolicy
|
||||
}
|
||||
for i := range username {
|
||||
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
|
||||
return "", "", false, types.ErrPolicy
|
||||
}
|
||||
}
|
||||
return username, "", false, nil
|
||||
}
|
||||
|
||||
func asciiRegistrationDigits(value string) bool {
|
||||
for i := range value {
|
||||
if value[i] < '0' || value[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func registrationSecretMatches(secret []byte, account string) bool {
|
||||
separator := strings.IndexByte(string(secret), ':')
|
||||
return separator > 0 && string(secret[:separator]) == account
|
||||
}
|
||||
|
||||
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
|
||||
if store.RegistrationInvites == nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
invite, err := store.RegistrationInvites.GetByCode(tenantID, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if invite == nil {
|
||||
return types.ErrInviteInvalid
|
||||
}
|
||||
switch invite.Status {
|
||||
case types.RegistrationInviteRevoked:
|
||||
return types.ErrInviteRevoked
|
||||
case types.RegistrationInviteExpired:
|
||||
return types.ErrInviteExpired
|
||||
case types.RegistrationInviteActive:
|
||||
if !now.Before(invite.ExpiresAt) {
|
||||
return types.ErrInviteExpired
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return types.ErrInviteInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func registrationInviteReason(err error) string {
|
||||
switch err {
|
||||
case types.ErrInviteExpired:
|
||||
return "INVITE_CODE_EXPIRED"
|
||||
case types.ErrInviteRevoked:
|
||||
return "INVITE_CODE_REVOKED"
|
||||
default:
|
||||
return "INVITE_CODE_INVALID"
|
||||
}
|
||||
}
|
||||
|
||||
// Process update to an account:
|
||||
// * Authentication update, i.e. login/password change
|
||||
// * Credentials update
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestResolveRegistrationAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
phone string
|
||||
wantAccount string
|
||||
wantPhone string
|
||||
wantPhoneFlow 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: "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},
|
||||
{name: "phone whitespace rejected", username: "alice", phone: " 13800138000", wantErr: true},
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) {
|
||||
if !registrationSecretMatches([]byte("alice:password:with-colon"), "alice") {
|
||||
t.Fatal("matching account was rejected")
|
||||
}
|
||||
if registrationSecretMatches([]byte("bob:password"), "alice") || registrationSecretMatches([]byte("password"), "alice") {
|
||||
t.Fatal("mismatched or malformed secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationInviteStates(t *testing.T) {
|
||||
oldInvites := store.RegistrationInvites
|
||||
defer func() { store.RegistrationInvites = oldInvites }()
|
||||
now := time.Now().UTC()
|
||||
tests := []struct {
|
||||
status types.RegistrationInviteStatus
|
||||
expires time.Time
|
||||
want error
|
||||
}{
|
||||
{types.RegistrationInviteActive, now.Add(time.Minute), nil},
|
||||
{types.RegistrationInviteActive, now, types.ErrInviteExpired},
|
||||
{types.RegistrationInviteRevoked, now.Add(time.Minute), types.ErrInviteRevoked},
|
||||
{types.RegistrationInviteExpired, now.Add(time.Minute), types.ErrInviteExpired},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{Status: tc.status, ExpiresAt: tc.expires}}
|
||||
if got := validateRegistrationInvite(7, "AbCdE", now); got != tc.want {
|
||||
t.Fatalf("status=%q: got %v want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +173,17 @@ func TestServeIMPasswordResetConfirm(t *testing.T) {
|
||||
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
}}
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic").
|
||||
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42)).
|
||||
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
|
||||
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).
|
||||
Return(&types.User{Phone: "+8613800138000"}, nil)
|
||||
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
|
||||
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
|
||||
if cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
|
||||
t.Fatalf("unexpected reset credential: %#v", cred)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
store.Users = users
|
||||
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
basicAuth.EXPECT().IsInitialized().Return(true)
|
||||
|
||||
@@ -214,6 +214,31 @@ func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCode synchronously delivers a product verification code. Unlike the
|
||||
// legacy credential flow, it fails when no real SMS provider is configured.
|
||||
func (v *validator) SendCode(_ t.TenantID, phone, lang, scene, code string) error {
|
||||
if v.Twilio == nil {
|
||||
return t.ErrUnsupported
|
||||
}
|
||||
var template *textt.Template
|
||||
if v.langMatcher != nil {
|
||||
_, idx := i18n.MatchStrings(v.langMatcher, lang)
|
||||
template = v.universalTempl[idx]
|
||||
} else if len(v.universalTempl) > 0 {
|
||||
template = v.universalTempl[0]
|
||||
}
|
||||
if template == nil {
|
||||
return t.ErrInternal
|
||||
}
|
||||
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
|
||||
"Code": code, "HostUrl": v.HostUrl, "Scene": scene,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return twilioSend(v.Sender, phone, content[""])
|
||||
}
|
||||
|
||||
// Check checks validity of user's response.
|
||||
func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) {
|
||||
cred, err := store.Users.GetActiveCred(tenantID, user, validatorName)
|
||||
|
||||
@@ -58,6 +58,12 @@ type Validator interface {
|
||||
TempAuthScheme() (string, error)
|
||||
}
|
||||
|
||||
// CodeSender is an optional synchronous delivery capability used by product
|
||||
// verification challenges which exist before a user account is created.
|
||||
type CodeSender interface {
|
||||
SendCode(tenantID t.TenantID, recipient, lang, scene, code string) error
|
||||
}
|
||||
|
||||
func ValidateHostURL(origUrl string) (string, error) {
|
||||
hostUrl, err := url.Parse(origUrl)
|
||||
if err != nil {
|
||||
|
||||
+3
-10
@@ -27,7 +27,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
|
||||
// Add authentication record
|
||||
authHandler := store.Store.GetAuthHandler("basic")
|
||||
authHandler.Init([]byte(`{"add_to_tags": true}`), "basic")
|
||||
authHandler.Init([]byte(`{"add_to_tags": false}`), "basic")
|
||||
|
||||
nameIndex := make(map[string]string, len(data.Users))
|
||||
|
||||
@@ -40,12 +40,13 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
}
|
||||
user := types.User{
|
||||
TenantID: tenantID,
|
||||
Username: uu.Username,
|
||||
Phone: uu.Tel,
|
||||
State: state,
|
||||
Access: types.DefaultAccess{
|
||||
Auth: types.ModeCAuth,
|
||||
Anon: types.ModeNone,
|
||||
},
|
||||
Tags: uu.Tags,
|
||||
Public: parsePublic(&uu.Public, data.datapath),
|
||||
}
|
||||
if !uu.Trusted.IsZero() {
|
||||
@@ -53,14 +54,6 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
}
|
||||
user.CreatedAt = getCreatedTime(uu.CreatedAt)
|
||||
|
||||
user.Tags = append(user.Tags, "basic:"+uu.Username)
|
||||
if uu.Email != "" {
|
||||
user.Tags = append(user.Tags, "email:"+uu.Email)
|
||||
}
|
||||
if uu.Tel != "" {
|
||||
user.Tags = append(user.Tags, "tel:"+uu.Tel)
|
||||
}
|
||||
|
||||
// store.Users.Create will subscribe user to !me topic but won't create a !me topic
|
||||
if _, err := store.Users.Create(tenantID, &user, uu.Private); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
+3
-3
@@ -321,17 +321,17 @@ func main() {
|
||||
if userId.IsZero() {
|
||||
log.Fatalf("Must specify a valid user ID '%s' to promote to ROOT", *makeRoot)
|
||||
}
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId, "basic")
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to read user authentication record", err)
|
||||
}
|
||||
tenantAdapter, ok := adapter.(interface {
|
||||
TenantAuthUpdRecord(types.TenantID, types.Uid, string, string, auth.Level, []byte, time.Time) error
|
||||
TenantAuthUpdateRecord(types.TenantID, types.Uid, string, auth.Level, []byte, time.Time) error
|
||||
})
|
||||
if !ok {
|
||||
log.Fatalln("Configured adapter does not support tenant authentication")
|
||||
}
|
||||
if err = tenantAdapter.TenantAuthUpdRecord(tenantID, userId, "basic", "basic:"+login,
|
||||
if err = tenantAdapter.TenantAuthUpdateRecord(tenantID, userId, login,
|
||||
auth.LevelRoot, nil, time.Time{}); err != nil {
|
||||
log.Fatalln("Failed to promote user to ROOT", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user