产品成员邀请与生命周期
短信发送须先完成滑块验证,并在原发送请求中携带一次性 captcha_token;发布前同步升级相关客户端。
1. 模型与职责
图奕平台只维护三类数据:全局用户身份、用户与产品的成员关系、平台签发的认证状态。产品邀请完成后,用户就是普通图奕用户,拥有稳定且不复用的 OIDC sub。managed_invitation 只是成员关系的创建来源,不是平台角色。
代理、子用户、业务角色、套餐、设备、隧道及资源归属全部由产品维护。以网云穿为例,解除代理关系只修改网云穿自己的业务数据;不得删除图奕用户、改变 sub 或解除产品成员关系。
成员关系只有 active 和 suspended:
active:可按产品既有策略登录。suspended:仅拒绝该产品的 OIDC 和原生登录,并撤销该产品对应 Token;不影响其他产品,也不撤销平台浏览器会话。- 恢复必须显式调用
reactivate。重复邀请不能恢复已暂停成员。 - 用户本人重置全局密码时,平台撤销该用户全部平台会话和各产品 Token,并投递既有
user.sessions_revoked事件,reason为password_changed。
2. 开通准备
管理员在产品向导中同时启用“Web 登录”和“产品成员邀请”。平台生成带 managed_users:read managed_users:write 的 Service Client,并把产品网站基础地址的精确 Origin 登记到 Web Client。Origin 只包含协议、主机和端口,例如 https://console.example.com,不能包含路径或通配符。
产品后端保存 Service Client Secret;浏览器、桌面客户端和日志不得保存它。产品必须配置并消费签名 Webhook,才能及时处理成员暂停、恢复和全局密码变更。
产品成员密码重置还要求管理员在“联麓短信”中配置已审核的专用密码重置模板 ID。该模板应包含验证码、产品名称和固定平台地址 https://platform.too1.net/password/managed-reset。未配置时重置接口返回 password_reset_unavailable,不会降级为产品代设密码。
3. 服务端鉴权与幂等
所有产品服务端接口使用 Service Bearer Token,并执行产品隔离、Scope、Client 状态和 CIDR 校验。写接口必须提供 1–64 字符的 Idempotency-Key;同一 Client 重复提交完全相同的请求会返回原结果,不同请求复用同一个 Key 返回 idempotency_conflict。
接口前缀为 /api/v1:
| 方法 | 路径 | Scope | 用途 |
|---|---|---|---|
POST |
/managed-user-invitations |
managed_users:write |
创建邀请 |
GET |
/managed-user-invitations?external_user_id=... |
managed_users:read |
按产品外部编号对账 |
GET |
/managed-user-invitations/{id} |
managed_users:read |
查询邀请 |
POST |
/managed-user-invitations/{id} |
managed_users:write |
以 {"action":"cancel"} 取消邀请 |
GET |
/managed-users |
managed_users:read |
查询当前产品成员 |
GET |
/managed-users/{sub} |
managed_users:read |
查询单个成员 |
POST |
/managed-users/{sub}/suspend |
managed_users:write |
暂停当前产品成员 |
POST |
/managed-users/{sub}/reactivate |
managed_users:write |
恢复当前产品成员 |
POST |
/managed-users/{sub}/password-reset-requests |
managed_users:write |
请求用户本人重置密码 |
POST |
/managed-users |
managed_users:write |
创建托管成员(见第 9 节) |
POST |
/managed-users/{sub}/password |
managed_users:write |
为托管成员设置新密码 |
POST |
/managed-users/{sub}/claims |
managed_users:write |
创建认领事务 |
GET |
/managed-member-claims/{id} |
managed_users:read |
查询认领事务 |
POST |
/managed-member-claims/{id} |
managed_users:write |
以 {"action":"cancel"} 取消认领事务 |
成员列表与详情包含 hosted(布尔,未认领的本产品托管成员为 true)和 account_name(有账号名时返回);provisioning_source 取值增加 hosted。
创建邀请示例:
POST /api/v1/managed-user-invitations HTTP/1.1
Authorization: Bearer SERVICE_ACCESS_TOKEN
Idempotency-Key: member-invite:agent-user-1008:v1
Content-Type: application/json
{
"external_user_id": "agent-user-1008",
"phone_e164": "+8613812345678",
"display_name": "待激活成员",
"browser_origin": "https://console.example.com"
}
成功响应只返回邀请、一次性 transaction_token 和 activation_api。产品服务端不得收到验证码、密码或“手机号是否已经注册”的判断。transaction_token 只保存哈希,有效期 15 分钟,产品后端应把它作为短期事务值交给同 Origin 激活页,不得落入持久 Cookie、URL、埋点或日志。产品可在服务端按邀请有效期短期加密保存事务值以恢复同一邀请;必须限制为邀请创建者访问,完成或过期后清除,不缓存本人验证码或密码。
创建请求响应丢失时,不要换 Key 自动重建邀请。先用原 Key 重试;仍无法取得结果时,按 external_user_id 查询并恢复业务状态。
4. 浏览器激活
浏览器只可从邀请登记的精确 Origin 调用响应里的 activation_api,每次请求均使用:
Authorization: ManagedAuth TRANSACTION_TOKEN
Origin: https://console.example.com
Content-Type: application/json
请求必须省略 Cookie,不能混用 OIDC Access Token、Service Token、WebAuth 或 Native 凭据。平台只为邀请对应的精确 Origin 返回 CORS 响应,不使用 *,也不返回 Access-Control-Allow-Credentials。
| 方法 | 相对 activation_api 的地址 |
用途 |
|---|---|---|
GET |
`` | 查询邀请状态、下一步和当前协议 |
POST |
/sms/send |
发送 5 分钟验证码;60 秒内不得重发 |
POST |
/sms/verify |
提交 {"code":"123456"} |
POST |
/complete |
完成激活 |
POST |
/cancel |
用户取消 |
验证成功后,next_action 只会是:
set_password:手机号尚无图奕用户。提交新密码、当前legal_version和accept_terms:true,平台原子创建全局用户、手机号身份、协议记录和产品成员关系。可附带account_name:4–32 位,以小写字母开头,允许数字、点、下划线及短横线;全局唯一,冲突回滚且保留当前手机验证证明。新账号可使用此账号名或手机号进行 Web/Native 密码登录,二者共用手机号对应的失败限流。confirm_membership:手机号已有图奕用户。不得提交密码或account_name,只确认当前协议和加入产品;平台复用原sub,不能通过产品邀请改名或代设全局密码。
验证码验证会生成可重试的短期证明。若数据库提交失败,证明不会提前销毁;重试完成不会创建两个用户或两条成员关系。外部编号、手机号对应用户或已有产品成员发生冲突时返回 relationship_conflict,平台不会自动覆盖、迁移、合并或解除暂停。
短信状态为 unknown 表示渠道可能已经受理。界面必须提示用户检查短信并允许输入当前验证码;不得自动再次发送,也不得声称“未发送”。邀请过期、取消或步骤不匹配返回 invitation_unavailable。
5. 暂停、恢复与产品业务关系
产品以平台 sub 调用暂停或恢复接口。暂停事务会同时更新成员状态、撤销该产品的认证请求和 Token,并写入 Outbox。网云穿收到事件后再按自己的业务规则撤销本地会话;平台不会修改代理树或资源归属。
产品内“解除代理关系”不调用平台接口。用户仍是网云穿成员,也可以继续使用网云穿和其他产品;是否允许其成为独立账号、保留哪些资源由网云穿决定。
6. 用户本人密码重置
产品只能为当前产品的 active 成员发起重置请求,不能设置、读取或传输密码。平台限制同一用户每 60 秒一次、同一产品每日最多 5 次;任务有效期 30 分钟。
用户从短信中的固定平台地址打开重置页,输入手机号、验证码和新密码。平台验证后在一个数据库事务内更新 Argon2id 密码、撤销全部平台会话和 Token、取消未完成认证事务并写入各产品 Outbox。失败或数据库回滚时,已验证证明仍可重试;成功提交后才销毁证明。
7. Webhook 与对账
以下事件均使用既有 HMAC-SHA256 Webhook,事件 ID 必须幂等消费:
| 事件 | 关键字段 | 产品动作 |
|---|---|---|
product_membership.activated |
sub、product_code、external_user_id、provisioning_source |
建立或确认产品业务账号 |
product_membership.suspended |
sub、product_code、external_user_id |
撤销该产品本地会话 |
product_membership.reactivated |
sub、product_code、external_user_id |
恢复登录资格,不自动变更资源 |
user.sessions_revoked |
sub、product_code、reason |
reason 为 password_changed 或 account_claimed 时撤销产品本地会话 |
user.claimed |
sub、product_code、external_user_id、external_ref、claim_id |
托管成员已认领为本人账号:解除上级关系、撤销上级授权 |
Webhook 至少投递一次,可能重复或乱序。产品先验签和检查时间窗口,再以事件 ID 建 inbox 唯一约束,并在同一业务事务中更新本地状态。若事件积压,可用成员和邀请查询接口主动对账。
8. 网云穿接入边界
网云穿仓库需要实现:邀请激活页面、短期事务凭据的内存态或短期服务端存储、成员 sub 与业务账号映射、代理树与解除关系、Webhook inbox、成员暂停后的本地会话撤销,以及标准 OIDC Logout 的 state 校验。图奕平台不会替网云穿维护代理层级,也不会在 Token 中加入代理或子用户声明。
退出时,网云穿先清理自身业务会话,再发起 /oauth2/logout,携带 id_token_hint、已登记的 post_logout_redirect_uri 和随机独立 state。平台校验后直接退出当前浏览器会话及该产品凭据并 302 返回,不展示二次确认页;网云穿回调必须校验并一次性消费 state。
9. 托管成员与认领
托管成员由产品直接创建:只有账号名、名称和密码,没有手机号身份,平台在 users.hosted_product_code 记录托管它的产品。认领前它只能登录该产品:托管 OIDC、自定义网页、原生登录在签发授权码前,以及 Token 签发与刷新时,凡 Client 所属产品不同一律拒绝(网页登录提示“该账号仅限在 {产品名} 使用”,接口返回 403 hosted_member_product_only,Token 端点返回 invalid_grant)。平台账户中心可访问,但托管成员不能在账户中心绑定或修改手机号,手机号只能通过认领写入。
账号名密码登录(网页 /login/password、WebAuth/Native 的 /password,字段仍为 phone_e164)按用户 ID 校验,不再要求手机号身份。同一账号用手机号或账号名登录共用失败计数:有手机号时按手机号计数,否则按账号名计数;不存在的账号名单独计数,返回与密码错误相同的结果。
9.1 创建托管成员
POST /api/v1/managed-users
Authorization: Bearer SERVICE_ACCESS_TOKEN
Idempotency-Key: hosted:wyc-hosted-123:v1
Content-Type: application/json
{"external_user_id":"wyc-hosted-123","account_name":"factory01","display_name":"某某工厂","password":"可选"}
account_name规则^[a-z][a-z0-9_.-]{3,31}$(输入会转小写),全平台唯一;display_name1–64 字符;password可省略,省略时password_required=true,成员在设置密码前无法登录。平台只保存 Argon2id 哈希,不回传密码。- 成功
201:{"data":{"subject","external_user_id","account_name","display_name","status":"active","hosted":true,"password_required":false,"created_at"}}。 - 同一
external_user_id与同一账号名再次提交(即使换了 Idempotency-Key)返回200和原成员,不修改密码;同一外部编号对应其他账号名或非托管成员返回409 relationship_conflict。 - 错误:账号名已占用
409 account_name_taken;字段不合法400 invalid_request;密码不满足策略422 weak_password。 - 平台投递
product_membership.activated(provisioning_source=hosted)。
9.2 设置密码
POST /api/v1/managed-users/{sub}/password,请求 {"password":"..."},响应 200 与 9.1 相同的成员对象。只允许本产品的未认领托管成员;已认领或非托管返回 409 not_hosted,非本产品成员返回 404 not_found。成功后在同一事务撤销该用户全部平台会话、认证事务和各产品 Token,并投递 user.sessions_revoked(reason=password_changed)。
9.3 认领事务
POST /api/v1/managed-users/{sub}/claims,请求 {"external_ref":"wyc-claim-45","browser_origin":"https://console.example.com","expires_in_hours":168}(有效期 1–168 小时,默认 168)。external_ref 在同一 Service Client 内唯一,重复返回 409 relationship_conflict;browser_origin 必须登记在本产品 Web Client 的精确 Origin 中,否则 403 origin_not_allowed;成员已认领返回 409 not_hosted。同一成员新建认领会取消其旧的进行中认领。
成功 201:
{"data":{"claim":{"claim_id":"…","subject":"…","external_ref":"wyc-claim-45","account_name":"factory01","display_name":"某某工厂","status":"pending","next_action":"verify_phone","expires_at":"…","created_at":"…"},
"transaction_token":"43 位一次性凭据","activation_api":"https://platform.too1.net/api/v1/managed-member-claim-activations/{claim_id}"}}
transaction_token 只存哈希,保管要求与成员邀请相同。查询 GET /api/v1/managed-member-claims/{id} 返回 {"data": claim};status 为 pending、phone_verified、completed、cancelled、expired,完成后带 completed_at、phone_masked。取消 POST /api/v1/managed-member-claims/{id} {"action":"cancel"} 返回 {"data":{"status":"cancelled"}},非进行中返回 409 invitation_unavailable。
9.4 认领页(浏览器直连)
认领页使用响应里的 activation_api,鉴权与成员激活完全相同:Authorization: ManagedAuth TRANSACTION_TOKEN、精确 Origin、Content-Type: application/json、省略 Cookie。
| 方法 | 相对 activation_api |
请求 | 成功响应 |
|---|---|---|---|
GET |
`` | — | 200 {"data":{"claim":{…},"legal":{"version","terms_url","privacy_url"}}} |
POST |
/sms/send |
{"phone_e164":"+86…"} |
201(202 结果未知)`{"data":{"status":"accepted |
POST |
/sms/verify |
{"code":"123456"} |
200 {"data": claim},next_action=set_password |
POST |
/complete |
{"new_password":"…","accept_terms":true,"legal_version":"…"} |
200 {"data": claim},status=completed |
POST |
/cancel |
{} |
200 {"data":{"status":"cancelled"}} |
- 只支持
+86手机号,否则400 invalid_request。手机号已属于其他图奕用户时/sms/send与/complete返回409 phone_taken:平台不合并账号,提示更换手机号或联系客服。 - 验证码错误
422 invalid_code;协议版本不符422 terms_changed;密码不合规422 weak_password;事务过期、取消或步骤不匹配409 invitation_unavailable;重复完成返回原结果。 - 短信沿用成员激活模板(
managed_user_activation),unknown时同样不得自动重发。 - 完成在一个事务内:写入手机号身份、设置新密码、清除
hosted_product_code(provisioning_source保持hosted)、记录协议同意、认领置completed、撤销该用户全部会话与 Token,并投递user.claimed(仅本产品)与user.sessions_revoked(reason=account_claimed,所有已映射产品)。之后成员可用手机号或账号名登录任意产品,产品不能再为其设置密码。