feat: validate personal and enterprise auth tenants

This commit is contained in:
richjohnrambo
2026-09-21 21:24:25 +08:00
parent fc393cd685
commit 186cfa21c8
9 changed files with 689 additions and 53 deletions
@@ -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
View File
@@ -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"}` |
@@ -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"}` |
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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",
},
{
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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