feat: validate personal and enterprise auth tenants
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
# IM 个人/企业注册登录前后端对接说明
|
||||
|
||||
## 1. 目标
|
||||
|
||||
当前客户端没有独立的企业码进入页面,登录页和注册页由用户选择“个人用户”或“企业用户”。前端提交登录和注册时必须显式带类型,后端不能再通过企业码是否为空来推断个人/企业。
|
||||
|
||||
核心规则:
|
||||
|
||||
1. 前端新增字段 `registration_type`,类型为 int,取值只能是 `0` 或 `1`。
|
||||
2. `registration_type=1` 时,后端按个人注册处理,内部租户使用 `default`,数据写入 `default` 租户业务库。
|
||||
3. `registration_type=0` 时,前端必须提交企业码,后端必须校验企业存在、启用且允许注册。
|
||||
4. 登录新增字段 `login_type`,类型为 int,取值同样是 `0` 企业、`1` 个人。
|
||||
5. 企业登录或企业注册都不允许使用 `default`。
|
||||
6. `default` 是系统保留的个人租户码,不允许作为企业码输入。
|
||||
|
||||
一句话规则:**注册类型由 `registration_type` 决定,登录类型由 `login_type` 决定;个人落 `default`,企业必须校验企业码。**
|
||||
|
||||
## 2. 前端注册字段
|
||||
|
||||
注册页提交发码和最终注册时都带同一组注册上下文字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `registration_type` | int | 是 | `0` 表示企业注册,`1` 表示个人注册 |
|
||||
| `tenant_code` | string | 企业注册必填 | 企业码;个人注册不填或传空字符串 |
|
||||
|
||||
前端规则:
|
||||
|
||||
| `registration_type` | `tenant_code` | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 空 | 允许提交,后端内部使用 `default` |
|
||||
| `1` | 非空 | 后端拒绝,避免误入库 |
|
||||
| `0` | 空 | 后端拒绝,提示请输入企业码 |
|
||||
| `0` | `default`、`Default`、`DEFAULT` | 后端拒绝 |
|
||||
| `0` | 其他值 | 后端在注册链路中校验 |
|
||||
|
||||
用户在企业注册模式输入 `default` 时,建议提示:
|
||||
|
||||
```text
|
||||
default 是系统保留企业码。个人注册请选择个人用户,企业注册请输入企业提供的企业码。
|
||||
```
|
||||
|
||||
## 3. 企业码校验
|
||||
|
||||
不新增单独的企业码校验接口,也不要求前端在注册前调用 `/v1/im/tenants/resolve`。
|
||||
|
||||
后端在注册发码和最终注册链路中校验:
|
||||
|
||||
1. `registration_type=1`
|
||||
- 不要求企业码。
|
||||
- 即使请求 Header 或内部路由使用 `default`,也属于服务端内部租户,不是用户输入企业码。
|
||||
- 创建用户时写入 `default` 租户业务库。
|
||||
|
||||
2. `registration_type=0`
|
||||
- `tenant_code` 必填。
|
||||
- `tenant_code` 大小写不敏感等于 `default` 时拒绝。
|
||||
- 查询企业是否存在、启用、允许注册。
|
||||
- 通过后在对应企业租户业务库创建用户。
|
||||
|
||||
企业不存在、停用或不允许注册时,客户端统一提示:
|
||||
|
||||
```text
|
||||
企业不存在或不可用
|
||||
```
|
||||
|
||||
客户端不要区分不存在、停用、禁用注册等内部原因。
|
||||
|
||||
`/v1/im/tenants/resolve` 只保留给服务发现、品牌配置、历史企业入口等场景使用;当前注册页不依赖它完成主流程。
|
||||
|
||||
## 4. 前端登录字段
|
||||
|
||||
登录页提交 WebSocket `{login}` 时带登录上下文字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `login_type` | int | basic 登录必填 | `0` 表示企业登录,`1` 表示个人登录 |
|
||||
| `tenant_code` | string | 企业登录必填 | 企业码;个人登录不填、传空字符串或内部传 `default` |
|
||||
|
||||
登录规则:
|
||||
|
||||
| `login_type` | `tenant_code` / `hi.tenant` | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
| `1` | `default` | 个人登录,允许 |
|
||||
| `1` | 非 `default` | 拒绝,避免个人登录误进企业租户 |
|
||||
| `0` | 空 | 拒绝,提示请输入企业码 |
|
||||
| `0` | `default`、`Default`、`DEFAULT` | 拒绝,统一提示“企业不存在或不可用” |
|
||||
| `0` | 其他值 | 企业登录,后端按该租户认证 |
|
||||
|
||||
企业用户如果在企业入口输入 `default`,后端返回策略错误,错误参数为 `what=tenant_code`、`reason=TENANT_CODE_FORBIDDEN`;客户端统一展示:
|
||||
|
||||
```text
|
||||
企业不存在或不可用
|
||||
```
|
||||
|
||||
个人登录 `{login}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"login": {
|
||||
"id": "login-1",
|
||||
"scheme": "basic",
|
||||
"login_type": 1,
|
||||
"tenant_code": "",
|
||||
"secret": "base64(account:password)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
企业登录 `{login}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"login": {
|
||||
"id": "login-1",
|
||||
"scheme": "basic",
|
||||
"login_type": 0,
|
||||
"tenant_code": "acme",
|
||||
"secret": "base64(account:password)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 个人注册流程
|
||||
|
||||
个人注册必须显式提交 `registration_type=1`。
|
||||
|
||||
1. 用户选择个人注册。
|
||||
2. 前端提交 `registration_type=1`,`tenant_code` 为空。
|
||||
3. 注册发码、验证码校验、WebSocket `hi`、`acc` 注册链路使用服务端内部租户 `default`。
|
||||
4. 服务端将用户创建到 `default` 租户对应的业务库。
|
||||
|
||||
发码示例:
|
||||
|
||||
```http
|
||||
POST /v1/im/auth/verification-codes
|
||||
X-MeetingIM-APIKey: <api_key>
|
||||
X-IM-Schema: 1
|
||||
X-Tenant-Code: default
|
||||
X-Request-ID: <uuid>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scene": "register",
|
||||
"registration_type": 1,
|
||||
"tenant_code": "",
|
||||
"phone": "19122421333"
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket 握手示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"hi": {
|
||||
"id": "hi-1",
|
||||
"ver": "0.25",
|
||||
"ua": "ios/1.0.0",
|
||||
"platf": "ios",
|
||||
"lang": "zh-CN",
|
||||
"tenant": "default"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最终注册 `{acc}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"acc": {
|
||||
"id": "acc-1",
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"registration_type": 1,
|
||||
"tenant_code": "",
|
||||
"username": "henry",
|
||||
"phone": "19122421333",
|
||||
"secret": "base64(henry:password)",
|
||||
"login": true,
|
||||
"cred": [
|
||||
{
|
||||
"meth": "tel",
|
||||
"val": "19122421333",
|
||||
"resp": "verification_token",
|
||||
"params": {"challenge_id": "challenge-id"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 企业注册流程
|
||||
|
||||
企业注册必须显式提交 `registration_type=0`。
|
||||
|
||||
1. 用户选择企业注册。
|
||||
2. 用户填写企业码,例如 `acme`。
|
||||
3. 前端直接提交用户输入的企业码,不做企业码存在性或保留字拦截。
|
||||
4. 获取验证码、提交注册时都带 `registration_type=0` 和 `tenant_code=acme`。
|
||||
5. 后端在注册链路中校验企业是否存在且可用。
|
||||
6. 校验通过后,服务端在该企业租户业务库中创建用户;校验不通过时返回“企业不存在或不可用”。
|
||||
|
||||
注册发码请求:
|
||||
|
||||
```http
|
||||
POST /v1/im/auth/verification-codes
|
||||
X-MeetingIM-APIKey: <api_key>
|
||||
X-IM-Schema: 1
|
||||
X-Tenant-Code: acme
|
||||
X-Request-ID: <uuid>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scene": "register",
|
||||
"registration_type": 0,
|
||||
"tenant_code": "acme",
|
||||
"phone": "19122421333"
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket `hi`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hi": {
|
||||
"id": "hi-1",
|
||||
"ver": "0.25",
|
||||
"ua": "android/1.0.0",
|
||||
"platf": "android",
|
||||
"lang": "zh-CN",
|
||||
"tenant": "acme"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最终注册 `{acc}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"acc": {
|
||||
"id": "acc-1",
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"registration_type": 0,
|
||||
"tenant_code": "acme",
|
||||
"username": "henry",
|
||||
"phone": "19122421333",
|
||||
"secret": "base64(henry:password)",
|
||||
"login": true,
|
||||
"cred": [
|
||||
{
|
||||
"meth": "tel",
|
||||
"val": "19122421333",
|
||||
"resp": "verification_token",
|
||||
"params": {"challenge_id": "challenge-id"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 后端校验规则
|
||||
|
||||
后端负责注册链路的最终校验,前端不做企业码拦截。
|
||||
|
||||
1. `registration_type` 缺失或不是 `0|1`
|
||||
- 拒绝注册。
|
||||
- 返回 `MALFORMED_REQUEST`,提示客户端升级或检查参数。
|
||||
|
||||
2. `registration_type=1`
|
||||
- 不从空企业码推断,而是由该字段明确进入个人注册。
|
||||
- 内部租户码设置为 `default`。
|
||||
- 拒绝用户提交的非空 `tenant_code`,避免误入库。
|
||||
- 注册时写入 `default` 租户业务库。
|
||||
|
||||
3. `registration_type=0`
|
||||
- `tenant_code` 必填。
|
||||
- 先 trim 首尾空格。
|
||||
- 如果大小写不敏感等于 `default`,拒绝注册。
|
||||
- 否则查询租户是否存在、是否启用、是否允许注册。
|
||||
- 校验通过后绑定对应租户。
|
||||
|
||||
建议错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 30000,
|
||||
"reason": "MALFORMED_REQUEST",
|
||||
"message": "invalid registration_type or tenant_code"
|
||||
}
|
||||
```
|
||||
|
||||
企业不可用时,客户端展示:
|
||||
|
||||
```text
|
||||
企业不存在或不可用
|
||||
```
|
||||
|
||||
## 8. 缓存和本地数据隔离
|
||||
|
||||
客户端本地缓存、Token、会话、草稿、附件缓存必须继续按 `tenant_code + user_id` 隔离。
|
||||
|
||||
个人注册用户登录后,缓存键中的 `tenant_code` 使用 `default`。企业用户登录后,使用对应企业码。切换个人/企业身份时必须清理当前连接并重新 `hi`,不能在同一个 WebSocket 中切换租户。
|
||||
|
||||
## 9. 验收用例
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| `login_type=1`,`hi.tenant=default` | 个人登录成功进入 `default` 租户认证 |
|
||||
| `login_type=1`,`hi.tenant=acme` | 拒绝登录 |
|
||||
| `login_type=0`,企业码空 | 拒绝登录,提示请输入企业码 |
|
||||
| `login_type=0`,企业码为 `default` | 拒绝登录,提示企业不存在或不可用 |
|
||||
| `login_type=0`,企业码有效 | 在该企业租户认证 |
|
||||
| `registration_type=1`,企业码空,手机号注册 | 成功进入 `default` 租户注册链路 |
|
||||
| `registration_type` 缺失 | 拒绝注册,返回参数错误 |
|
||||
| `registration_type=1`,企业码非空 | 拒绝或忽略,建议拒绝,避免误入库 |
|
||||
| `registration_type=0`,企业码空 | 拒绝注册,提示请输入企业码 |
|
||||
| `registration_type=0`,企业码为 `default` | 拒绝注册 |
|
||||
| `registration_type=0`,企业码不存在 | 发码或注册失败,提示企业不存在或不可用 |
|
||||
| `registration_type=0`,企业码已停用 | 统一提示企业不存在或不可用 |
|
||||
| `registration_type=0`,企业码有效 | 注册写入该企业租户库 |
|
||||
| 个人用户注册成功后登录 | `tenant_code=default`,可正常登录、收发消息 |
|
||||
|
||||
## 10. 对客户端的明确要求
|
||||
|
||||
1. 登录页和注册页都必须明确区分个人和企业。
|
||||
2. basic 登录必须提交 `login_type`。
|
||||
3. 发码和最终注册都必须提交 `registration_type`。
|
||||
4. 个人登录/注册提交类型 `1`,不要让用户输入或看到 `default`。
|
||||
5. 企业登录/注册提交类型 `0` 和用户输入的企业码。
|
||||
6. 企业用户手动输入 `default` 也直接提交,由后端返回错误,客户端按后端错误展示提示。
|
||||
7. 不需要为了注册主流程调用 `/v1/im/tenants/resolve`。
|
||||
|
||||
## 11. 对后端的明确要求
|
||||
|
||||
1. basic 登录链路必须以 `login_type` 作为个人/企业登录的判定来源。
|
||||
2. 注册链路必须以 `registration_type` 作为个人/企业注册的判定来源。
|
||||
3. 不允许仅根据企业码是否为空推断个人注册或个人登录。
|
||||
4. 登录、注册、发码、验证码校验、WebSocket `hi/login/acc` 链路保持同一租户上下文。
|
||||
5. 显式企业码 `default` 必须拒绝,防止用户绕过企业码校验。
|
||||
6. 企业登录/注册的企业码必须校验存在、启用、允许登录/注册。
|
||||
7. 日志中要能看出来源是 `personal_default` 还是 `enterprise_code`,方便排查误入库问题。
|
||||
+15
-13
@@ -160,8 +160,8 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
|
||||
|
||||
```text
|
||||
读取客户端预置的 host 和普通 API Key
|
||||
-> 用户输入/客户端读取 tenant_code
|
||||
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
|
||||
-> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
|
||||
-> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
|
||||
-> 带 API Key 完成 WebSocket Upgrade
|
||||
-> hi(tenant_code)
|
||||
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
|
||||
@@ -173,7 +173,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
|
||||
-> 进入可发送状态
|
||||
```
|
||||
|
||||
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
|
||||
当前服务端已提供 `/v1/im/tenants/resolve`,用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不把它作为必选前置校验。企业是否存在、是否可用必须在 `{hi}`、登录和注册链路中最终校验。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
|
||||
|
||||
`hi` 请求:
|
||||
|
||||
@@ -663,22 +663,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
<img src="images/企业码登录.png" alt="企业码登录" width="360">
|
||||
|
||||
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
|
||||
**状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
|
||||
|
||||
**当前系统的执行方式:**
|
||||
|
||||
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
|
||||
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
|
||||
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。
|
||||
- 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
|
||||
- 客户端从构建/环境配置读取 `host` 和普通 API Key,直接使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket;当前登录/注册主流程不要求先调用 `POST /v1/im/tenants/resolve`。
|
||||
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"登录/注册页确定的 tenant_code"}}`。
|
||||
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
|
||||
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。
|
||||
- 当前失败表现:企业码超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。登录/注册页统一展示“企业不存在或不可用”。
|
||||
|
||||
**企业解析接口:**
|
||||
|
||||
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
|
||||
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
|
||||
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
|
||||
- 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
|
||||
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
|
||||
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](IM-个人无企业码注册前后端对接说明.md)。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
@@ -810,13 +811,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
|
||||
|
||||
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
|
||||
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
|
||||
- 前端动作:企业码、账号、密码和协议勾选在同一个登录页完成;个人用户可不填企业码,企业用户填写企业码。账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
|
||||
- 协议:点击登录后先按页面企业码确定 `tenant_code` 并发送 WebSocket `{hi}`;`hi` 成功后发送 `{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-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
|
||||
- Token:当前复用 MeetingIM 单一登录 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` 按专用页面处理。
|
||||
- 错误:企业不存在、停用或不允许登录统一显示“企业不存在或不可用”;`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并留在登录页重新选择个人/企业身份;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
|
||||
|
||||
登录成功示例:
|
||||
|
||||
@@ -856,6 +857,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
|
||||
|
||||
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
|
||||
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
|
||||
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
|
||||
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
|
||||
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
|
||||
@@ -986,7 +988,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
|
||||
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
|
||||
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
|
||||
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
|
||||
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
|
||||
|
||||
+15
-13
@@ -134,8 +134,8 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
|
||||
|
||||
```text
|
||||
读取客户端预置的 host 和普通 API Key
|
||||
-> 用户输入/客户端读取 tenant_code
|
||||
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
|
||||
-> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
|
||||
-> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
|
||||
-> 带 API Key 完成 WebSocket Upgrade
|
||||
-> hi(tenant_code)
|
||||
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
|
||||
@@ -147,7 +147,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
|
||||
-> 进入可发送状态
|
||||
```
|
||||
|
||||
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
|
||||
当前服务端已提供 `/v1/im/tenants/resolve`,用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不把它作为必选前置校验。企业是否存在、是否可用必须在 `{hi}`、登录和注册链路中最终校验。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
|
||||
|
||||
`hi` 请求:
|
||||
|
||||
@@ -633,22 +633,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
<img src="images/企业码登录.png" alt="企业码登录" width="360">
|
||||
|
||||
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
|
||||
**状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
|
||||
|
||||
**当前系统的执行方式:**
|
||||
|
||||
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
|
||||
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
|
||||
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。
|
||||
- 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
|
||||
- 客户端从构建/环境配置读取 `host` 和普通 API Key,直接使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket;当前登录/注册主流程不要求先调用 `POST /v1/im/tenants/resolve`。
|
||||
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"登录/注册页确定的 tenant_code"}}`。
|
||||
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
|
||||
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。
|
||||
- 当前失败表现:企业码超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。登录/注册页统一展示“企业不存在或不可用”。
|
||||
|
||||
**企业解析接口:**
|
||||
|
||||
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
|
||||
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
|
||||
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
|
||||
- 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
|
||||
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
|
||||
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](../IM-个人无企业码注册前后端对接说明.md)。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
@@ -780,13 +781,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
|
||||
|
||||
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
|
||||
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
|
||||
- 前端动作:企业码、账号、密码和协议勾选在同一个登录页完成;个人用户可不填企业码,企业用户填写企业码。账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
|
||||
- 协议:点击登录后先按页面企业码确定 `tenant_code` 并发送 WebSocket `{hi}`;`hi` 成功后发送 `{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-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
|
||||
- Token:当前复用 MeetingIM 单一登录 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` 按专用页面处理。
|
||||
- 错误:企业不存在、停用或不允许登录统一显示“企业不存在或不可用”;`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并留在登录页重新选择个人/企业身份;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
|
||||
|
||||
登录成功示例:
|
||||
|
||||
@@ -826,6 +827,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
|
||||
|
||||
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
|
||||
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
|
||||
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
|
||||
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
|
||||
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
|
||||
@@ -955,7 +957,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
|
||||
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
|
||||
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
|
||||
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
|
||||
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
|
||||
|
||||
@@ -152,6 +152,10 @@ type MsgClientAcc struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
|
||||
Phone string `json:"phone,omitempty"`
|
||||
// Registration type: 0 enterprise, 1 personal. Required when creating a new account.
|
||||
RegistrationType *int `json:"registration_type,omitempty"`
|
||||
// Tenant code submitted by client for enterprise registration. Personal registration keeps it empty.
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
// Deprecated compatibility field. New clients put a five-letter invitation
|
||||
// code in the shared verification input and submit it as the tel cred response.
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
@@ -173,6 +177,10 @@ type MsgClientLogin struct {
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
// Shared secret
|
||||
Secret []byte `json:"secret"`
|
||||
// Login type: 0 enterprise, 1 personal. Required for password login.
|
||||
LoginType *int `json:"login_type,omitempty"`
|
||||
// Tenant code submitted by client for enterprise login. Personal login keeps it empty or default.
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
// Credntials being verified (email or phone or captcha etc.)
|
||||
Cred []MsgCredClient `json:"cred,omitempty"`
|
||||
}
|
||||
|
||||
+63
-5
@@ -63,6 +63,12 @@ const (
|
||||
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
|
||||
)
|
||||
|
||||
const (
|
||||
imDefaultTenantCode = "default"
|
||||
registrationTypeEnterprise = 0
|
||||
registrationTypePersonal = 1
|
||||
)
|
||||
|
||||
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
@@ -291,10 +297,11 @@ type imTenantEntryFeedbackRequest struct {
|
||||
}
|
||||
|
||||
type imVerificationCreateRequest struct {
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
Scene string `json:"scene"`
|
||||
Phone string `json:"phone"`
|
||||
Country string `json:"country,omitempty"`
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
RegistrationType *int `json:"registration_type,omitempty"`
|
||||
Scene string `json:"scene"`
|
||||
Phone string `json:"phone"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
type imVerificationVerifyRequest struct {
|
||||
@@ -979,7 +986,13 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
|
||||
return
|
||||
}
|
||||
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
|
||||
var tenant *types.Tenant
|
||||
var ok bool
|
||||
if scene == "register" {
|
||||
tenant, ok = resolveIMHTTPRegistrationTenant(wrt, req, body)
|
||||
} else {
|
||||
tenant, ok = resolveIMHTTPTenant(wrt, req, body.TenantCode)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -9891,6 +9904,51 @@ func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCod
|
||||
return tenant, true
|
||||
}
|
||||
|
||||
func resolveIMHTTPRegistrationTenant(wrt http.ResponseWriter, req *http.Request, body imVerificationCreateRequest) (*types.Tenant, bool) {
|
||||
if body.RegistrationType == nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
headerCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
||||
bodyCode := strings.TrimSpace(body.TenantCode)
|
||||
var tenantCode string
|
||||
|
||||
switch *body.RegistrationType {
|
||||
case registrationTypePersonal:
|
||||
if bodyCode != "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
||||
return nil, false
|
||||
}
|
||||
if headerCode != "" && !strings.EqualFold(headerCode, imDefaultTenantCode) {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
||||
return nil, false
|
||||
}
|
||||
tenantCode = imDefaultTenantCode
|
||||
case registrationTypeEnterprise:
|
||||
if bodyCode == "" {
|
||||
bodyCode = headerCode
|
||||
} else if headerCode != "" && !strings.EqualFold(headerCode, bodyCode) {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
||||
return nil, false
|
||||
}
|
||||
if bodyCode == "" || len(bodyCode) > 64 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
||||
return nil, false
|
||||
}
|
||||
if strings.EqualFold(bodyCode, imDefaultTenantCode) {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
tenantCode = bodyCode
|
||||
default:
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return resolveIMHTTPTenant(wrt, req, tenantCode)
|
||||
}
|
||||
|
||||
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
|
||||
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
|
||||
Code: 0,
|
||||
|
||||
+55
-6
@@ -1736,7 +1736,7 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1786,7 +1786,7 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1798,6 +1798,55 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesPersonalUsesDefaultTenant(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 1, Code: imDefaultTenantCode, State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
|
||||
t.Fatal("registration SMS sender must not be called for fixed-code registration")
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","registration_type":1,"phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||
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)
|
||||
}
|
||||
if verifications.created.TenantID != 1 {
|
||||
t.Fatalf("tenant: got %d, want 1", verifications.created.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesRejectsEnterpriseDefaultTenant(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"default","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
resp := decodeIMHTTPResponse(t, rec)
|
||||
if resp.Reason != imReasonTenantUnavailable {
|
||||
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -1883,7 +1932,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
|
||||
|
||||
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
bytes.NewBufferString(`{"scene":"register","registration_type":0,"phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -1922,7 +1971,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -8242,12 +8291,12 @@ func TestIMMessageTypeDraftyEntities(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "legacy flat file_id content still works",
|
||||
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
|
||||
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "legacy flat url content still works",
|
||||
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
|
||||
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
|
||||
want: "link",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1143,6 +1143,12 @@ func (s *Session) login(msg *ClientComMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateLoginTenant(s, msg); err != nil {
|
||||
logs.Warn.Println("s.login: invalid login tenant", err, s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, loginTenantErrorParams(err)))
|
||||
return
|
||||
}
|
||||
|
||||
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
|
||||
if handler == nil {
|
||||
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp)
|
||||
|
||||
+60
-16
@@ -19,6 +19,10 @@ type testTenantStore struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func ptrInt(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
type testLoginDeviceStore struct {
|
||||
devices map[types.Uid][]types.DeviceDef
|
||||
updates int
|
||||
@@ -365,9 +369,11 @@ func TestDispatchLogin(t *testing.T) {
|
||||
|
||||
msg := &ClientComMessage{
|
||||
Login: &MsgClientLogin{
|
||||
Id: "123",
|
||||
Scheme: "basic",
|
||||
Secret: []byte(secret),
|
||||
Id: "123",
|
||||
Scheme: "basic",
|
||||
Secret: []byte(secret),
|
||||
LoginType: ptrInt(registrationTypeEnterprise),
|
||||
TenantCode: "test",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -425,7 +431,7 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
|
||||
|
||||
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: "acme"}
|
||||
s.login(&ClientComMessage{
|
||||
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret},
|
||||
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret, LoginType: ptrInt(registrationTypeEnterprise), TenantCode: "acme"},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
@@ -438,6 +444,40 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchLoginRejectsEnterpriseDefaultTenant(t *testing.T) {
|
||||
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode}
|
||||
s.login(&ClientComMessage{
|
||||
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password"), LoginType: ptrInt(registrationTypeEnterprise), TenantCode: imDefaultTenantCode},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
resp := (<-s.send).(*ServerComMessage)
|
||||
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
params, _ := resp.Ctrl.Params.(map[string]any)
|
||||
if params["reason"] != "TENANT_CODE_FORBIDDEN" {
|
||||
t.Fatalf("unexpected params: %+v", resp.Ctrl.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchLoginRequiresTypeForBasic(t *testing.T) {
|
||||
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode}
|
||||
s.login(&ClientComMessage{
|
||||
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password")},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
resp := (<-s.send).(*ServerComMessage)
|
||||
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
params, _ := resp.Ctrl.Params.(map[string]any)
|
||||
if params["reason"] != "LOGIN_TYPE_REQUIRED" {
|
||||
t.Fatalf("unexpected params: %+v", resp.Ctrl.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSysRequiresRoot(t *testing.T) {
|
||||
s := &Session{
|
||||
send: make(chan any, 1),
|
||||
@@ -1420,12 +1460,14 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
public := "public name"
|
||||
msg := &ClientComMessage{
|
||||
Acc: &MsgClientAcc{
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
Phone: "13800138000",
|
||||
Secret: []byte(secret),
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
Phone: "13800138000",
|
||||
RegistrationType: ptrInt(registrationTypeEnterprise),
|
||||
TenantCode: "test",
|
||||
Secret: []byte(secret),
|
||||
Cred: []MsgCredClient{{
|
||||
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
|
||||
Params: map[string]any{"challenge_id": "challenge-1"},
|
||||
@@ -1540,12 +1582,14 @@ func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) {
|
||||
|
||||
msg := &ClientComMessage{
|
||||
Acc: &MsgClientAcc{
|
||||
Id: "username-only",
|
||||
User: "new",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
|
||||
Id: "username-only",
|
||||
User: "new",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
RegistrationType: ptrInt(registrationTypeEnterprise),
|
||||
TenantCode: "test",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+124
@@ -43,6 +43,12 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateAccountRegistrationTenant(s, msg); err != nil {
|
||||
logs.Warn.Println("create user: invalid registration tenant fields", err, "sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, registrationTenantErrorParams(err)))
|
||||
return
|
||||
}
|
||||
|
||||
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
|
||||
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
|
||||
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
|
||||
@@ -382,6 +388,124 @@ func resolveRegistrationAccount(username, phone string) (account, canonicalPhone
|
||||
return username, "", username, false, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errRegistrationTypeRequired = errors.New("registration_type required")
|
||||
errRegistrationTypeInvalid = errors.New("registration_type invalid")
|
||||
errRegistrationTenantForbidden = errors.New("registration tenant forbidden")
|
||||
errRegistrationTenantRequired = errors.New("registration tenant required")
|
||||
errRegistrationTenantMismatch = errors.New("registration tenant mismatch")
|
||||
errLoginTypeRequired = errors.New("login_type required")
|
||||
errLoginTypeInvalid = errors.New("login_type invalid")
|
||||
)
|
||||
|
||||
func validateAccountRegistrationTenant(s *Session, msg *ClientComMessage) error {
|
||||
if msg == nil || msg.Acc == nil || msg.Acc.RegistrationType == nil {
|
||||
return errRegistrationTypeRequired
|
||||
}
|
||||
tenantCode := strings.TrimSpace(msg.Acc.TenantCode)
|
||||
switch *msg.Acc.RegistrationType {
|
||||
case registrationTypePersonal:
|
||||
if tenantCode != "" {
|
||||
return errRegistrationTenantForbidden
|
||||
}
|
||||
if !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
|
||||
return errRegistrationTenantMismatch
|
||||
}
|
||||
case registrationTypeEnterprise:
|
||||
if tenantCode == "" {
|
||||
return errRegistrationTenantRequired
|
||||
}
|
||||
if strings.EqualFold(tenantCode, imDefaultTenantCode) {
|
||||
return errRegistrationTenantForbidden
|
||||
}
|
||||
if !strings.EqualFold(s.tenantCode, tenantCode) {
|
||||
return errRegistrationTenantMismatch
|
||||
}
|
||||
default:
|
||||
return errRegistrationTypeInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registrationTenantErrorParams(err error) map[string]any {
|
||||
params := map[string]any{"what": "registration_type", "reason": "REGISTRATION_TYPE_INVALID"}
|
||||
switch {
|
||||
case errors.Is(err, errRegistrationTypeRequired):
|
||||
params["reason"] = "REGISTRATION_TYPE_REQUIRED"
|
||||
case errors.Is(err, errRegistrationTypeInvalid):
|
||||
params["reason"] = "REGISTRATION_TYPE_INVALID"
|
||||
case errors.Is(err, errRegistrationTenantRequired):
|
||||
params["what"] = "tenant_code"
|
||||
params["reason"] = "TENANT_CODE_REQUIRED"
|
||||
case errors.Is(err, errRegistrationTenantForbidden):
|
||||
params["what"] = "tenant_code"
|
||||
params["reason"] = "TENANT_CODE_FORBIDDEN"
|
||||
case errors.Is(err, errRegistrationTenantMismatch):
|
||||
params["what"] = "tenant_code"
|
||||
params["reason"] = "TENANT_CODE_MISMATCH"
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateLoginTenant(s *Session, msg *ClientComMessage) error {
|
||||
if msg == nil || msg.Login == nil {
|
||||
return nil
|
||||
}
|
||||
if msg.Login.Scheme != "basic" && msg.Login.LoginType == nil {
|
||||
return nil
|
||||
}
|
||||
if msg.Login.LoginType == nil {
|
||||
return errLoginTypeRequired
|
||||
}
|
||||
|
||||
tenantCode := strings.TrimSpace(msg.Login.TenantCode)
|
||||
switch *msg.Login.LoginType {
|
||||
case registrationTypePersonal:
|
||||
if tenantCode != "" && !strings.EqualFold(tenantCode, imDefaultTenantCode) {
|
||||
return errRegistrationTenantForbidden
|
||||
}
|
||||
if !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
|
||||
return errRegistrationTenantMismatch
|
||||
}
|
||||
case registrationTypeEnterprise:
|
||||
if tenantCode == "" {
|
||||
tenantCode = s.tenantCode
|
||||
}
|
||||
if tenantCode == "" {
|
||||
return errRegistrationTenantRequired
|
||||
}
|
||||
if strings.EqualFold(tenantCode, imDefaultTenantCode) {
|
||||
return errRegistrationTenantForbidden
|
||||
}
|
||||
if !strings.EqualFold(s.tenantCode, tenantCode) {
|
||||
return errRegistrationTenantMismatch
|
||||
}
|
||||
default:
|
||||
return errLoginTypeInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginTenantErrorParams(err error) map[string]any {
|
||||
params := map[string]any{"what": "login_type", "reason": "LOGIN_TYPE_INVALID"}
|
||||
switch {
|
||||
case errors.Is(err, errLoginTypeRequired):
|
||||
params["reason"] = "LOGIN_TYPE_REQUIRED"
|
||||
case errors.Is(err, errLoginTypeInvalid):
|
||||
params["reason"] = "LOGIN_TYPE_INVALID"
|
||||
case errors.Is(err, errRegistrationTenantRequired):
|
||||
params["what"] = "tenant_code"
|
||||
params["reason"] = "TENANT_CODE_REQUIRED"
|
||||
case errors.Is(err, errRegistrationTenantForbidden):
|
||||
params["what"] = "tenant_code"
|
||||
params["reason"] = "TENANT_CODE_FORBIDDEN"
|
||||
case errors.Is(err, errRegistrationTenantMismatch):
|
||||
params["what"] = "tenant_code"
|
||||
params["reason"] = "TENANT_CODE_MISMATCH"
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateRegistrationUsername(username string) error {
|
||||
if username == "" || len(username) > 10 {
|
||||
return types.ErrPolicy
|
||||
|
||||
Reference in New Issue
Block a user