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
|
```text
|
||||||
读取客户端预置的 host 和普通 API Key
|
读取客户端预置的 host 和普通 API Key
|
||||||
-> 用户输入/客户端读取 tenant_code
|
-> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
|
||||||
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
|
-> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
|
||||||
-> 带 API Key 完成 WebSocket Upgrade
|
-> 带 API Key 完成 WebSocket Upgrade
|
||||||
-> hi(tenant_code)
|
-> hi(tenant_code)
|
||||||
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
|
-> 收到 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` 请求:
|
`hi` 请求:
|
||||||
|
|
||||||
@@ -663,22 +663,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
<img src="images/企业码登录.png" alt="企业码登录" width="360">
|
<img src="images/企业码登录.png" alt="企业码登录" width="360">
|
||||||
|
|
||||||
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
|
**状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
|
||||||
|
|
||||||
**当前系统的执行方式:**
|
**当前系统的执行方式:**
|
||||||
|
|
||||||
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
|
- 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
|
||||||
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
|
- 客户端从构建/环境配置读取 `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":"用户输入的企业码"}}`。
|
- 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 登录。
|
- 服务端通过 `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"}`。
|
- `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`。
|
- 成功数据:`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`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
|
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `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。**
|
**状态:基本账号密码登录已实现;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 编码。
|
- 成功:读取 `{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`。
|
- 产品 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 应失效。
|
- 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`。
|
- 密码:服务端使用 `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`。两个字段同时为空时拒绝注册。
|
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `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-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
|
||||||
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
|
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
|
||||||
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
|
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
|
||||||
@@ -986,7 +988,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
| 接口 | 输入示例 | 成功 `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/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-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"}` |
|
| `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
|
```text
|
||||||
读取客户端预置的 host 和普通 API Key
|
读取客户端预置的 host 和普通 API Key
|
||||||
-> 用户输入/客户端读取 tenant_code
|
-> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
|
||||||
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
|
-> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
|
||||||
-> 带 API Key 完成 WebSocket Upgrade
|
-> 带 API Key 完成 WebSocket Upgrade
|
||||||
-> hi(tenant_code)
|
-> hi(tenant_code)
|
||||||
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
|
-> 收到 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` 请求:
|
`hi` 请求:
|
||||||
|
|
||||||
@@ -633,22 +633,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
<img src="images/企业码登录.png" alt="企业码登录" width="360">
|
<img src="images/企业码登录.png" alt="企业码登录" width="360">
|
||||||
|
|
||||||
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
|
**状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
|
||||||
|
|
||||||
**当前系统的执行方式:**
|
**当前系统的执行方式:**
|
||||||
|
|
||||||
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
|
- 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
|
||||||
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
|
- 客户端从构建/环境配置读取 `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":"用户输入的企业码"}}`。
|
- 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 登录。
|
- 服务端通过 `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"}`。
|
- `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`。
|
- 成功数据:`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`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
|
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `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。**
|
**状态:基本账号密码登录已实现;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 编码。
|
- 成功:读取 `{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`。
|
- 产品 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 应失效。
|
- 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`。
|
- 密码:服务端使用 `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、邀请码和短信开关逻辑。**
|
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
|
||||||
|
|
||||||
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
|
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `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-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
|
||||||
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
|
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
|
||||||
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
|
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
|
||||||
@@ -955,7 +957,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
| 接口 | 输入示例 | 成功 `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/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-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"}` |
|
| `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"`
|
Username string `json:"username,omitempty"`
|
||||||
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
|
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
|
||||||
Phone string `json:"phone,omitempty"`
|
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
|
// Deprecated compatibility field. New clients put a five-letter invitation
|
||||||
// code in the shared verification input and submit it as the tel cred response.
|
// code in the shared verification input and submit it as the tel cred response.
|
||||||
InviteCode string `json:"invite_code,omitempty"`
|
InviteCode string `json:"invite_code,omitempty"`
|
||||||
@@ -173,6 +177,10 @@ type MsgClientLogin struct {
|
|||||||
Scheme string `json:"scheme,omitempty"`
|
Scheme string `json:"scheme,omitempty"`
|
||||||
// Shared secret
|
// Shared secret
|
||||||
Secret []byte `json:"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.)
|
// Credntials being verified (email or phone or captcha etc.)
|
||||||
Cred []MsgCredClient `json:"cred,omitempty"`
|
Cred []MsgCredClient `json:"cred,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-5
@@ -63,6 +63,12 @@ const (
|
|||||||
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
|
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 {
|
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
@@ -291,10 +297,11 @@ type imTenantEntryFeedbackRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type imVerificationCreateRequest struct {
|
type imVerificationCreateRequest struct {
|
||||||
TenantCode string `json:"tenant_code,omitempty"`
|
TenantCode string `json:"tenant_code,omitempty"`
|
||||||
Scene string `json:"scene"`
|
RegistrationType *int `json:"registration_type,omitempty"`
|
||||||
Phone string `json:"phone"`
|
Scene string `json:"scene"`
|
||||||
Country string `json:"country,omitempty"`
|
Phone string `json:"phone"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type imVerificationVerifyRequest struct {
|
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"})
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
|
||||||
return
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -9891,6 +9904,51 @@ func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCod
|
|||||||
return tenant, true
|
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) {
|
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
|
||||||
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
|
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
|
|||||||
+55
-6
@@ -1736,7 +1736,7 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
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-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||||
req.Header.Set("X-Tenant-Code", "acme")
|
req.Header.Set("X-Tenant-Code", "acme")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -1786,7 +1786,7 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
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-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||||
req.Header.Set("X-Tenant-Code", "acme")
|
req.Header.Set("X-Tenant-Code", "acme")
|
||||||
rec := httptest.NewRecorder()
|
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) {
|
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
@@ -1883,7 +1932,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
|
|||||||
|
|
||||||
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
|
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
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))
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -1922,7 +1971,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
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-MeetingIM-APIKey", makeTestAPIKey(t))
|
||||||
req.Header.Set("X-Tenant-Code", "acme")
|
req.Header.Set("X-Tenant-Code", "acme")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -8242,12 +8291,12 @@ func TestIMMessageTypeDraftyEntities(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "legacy flat file_id content still works",
|
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",
|
want: "image",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "legacy flat url content still works",
|
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",
|
want: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1143,6 +1143,12 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
return
|
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)
|
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
|
||||||
if handler == nil {
|
if handler == nil {
|
||||||
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp)
|
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
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrInt(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
type testLoginDeviceStore struct {
|
type testLoginDeviceStore struct {
|
||||||
devices map[types.Uid][]types.DeviceDef
|
devices map[types.Uid][]types.DeviceDef
|
||||||
updates int
|
updates int
|
||||||
@@ -365,9 +369,11 @@ func TestDispatchLogin(t *testing.T) {
|
|||||||
|
|
||||||
msg := &ClientComMessage{
|
msg := &ClientComMessage{
|
||||||
Login: &MsgClientLogin{
|
Login: &MsgClientLogin{
|
||||||
Id: "123",
|
Id: "123",
|
||||||
Scheme: "basic",
|
Scheme: "basic",
|
||||||
Secret: []byte(secret),
|
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 := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: "acme"}
|
||||||
s.login(&ClientComMessage{
|
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(),
|
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) {
|
func TestPublishSysRequiresRoot(t *testing.T) {
|
||||||
s := &Session{
|
s := &Session{
|
||||||
send: make(chan any, 1),
|
send: make(chan any, 1),
|
||||||
@@ -1420,12 +1460,14 @@ func TestDispatchAccNew(t *testing.T) {
|
|||||||
public := "public name"
|
public := "public name"
|
||||||
msg := &ClientComMessage{
|
msg := &ClientComMessage{
|
||||||
Acc: &MsgClientAcc{
|
Acc: &MsgClientAcc{
|
||||||
Id: "123",
|
Id: "123",
|
||||||
User: "newXYZ",
|
User: "newXYZ",
|
||||||
Scheme: "basic",
|
Scheme: "basic",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
Phone: "13800138000",
|
Phone: "13800138000",
|
||||||
Secret: []byte(secret),
|
RegistrationType: ptrInt(registrationTypeEnterprise),
|
||||||
|
TenantCode: "test",
|
||||||
|
Secret: []byte(secret),
|
||||||
Cred: []MsgCredClient{{
|
Cred: []MsgCredClient{{
|
||||||
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
|
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
|
||||||
Params: map[string]any{"challenge_id": "challenge-1"},
|
Params: map[string]any{"challenge_id": "challenge-1"},
|
||||||
@@ -1540,12 +1582,14 @@ func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) {
|
|||||||
|
|
||||||
msg := &ClientComMessage{
|
msg := &ClientComMessage{
|
||||||
Acc: &MsgClientAcc{
|
Acc: &MsgClientAcc{
|
||||||
Id: "username-only",
|
Id: "username-only",
|
||||||
User: "new",
|
User: "new",
|
||||||
Scheme: "basic",
|
Scheme: "basic",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
Secret: []byte(secret),
|
RegistrationType: ptrInt(registrationTypeEnterprise),
|
||||||
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
|
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
|
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)
|
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
|
||||||
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
|
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
|
||||||
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
|
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
|
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 {
|
func validateRegistrationUsername(username string) error {
|
||||||
if username == "" || len(username) > 10 {
|
if username == "" || len(username) > 10 {
|
||||||
return types.ErrPolicy
|
return types.ErrPolicy
|
||||||
|
|||||||
Reference in New Issue
Block a user