1112 lines
50 KiB
Markdown
1112 lines
50 KiB
Markdown
# Login And Registration Technical Design
|
||
|
||
本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md)。
|
||
|
||
## Scope
|
||
|
||
v1 只有一个用户创建入口:
|
||
|
||
- 三方身份登录即注册。`provider + provider_subject` 不存在时创建用户、生成 `default_display_user_id`、设置当前 `display_user_id`、绑定三方身份并签发 token。
|
||
- 注册邀请码是可选归因能力。非空邀请码如果有效,则在注册事务或资料补全事务内绑定邀请人;邀请码关系一旦绑定不可由客户端修改。
|
||
|
||
三方登录不拆成“先登录、发现未注册、再注册”两步。客户端调用三方 SDK 拿到 credential 后,只调用 `LoginThirdParty`。服务端根据 `provider + provider_subject` 决定登录还是创建用户,并通过 `is_new_user` 和 `profile_completed` 告诉客户端下一步是否进入资料补全页。
|
||
|
||
v1 的密码能力只有一个前置条件:
|
||
|
||
- 用户必须已经通过三方登录拿到 access token,之后才能设置密码。
|
||
|
||
v1 不支持:
|
||
|
||
- 账号密码注册。
|
||
- 用户名、邮箱、手机号作为独立登录账号。
|
||
- 未登录状态设置密码。
|
||
- 手机验证码登录。
|
||
- 匿名游客登录。
|
||
- 三方登录返回 `NOT_REGISTERED` 后再调用注册接口。
|
||
- 多账号主动合并。
|
||
- 客户端直接访问 `user-service`。
|
||
- gateway 保存密码、三方凭证或 refresh token。
|
||
|
||
## Ownership
|
||
|
||
登录注册归属 `user-service`,gateway 只做外部 HTTP JSON 入口和 gRPC 协议转换。
|
||
|
||
| Capability | Owner | Rule |
|
||
| --- | --- | --- |
|
||
| App 解析 | `gateway-service` + `user-service` | gateway 从包名、平台或显式 `app_code` 解析 App;`user-service` 的 `apps` 表是权威映射 |
|
||
| HTTP JSON API | `gateway-service` | 只暴露 `/api/v1/auth/*`,统一返回 envelope |
|
||
| 用户创建 | `user-service` | 只能由三方身份首次登录触发 |
|
||
| 注册完成状态 | `user-service` | 创建用户后默认未完成资料,资料补全后显式标记完成 |
|
||
| 邀请码归因 | `user-service` | 生成用户自己的邀请码,解析注册邀请码并写邀请关系事实 |
|
||
| 展示短号 | `user-service` | 新用户创建时生成默认短号,后续登录用当前有效短号;临时靓号有效期内会覆盖默认短号 |
|
||
| 密码身份 | `user-service` | 已登录用户手动设置后才存在 |
|
||
| 三方身份绑定 | `user-service` | `provider + subject` 唯一绑定用户 |
|
||
| 三方凭证校验 | `user-service` | 生产环境必须由 Firebase/provider verifier 校验 credential 后得到 subject |
|
||
| access token 签发 | `user-service` | 登录成功后签发 |
|
||
| access token 校验 | `gateway-service` | 业务 API 和设置密码入口校验 |
|
||
| refresh token | `user-service` | 只保存 hash,支持轮换和失效 |
|
||
| 房间身份态 | `room-service` | 只消费已鉴权 `actor_user_id`,不处理登录 |
|
||
| 腾讯云 IM 身份态 | `gateway-service` | 用 access token 鉴权后签发腾讯云 IM UserSig,不处理注册 |
|
||
|
||
`gateway-service` 不读取用户表,不校验密码 hash,不调用 Firebase 或三方平台做最终身份判断。Firebase service account、Firebase project id 和三方 provider secret 只存在于 `user-service` verifier 配置或服务端密钥系统。
|
||
|
||
所有登录、注册、session、三方身份和短号能力都按 `app_code` 隔离。客户端包名 `com.org.laluparty` 当前映射为 `lalu`;后续 `popoo`、`siya` 等 App 只新增 `apps` 映射和对应 provider 配置,不复制登录服务。JWT 必须携带 `app_code`,刷新 token、设置密码和密码登录都必须在同一 `app_code` 内查询。
|
||
|
||
## External HTTP API
|
||
|
||
所有接口使用 gateway 业务响应 envelope:
|
||
|
||
```json
|
||
{
|
||
"code": "OK",
|
||
"message": "ok",
|
||
"request_id": "req_xxx",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
`request_id` 来自 `X-Request-ID` 或由 gateway 生成,并透传到内部 gRPC meta。
|
||
|
||
### Third Party Login Or Register
|
||
|
||
```text
|
||
POST /api/v1/auth/third-party/login
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"provider": "firebase",
|
||
"credential": "firebase_id_token",
|
||
"username": "hy",
|
||
"gender": "male",
|
||
"country": "CN",
|
||
"invite_code": "INV-1",
|
||
"device_id": "device_xxx",
|
||
"device": "iPhone 15",
|
||
"os_version": "iOS 18.1",
|
||
"avatar": "https://cdn.example/avatar.png",
|
||
"birth": "2000-01-02",
|
||
"app_version": "1.2.3",
|
||
"build_number": "123",
|
||
"source": "campaign-a",
|
||
"install_channel": "app_store",
|
||
"campaign": "spring_launch",
|
||
"platform": "ios",
|
||
"language": "zh-CN",
|
||
"timezone": "Asia/Shanghai"
|
||
}
|
||
```
|
||
|
||
Response `data`:
|
||
|
||
```json
|
||
{
|
||
"user_id": "918274650129384448",
|
||
"display_user_id": "163000",
|
||
"default_display_user_id": "163000",
|
||
"display_user_id_kind": "default",
|
||
"display_user_id_expires_at_ms": 0,
|
||
"session_id": "sess_xxx",
|
||
"access_token": "jwt_xxx",
|
||
"refresh_token": "refresh_xxx",
|
||
"expires_in_sec": 1800,
|
||
"token_type": "Bearer",
|
||
"app_code": "lalu",
|
||
"is_new_user": true,
|
||
"profile_completed": false,
|
||
"onboarding_status": "profile_required"
|
||
}
|
||
```
|
||
|
||
三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 `users`,同时从 App 序列分配 `default_display_user_id`(首号 `163000`)、生成用户自己的邀请码,并在同一事务中创建 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions`。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。三方绑定唯一键是 `app_code + provider + provider_subject`,同一个 Firebase UID 可以在不同 App 下创建不同业务用户。
|
||
|
||
`LoginThirdParty` 中的 `username/avatar/gender/country` 只作为 provider 或客户端当次传入的注册资料快照,不代表注册完成。App 注册页的权威提交点是 `CompleteOnboarding(username, avatar, gender, country, optional invite_code)`;只有该接口成功后才设置 `profile_completed=true`。
|
||
|
||
注册链路分成两类数据,不能混用:
|
||
|
||
| Category | Submit Time | Source | Fields |
|
||
| --- | --- | --- | --- |
|
||
| 注册上下文 | `POST /api/v1/auth/third-party/login` | 客户端自动上报或 gateway 观察 | `device_id`、`platform`、`device`、`os_version`、`app_version`、`build_number`、`language`、`timezone`、`source`、`install_channel`、`campaign`、`register_ip`、`register_user_agent`、`country_by_ip` |
|
||
| 注册页用户资料 | `POST /api/v1/users/me/onboarding/complete` | 用户在注册页主动填写或选择 | `username`、`avatar`、`gender`、`country` |
|
||
| 注册邀请归因 | `POST /api/v1/auth/third-party/login` 或 `POST /api/v1/users/me/onboarding/complete` | 用户选填的邀请码 | `invite_code` |
|
||
|
||
`device_id` 和 `platform` 是注册创建用户时必须登记的客户端上下文,不是注册页让用户填写的内容,也不进入 `CompleteOnboarding` request body。注册页只负责让用户完成可展示、可分区的最小资料。
|
||
|
||
`invite_code` 推荐在资料补全页提交。为了兼容客户端在三方登录前已经拿到邀请码的场景,`LoginThirdParty` 也保留可选 `invite_code`。已有用户再次登录时提交的邀请码不修改历史邀请关系。
|
||
|
||
客户端跳转规则:
|
||
|
||
- `is_new_user=true` 表示本次调用首次创建了用户和三方绑定,客户端应进入资料补全页。
|
||
- `profile_completed=false` 表示该用户仍未完成 App 注册资料,即使 `is_new_user=false` 也必须继续进入资料补全页。
|
||
- `is_new_user=false && profile_completed=true` 才能直接进入 App 首页。
|
||
- 服务端不返回 `NOT_REGISTERED`。三方身份不存在时由本接口原子创建;credential 无效、provider 不允许或 provider 校验失败统一返回 `AUTH_FAILED`。
|
||
|
||
`LoginThirdParty` 入参字段必须在 user-service 写库前完成校验,失败统一返回 `INVALID_ARGUMENT`,不能让 MySQL `VARCHAR` 或 `DATE` 约束错误泄漏成 gateway `UPSTREAM_ERROR`。
|
||
|
||
| Field | Max | Format |
|
||
| --- | ---: | --- |
|
||
| `username` | 64 | trimmed display name |
|
||
| `gender` | 32 | client enum/string snapshot |
|
||
| `country` | 3 | ISO 3166-1 alpha-2/alpha-3 canonical code, normalized uppercase |
|
||
| `invite_code` | 64 | optional invite code, trim and uppercase before resolving |
|
||
| `register_ip` | 64 | gateway observed IP only |
|
||
| `register_user_agent` | 512 | gateway observed User-Agent only |
|
||
| `country_by_ip` | 2 | trusted edge/server country code only |
|
||
| `device_id` | 128 | required, first refresh session binding |
|
||
| `device` | 128 | device model/description |
|
||
| `os_version` | 64 | client OS version |
|
||
| `avatar` | 512 | absolute `http` or `https` URL |
|
||
| `birth` | date | `yyyy-mm-dd` |
|
||
| `app_version` | 64 | client version |
|
||
| `build_number` | 64 | client build number |
|
||
| `source` | 64 | registration source |
|
||
| `install_channel` | 64 | install channel |
|
||
| `campaign` | 128 | attribution campaign |
|
||
| `platform` | 16 | `android` or `ios` |
|
||
| `language` | 32 | BCP 47 language tag subset, e.g. `en-US` |
|
||
| `timezone` | 64 | IANA timezone name, e.g. `America/Los_Angeles` |
|
||
|
||
`display_user_id` 永远表示当前有效展示号。用户没有临时靓号时,它等于 `default_display_user_id`;用户有 active 靓号时,它等于靓号。
|
||
|
||
### Third Party Credential Verification
|
||
|
||
生产环境的三方登录必须使用真实身份源 verifier。首版 App 已接入 Firebase Auth,因此 `provider=firebase` 的 `credential` 统一表示 Firebase ID token。`StaticThirdPartyVerifier` 只能用于单元测试、本地隔离联调或明确的假 provider,不能配置到线上 provider allowlist。
|
||
|
||
校验规则:
|
||
|
||
- `provider` 必须命中 user-service 配置 allowlist;未知 provider 返回 `AUTH_FAILED`。
|
||
- verifier 必须校验 credential 的签名、过期时间、issuer、audience/client_id/app_id、nonce 或等价防重放字段。
|
||
- verifier 必须从 provider 验证后的 payload 或服务端换票结果中提取稳定 `provider_subject`;客户端不能直传 `provider_subject`。
|
||
- provider 返回可选 union id 时,`provider_subject` 仍然是登录绑定主键,`provider_union_id` 只做辅助字段。
|
||
- provider 配置缺失、密钥缺失、JWKS/公钥加载失败或 provider 响应不可判定时必须 fail-closed,返回 `AUTH_FAILED` 或服务端配置错误,不允许降级为静态 verifier。
|
||
- verifier 不能把 credential、id token、auth code、provider access token 写入日志、审计表或 `users`。
|
||
|
||
上线前必须按首批 provider 分别写清 credential 类型:
|
||
|
||
| Provider | Credential | Required Checks |
|
||
| --- | --- | --- |
|
||
| `firebase` | Firebase ID token | signature/Admin SDK or Firebase public keys, `iss`, `aud`, `exp/iat/auth_time`, non-empty `sub`, allowed `firebase.sign_in_provider` |
|
||
| `apple` | identity token or authorization code | signature/JWKS, issuer, audience, expiry, nonce when present |
|
||
| `facebook` | access token or limited-login token | app id, token validity, expiry, subject from provider API or signed token |
|
||
| `tiktok` | auth code or access token | app/client key, expiry, subject from provider API |
|
||
|
||
如果首版仍使用本地静态 verifier 联调,配置名和注释必须明确标记为 local/mock,线上配置示例不能把真实 provider 指向静态 verifier。
|
||
|
||
Firebase 首版是 App 三方登录的统一身份入口。客户端先用 Firebase Auth 完成 Google 登录,再调用 Firebase Auth 获取当前用户的 Firebase ID token,并把该 token 作为 `credential` 提交给 gateway。gateway 只做协议转发,不解析、不缓存、不落日志;user-service 的 Firebase verifier 必须执行:
|
||
|
||
| Check | Rule |
|
||
| --- | --- |
|
||
| `iss` | 必须是 `https://securetoken.google.com/<firebase_project_id>` |
|
||
| `aud` | 必须等于当前 App 后端配置的 Firebase project id |
|
||
| `exp/iat/auth_time` | token 未过期,签发和认证时间不能明显偏离当前时间 |
|
||
| `signature` | 优先使用 Firebase Admin SDK 校验;不用 Admin SDK 时必须按 Firebase 公钥和缓存过期时间校验 |
|
||
| `sub` / `uid` | 作为 `provider_subject`,不能为空,不能来自客户端 body |
|
||
| `firebase.sign_in_provider` | 首版只开放 Google 时必须等于 `google.com`;后续开放 Apple/phone 等方式时改成配置 allowlist |
|
||
| `email/email_verified/name/picture` | 只作为资料快照辅助字段,不能替代 `sub` 做绑定主键 |
|
||
|
||
Firebase verifier 出现项目 ID 缺失、issuer/audience 不匹配、签名错误、token 过期、`sub` 为空、`sign_in_provider` 不允许或公钥加载失败时,统一返回 `AUTH_FAILED`。服务端不能把 Firebase ID token 写入日志、审计或数据库。
|
||
|
||
### Set Password
|
||
|
||
```text
|
||
POST /api/v1/auth/password/set
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"password": "plain_password"
|
||
}
|
||
```
|
||
|
||
Response `data`:
|
||
|
||
```json
|
||
{
|
||
"password_set": true
|
||
}
|
||
```
|
||
|
||
设置密码只接受已登录用户的 access token。客户端不能在 body 中自报 `user_id` 或 `display_user_id`。重复设置密码返回 `PASSWORD_ALREADY_SET`,后续如果要支持改密,必须新增独立改密接口和旧密码校验规则。
|
||
|
||
### Update Profile
|
||
|
||
```text
|
||
POST /api/v1/users/me/profile/update
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"username": "hy",
|
||
"avatar": "https://cdn.example/avatar.png",
|
||
"birth": "2000-01-02"
|
||
}
|
||
```
|
||
|
||
该接口只修改用户名、头像和生日。`birth` 非空时必须是 `yyyy-mm-dd`;国家不能混在这个接口中修改。
|
||
|
||
未完成注册用户不通过该接口补齐注册页资料;注册页必须调用 `CompleteOnboarding` 一次性提交 `username/avatar/gender/country`。
|
||
|
||
### Change Country
|
||
|
||
```text
|
||
POST /api/v1/users/me/country/change
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"country": "US"
|
||
}
|
||
```
|
||
|
||
国家修改由 user-service 单独写 `user_country_change_logs`。同一用户按滚动 30 天冷却窗口只能成功修改一次;提交相同国家不写新日志,也不消耗新的冷却窗口。
|
||
|
||
未完成注册用户的首次国家选择不走该接口,而是跟随 `CompleteOnboarding` 在同一事务内写入。注册完成后的国家修改才进入冷却和审计链路。
|
||
|
||
### Complete Onboarding
|
||
|
||
```text
|
||
POST /api/v1/users/me/onboarding/complete
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"username": "hy",
|
||
"avatar": "https://cdn.example/avatar.png",
|
||
"gender": "male",
|
||
"country": "SG",
|
||
"invite_code": "A1B2C3"
|
||
}
|
||
```
|
||
|
||
Response `data`:
|
||
|
||
```json
|
||
{
|
||
"user_id": "918274650129384448",
|
||
"username": "hy",
|
||
"avatar": "https://cdn.example/avatar.png",
|
||
"country": "SG",
|
||
"region_id": 1001,
|
||
"profile_completed": true,
|
||
"profile_completed_at_ms": 1777000000000,
|
||
"onboarding_status": "completed",
|
||
"invite": {
|
||
"bound": true,
|
||
"invite_code": "A1B2C3",
|
||
"inviter_user_id": "10001"
|
||
},
|
||
"token": {
|
||
"user_id": "918274650129384448",
|
||
"session_id": "sess_xxx",
|
||
"access_token": "jwt_xxx",
|
||
"expires_in_sec": 1800,
|
||
"token_type": "Bearer",
|
||
"display_user_id": "163000",
|
||
"profile_completed": true,
|
||
"onboarding_status": "completed"
|
||
}
|
||
}
|
||
```
|
||
|
||
该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名、头像和可选邀请码;user-service 在同一事务内校验资料、解析国家和区域、按需绑定邀请关系、更新用户资料,并把用户标记为注册完成。
|
||
|
||
`token.access_token` 是注册完成后立即替换旧 access token 的新 JWT,包含 `profile_completed=true`。该接口不轮换 refresh token,`token.refresh_token` 为空或不返回;客户端必须保留登录时已有的 refresh token。
|
||
|
||
注册页用户必填字段:
|
||
|
||
| Field | Required | Reason |
|
||
| --- | --- | --- |
|
||
| `username` | yes | 房间、资料页和 IM 展示需要稳定昵称 |
|
||
| `avatar` | yes | App 注册页要求用户头像,房间和 IM 展示不使用空头像 |
|
||
| `country` | yes | 房间列表按区域分发,必须有用户选择国家 |
|
||
| `region_id` | no | 国家可以暂时没有区域映射,此时进入 `GLOBAL` 房间列表 |
|
||
| `invite_code` | no | 注册邀请归因;非空时必须能解析到有效邀请人 |
|
||
|
||
注册上下文必需字段:
|
||
|
||
| Field | Required | Submit Time | Reason |
|
||
| --- | --- | --- | --- |
|
||
| `device_id` | yes | `LoginThirdParty` | refresh session 绑定、风控和设备级频控基础字段 |
|
||
| `platform` | yes | `LoginThirdParty` | App 侧环境字段,只允许 `android` 或 `ios` |
|
||
| `app_version/build_number/os_version` | no | `LoginThirdParty` | 注册来源排障和灰度分析字段,不阻塞 onboarding 完成 |
|
||
|
||
完成规则:
|
||
|
||
- `username` 必须 trim 后非空,并满足 `users.username` 长度限制。
|
||
- `avatar` 必须是合法 `http` 或 `https` URL,并满足 `users.avatar` 长度限制。
|
||
- `country` 必须命中 active `countries.country_code`,并同步解析 `region_id`。
|
||
- `invite_code` 非空时必须能解析到同一 `app_code` 下的 active 用户,且不能是当前用户自己的邀请码。
|
||
- 邀请关系只能在未完成资料且尚未绑定邀请关系时创建;已完成注册后不能再补填邀请码。
|
||
- 初次完成注册时设置国家不消耗国家修改冷却期;注册完成后的国家修改才走 `ChangeUserCountry` 和 30 天冷却。
|
||
- 如果用户已经 `profile_completed=true`,该接口不再修改资料;客户端需要改资料时使用资料修改接口,改国家时使用国家修改接口。
|
||
- `CompleteOnboarding` 成功后必须返回新的 access token,客户端立即替换旧 token;refresh token 不轮换。
|
||
|
||
如果必填字段缺失或格式非法,返回 `INVALID_ARGUMENT`。如果非空邀请码无法解析为有效邀请人,返回 `INVALID_INVITE_CODE`。如果资料未完成用户访问被门禁保护的业务能力,返回 `PROFILE_REQUIRED`。`PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。
|
||
|
||
## App Login State Machine
|
||
|
||
客户端登录页不需要先问服务端“是否注册”。状态机固定为:
|
||
|
||
```text
|
||
unauthenticated
|
||
-> provider_sdk_login
|
||
-> POST /api/v1/auth/third-party/login
|
||
-> token_received
|
||
-> if is_new_user || !profile_completed: onboarding_required
|
||
-> else: authenticated_home
|
||
```
|
||
|
||
资料补全页状态机:
|
||
|
||
```text
|
||
onboarding_required
|
||
-> POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code)
|
||
-> token_received(profile_completed=true)
|
||
-> authenticated_home
|
||
```
|
||
|
||
客户端判断规则:
|
||
|
||
- 只要已经拿到 access token,就认为用户主身份已经创建。
|
||
- 资料未完成不是未注册;它是已登录用户的 App 准入状态。
|
||
- App 启动后如果本地已有 token,应先用业务接口或用户资料接口刷新 `profile_completed`,不要只依赖本地缓存。
|
||
- `is_new_user` 只在本次三方登录响应中有意义;长期准入判断必须使用 `profile_completed`。
|
||
- `CompleteOnboarding` 成功响应内的 `token.access_token` 是解除 profile gate 的唯一新快照,客户端必须立即替换旧 access token。
|
||
|
||
## Profile Gate
|
||
|
||
资料未完成用户允许访问的接口:
|
||
|
||
| API | Allowed | Reason |
|
||
| --- | --- | --- |
|
||
| `POST /api/v1/users/me/onboarding/complete` | yes | 一次性提交国家、用户名和头像并固化完成状态 |
|
||
| `POST /api/v1/auth/password/set` | yes | 允许补齐密码身份 |
|
||
| `POST /api/v1/auth/token/refresh` | yes | 避免补资料时 token 过期导致重登 |
|
||
| `POST /api/v1/auth/logout` | yes | 用户必须始终能退出 |
|
||
|
||
资料未完成用户默认禁止访问:
|
||
|
||
| API / Capability | Rule |
|
||
| --- | --- |
|
||
| 房间列表 | 返回 `PROFILE_REQUIRED` |
|
||
| 创建房间 | 返回 `PROFILE_REQUIRED` |
|
||
| 进入房间 | 返回 `PROFILE_REQUIRED` |
|
||
| 腾讯云 IM UserSig | 返回 `PROFILE_REQUIRED`,避免未完成资料用户进入实时系统 |
|
||
| 腾讯云 RTC Token | 返回 `PROFILE_REQUIRED` |
|
||
| 钱包和付费行为 | 返回 `PROFILE_REQUIRED` |
|
||
|
||
gateway 是 profile gate 的外部入口执行点。为了避免每个业务请求都同步打 user-service,首版在 access token 中携带 `profile_completed` 快照;高风险接口仍可回查 user-service。用户完成 onboarding 后,客户端必须使用 `CompleteOnboardingResponse.token.access_token` 进入房间、IM、RTC 或付费链路。
|
||
|
||
### Password Login
|
||
|
||
```text
|
||
POST /api/v1/auth/account/login
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"account": "163000",
|
||
"password": "plain_password",
|
||
"device_id": "device_xxx"
|
||
}
|
||
```
|
||
|
||
`account` 就是用户当前有效短号。gateway 会统一转成 user-service 的 `LoginPasswordRequest.display_user_id`。
|
||
|
||
Response `data`:
|
||
|
||
```json
|
||
{
|
||
"user_id": "918274650129384448",
|
||
"display_user_id": "163000",
|
||
"default_display_user_id": "163000",
|
||
"display_user_id_kind": "default",
|
||
"display_user_id_expires_at_ms": 0,
|
||
"session_id": "sess_xxx",
|
||
"access_token": "jwt_xxx",
|
||
"refresh_token": "refresh_xxx",
|
||
"expires_in_sec": 1800,
|
||
"token_type": "Bearer"
|
||
}
|
||
```
|
||
|
||
短号不存在、用户未设置密码和密码错误都返回 `AUTH_FAILED`。这三个分支不能给客户端更细错误,避免登录入口变成用户枚举接口。
|
||
|
||
密码登录只接受当前有效 `display_user_id`。如果用户申请了临时靓号,默认短号在靓号有效期内不能用于密码登录;靓号过期后,默认短号恢复登录能力。
|
||
|
||
本地联调 initdb 预置测试账号:账号 `123456`,密码 `1234567`;账号 `12345678`,密码 `12345678`。
|
||
|
||
### Refresh Token
|
||
|
||
```text
|
||
POST /api/v1/auth/token/refresh
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"refresh_token": "refresh_xxx",
|
||
"device_id": "device_xxx"
|
||
}
|
||
```
|
||
|
||
Response `data`:
|
||
|
||
```json
|
||
{
|
||
"user_id": "918274650129384448",
|
||
"display_user_id": "163000",
|
||
"default_display_user_id": "163000",
|
||
"display_user_id_kind": "default",
|
||
"display_user_id_expires_at_ms": 0,
|
||
"session_id": "sess_xxx",
|
||
"access_token": "jwt_xxx",
|
||
"refresh_token": "refresh_new_xxx",
|
||
"expires_in_sec": 1800,
|
||
"token_type": "Bearer"
|
||
}
|
||
```
|
||
|
||
refresh token 必须轮换。旧 refresh token 使用成功后立即失效。
|
||
|
||
### Logout
|
||
|
||
```text
|
||
POST /api/v1/auth/logout
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"refresh_token": "refresh_xxx",
|
||
"session_id": "sess_xxx"
|
||
}
|
||
```
|
||
|
||
Response `data`:
|
||
|
||
```json
|
||
{
|
||
"revoked": true
|
||
}
|
||
```
|
||
|
||
## Internal gRPC Contract
|
||
|
||
`api/proto/user/v1/auth.proto` 承载 auth RPC:
|
||
|
||
```protobuf
|
||
service AuthService {
|
||
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
|
||
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
|
||
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
|
||
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
|
||
rpc Logout(LogoutRequest) returns (LogoutResponse);
|
||
}
|
||
```
|
||
|
||
关键消息:
|
||
|
||
```protobuf
|
||
message LoginPasswordRequest {
|
||
RequestMeta meta = 1;
|
||
string display_user_id = 2;
|
||
string password = 3;
|
||
}
|
||
|
||
message LoginThirdPartyRequest {
|
||
RequestMeta meta = 1;
|
||
string provider = 2;
|
||
string credential = 3;
|
||
string username = 4;
|
||
string gender = 5;
|
||
string country = 6;
|
||
string invite_code = 7;
|
||
string device_id = 8;
|
||
string device = 9;
|
||
string avatar = 10;
|
||
string birth = 11;
|
||
string app_version = 12;
|
||
string source = 13;
|
||
string platform = 14;
|
||
string language = 15;
|
||
string os_version = 16;
|
||
string build_number = 17;
|
||
string timezone = 18;
|
||
string install_channel = 19;
|
||
string campaign = 20;
|
||
}
|
||
|
||
message SetPasswordRequest {
|
||
RequestMeta meta = 1;
|
||
int64 user_id = 2;
|
||
string password = 3;
|
||
}
|
||
|
||
message AuthToken {
|
||
int64 user_id = 1;
|
||
string session_id = 2;
|
||
string access_token = 3;
|
||
string refresh_token = 4;
|
||
int64 expires_in_sec = 5;
|
||
string token_type = 6;
|
||
string display_user_id = 7;
|
||
string default_display_user_id = 8;
|
||
string display_user_id_kind = 9;
|
||
int64 display_user_id_expires_at_ms = 10;
|
||
bool profile_completed = 11;
|
||
string onboarding_status = 12;
|
||
string app_code = 13;
|
||
}
|
||
|
||
message AuthResponse {
|
||
AuthToken token = 1;
|
||
bool is_new_user = 2;
|
||
bool profile_completed = 3;
|
||
string onboarding_status = 4;
|
||
}
|
||
```
|
||
|
||
`SetPasswordRequest.user_id` 必须来自 gateway 已校验 access token 后的上下文,不能来自客户端 JSON body。
|
||
|
||
`api/proto/user/v1/user.proto` 需要扩展用户资料和 onboarding RPC:
|
||
|
||
```protobuf
|
||
message User {
|
||
// existing fields...
|
||
bool profile_completed = 21;
|
||
int64 profile_completed_at_ms = 22;
|
||
string onboarding_status = 23;
|
||
}
|
||
|
||
message CompleteOnboardingRequest {
|
||
RequestMeta meta = 1;
|
||
int64 user_id = 2;
|
||
string username = 3;
|
||
string avatar = 4;
|
||
string country = 5;
|
||
string gender = 6;
|
||
string invite_code = 7;
|
||
}
|
||
|
||
message CompleteOnboardingResponse {
|
||
User user = 1;
|
||
bool profile_completed = 2;
|
||
int64 profile_completed_at_ms = 3;
|
||
string onboarding_status = 4;
|
||
AuthToken token = 5;
|
||
}
|
||
|
||
service UserService {
|
||
// existing rpc...
|
||
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
||
}
|
||
```
|
||
|
||
字段语义:
|
||
|
||
- `profile_completed=false` 表示用户已经创建但未完成 App 最小注册资料。
|
||
- `profile_completed_at_ms=0` 表示从未完成 onboarding。
|
||
- `onboarding_status` 首版只允许 `profile_required` 和 `completed`,后续可以追加 `country_required`、`age_required` 等更细状态。
|
||
- `CompleteOnboardingRequest.username/avatar/gender/country` 是注册页必填字段,不能从客户端拆成多次部分提交。
|
||
- `CompleteOnboardingRequest.invite_code` 是注册页选填字段;非空时必须解析到有效邀请人,否则返回 `INVALID_INVITE_CODE`。
|
||
- `CompleteOnboardingResponse.token.access_token` 是注册完成后用于替换旧 JWT 的新 access token;它复用当前 `session_id`,不轮换 refresh token。
|
||
|
||
## Data Model
|
||
|
||
### `users`
|
||
|
||
用户主记录保存用户全局身份、当前短号快照、注册资料和注册来源快照。三方 credential、密码明文、refresh token 原文都不能进入这张表。
|
||
|
||
所有 auth 相关表都带 `app_code`:
|
||
|
||
| Table | App Scope |
|
||
| --- | --- |
|
||
| `users` | 用户主记录按 `app_code` 写入,展示短号唯一键带 `app_code` |
|
||
| `password_accounts` | 主键是 `app_code + user_id` |
|
||
| `third_party_identities` | 唯一键是 `app_code + provider + provider_subject` |
|
||
| `auth_sessions` | refresh token hash 唯一键带 `app_code` |
|
||
| `login_audit` | 登录审计查询索引带 `app_code` |
|
||
| `display_user_id_sequences` | 每个 App 独立短号序列,首个 `next_value` 为 `163000` |
|
||
| `user_invite_codes` | 用户自己的邀请码按 `app_code + code` 唯一 |
|
||
| `user_invite_relations` | 被邀请用户按 `app_code + invited_user_id` 最多一条关系 |
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `user_id` | bigint | primary key |
|
||
| `default_display_user_id` | varchar | user's permanent default short id |
|
||
| `current_display_user_id` | varchar | current effective short id; pretty id can cover default id |
|
||
| `current_display_user_id_kind` | varchar | default or pretty |
|
||
| `current_display_user_id_expires_at_ms` | bigint | null for default id |
|
||
| `username` | varchar | registration display name snapshot |
|
||
| `gender` | varchar | client submitted gender value |
|
||
| `country` | varchar | ISO 3166-1 alpha-2/alpha-3 canonical country code |
|
||
| `invite_code` | varchar | optional submitted invite code snapshot; not the authoritative inviter relation |
|
||
| `register_ip` | varchar | gateway observed request ip snapshot |
|
||
| `register_user_agent` | varchar | gateway observed User-Agent snapshot |
|
||
| `country_by_ip` | varchar | server/edge inferred country from entry IP |
|
||
| `register_device_id` | varchar | registration device id and first session device binding |
|
||
| `register_device` | varchar | client device model or description |
|
||
| `os_version` | varchar | client OS version |
|
||
| `avatar` | varchar | absolute http/https avatar URL |
|
||
| `birth_date` | date | yyyy-mm-dd |
|
||
| `app_version` | varchar | registration client version |
|
||
| `build_number` | varchar | registration client build number |
|
||
| `source` | varchar | registration source channel |
|
||
| `install_channel` | varchar | install channel |
|
||
| `campaign` | varchar | attribution campaign |
|
||
| `platform` | varchar | android or ios |
|
||
| `language` | varchar | BCP 47 language tag subset |
|
||
| `timezone` | varchar | client IANA timezone |
|
||
| `profile_completed` | tinyint/bool | whether App minimum registration profile is complete |
|
||
| `profile_completed_at_ms` | bigint | completion time, 0 or null means incomplete |
|
||
| `onboarding_status` | varchar | profile_required or completed |
|
||
| `status` | varchar | active, disabled, banned |
|
||
| `created_at_ms` | bigint | creation time |
|
||
| `updated_at_ms` | bigint | update time |
|
||
|
||
新用户创建时:
|
||
|
||
- `profile_completed=false`
|
||
- `profile_completed_at_ms=0`
|
||
- `onboarding_status=profile_required`
|
||
|
||
`CompleteOnboarding` 成功时:
|
||
|
||
- 校验 `username/avatar/gender/country`。
|
||
- 如果提交了 `invite_code` 且尚未绑定邀请关系,解析并写 `user_invite_relations`。
|
||
- 写入 `users.username`、`users.avatar`、`users.gender`、`users.country` 和 `users.region_id`。
|
||
- 设置 `profile_completed=true`。
|
||
- 设置 `profile_completed_at_ms=now`。
|
||
- 设置 `onboarding_status=completed`。
|
||
|
||
资料完成状态只能由 user-service 修改,gateway 和客户端不能直接提交该字段。
|
||
|
||
### `user_invite_codes`
|
||
|
||
用户自己的邀请码表。新用户创建时必须生成一条 active primary code,供用户在我的页复制分享。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `invite_code_id` | bigint | primary id |
|
||
| `code` | varchar | trim + uppercase 后保存,`app_code + code` 唯一 |
|
||
| `owner_user_id` | bigint | code owner user |
|
||
| `status` | varchar | active or disabled |
|
||
| `is_primary` | tinyint/bool | 首版一个用户只有一个 primary code |
|
||
| `created_at_ms` | bigint | creation time |
|
||
| `updated_at_ms` | bigint | update time |
|
||
|
||
邀请码不能复用 `current_display_user_id`。展示短号可能因为靓号、运营调整或过期发生变化;邀请码需要长期稳定,才能解释历史邀请关系。
|
||
|
||
### `user_invite_relations`
|
||
|
||
用户邀请关系事实表。它是“谁邀请了谁”的权威来源。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `invited_user_id` | bigint | 被邀请用户,`app_code + invited_user_id` 主键 |
|
||
| `inviter_user_id` | bigint | 邀请人用户 |
|
||
| `invite_code_id` | bigint | 使用的邀请码 ID |
|
||
| `invite_code` | varchar | 绑定时的邀请码快照 |
|
||
| `source` | varchar | login or onboarding |
|
||
| `bound_at_ms` | bigint | relation binding time |
|
||
| `request_id` | varchar | gateway trace id |
|
||
| `created_at_ms` | bigint | creation time |
|
||
|
||
关系绑定规则:
|
||
|
||
- 一个用户最多一条邀请关系。
|
||
- 非空邀请码不存在、停用、跨 App、owner 非 active 或 owner 是当前用户时,返回 `INVALID_INVITE_CODE`。
|
||
- 已有三方身份再次登录时不能通过 `invite_code` 修改邀请人。
|
||
- 历史关系不因为邀请码后续停用或邀请人资料变化而删除。
|
||
|
||
### `password_accounts`
|
||
|
||
密码身份表。它表达“该用户已经设置过密码”,不表达独立账号体系。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `user_id` | bigint | primary key |
|
||
| `password_hash` | varchar | never store plain password |
|
||
| `hash_alg` | varchar | e.g. bcrypt |
|
||
| `created_at_ms` | bigint | creation time |
|
||
| `updated_at_ms` | bigint | update time |
|
||
|
||
密码登录时先用 `users.current_display_user_id` 解析当前用户,再按 `user_id` 读取 `password_accounts`。这样用户修改默认短号或申请临时靓号后,密码登录自动跟随当前有效短号,不需要同步更新密码表。
|
||
|
||
如果 `current_display_user_id_kind=pretty` 且 `current_display_user_id_expires_at_ms <= now`,登录前必须先触发 user-service 的靓号懒过期流程,把当前短号恢复为默认短号后再继续解析。
|
||
|
||
### `third_party_identities`
|
||
|
||
三方身份绑定表。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `id` | bigint | primary key |
|
||
| `user_id` | bigint | owner user |
|
||
| `provider` | varchar | provider name |
|
||
| `provider_subject` | varchar | provider user id, required |
|
||
| `provider_union_id` | varchar | optional |
|
||
| `created_at_ms` | bigint | creation time |
|
||
| `updated_at_ms` | bigint | update time |
|
||
|
||
唯一约束:
|
||
|
||
```text
|
||
unique(app_code, provider, provider_subject)
|
||
```
|
||
|
||
如果 provider 支持 union id,也不能只依赖 union id。`provider_subject` 是登录身份的稳定主键。
|
||
|
||
### `user_country_change_logs`
|
||
|
||
用户国家修改日志表,作为冷却期判断和审计来源。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `id` | bigint | primary key |
|
||
| `user_id` | bigint | changed user |
|
||
| `old_country` | varchar | nullable when previous country is empty |
|
||
| `new_country` | varchar | required |
|
||
| `request_id` | varchar | gateway trace id |
|
||
| `changed_at_ms` | bigint | change time |
|
||
|
||
冷却期检查必须和 users 行更新在同一事务中完成,不能只依赖 gateway 侧判断。
|
||
|
||
### `auth_sessions`
|
||
|
||
refresh token 和会话状态表。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `session_id` | varchar | primary key |
|
||
| `user_id` | bigint | session owner |
|
||
| `refresh_token_hash` | varchar | unique |
|
||
| `device_id` | varchar | client device |
|
||
| `expires_at_ms` | bigint | refresh expiry |
|
||
| `revoked_at_ms` | bigint | null means active |
|
||
| `created_at_ms` | bigint | creation time |
|
||
| `updated_at_ms` | bigint | update time |
|
||
|
||
refresh token 原文只返回给客户端一次,服务端只保存 hash。
|
||
|
||
### `login_audit`
|
||
|
||
登录审计表,不参与主链路判断。
|
||
|
||
| Column | Type | Rule |
|
||
| --- | --- | --- |
|
||
| `id` | bigint | primary key |
|
||
| `request_id` | varchar | trace id |
|
||
| `user_id` | bigint | nullable on failed login |
|
||
| `login_type` | varchar | password, third_party, refresh, set_password |
|
||
| `provider` | varchar | nullable |
|
||
| `result` | varchar | success, failed |
|
||
| `failure_code` | varchar | nullable |
|
||
| `client_ip` | varchar | gateway observed ip |
|
||
| `user_agent` | varchar | gateway observed ua |
|
||
| `created_at_ms` | bigint | event time |
|
||
|
||
审计失败不能影响登录主链路,但不能记录明文密码、三方 credential 或 refresh token 原文。
|
||
|
||
## Core Flows
|
||
|
||
### Third Party Login Or Register
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
participant P as Firebase Auth / Provider
|
||
|
||
C->>G: POST /api/v1/auth/third-party/login
|
||
G->>U: LoginThirdParty(provider, credential, registration_context)
|
||
U->>P: verify Firebase ID token or provider credential
|
||
P-->>U: provider_subject(uid/sub)
|
||
U->>U: find identity
|
||
alt identity exists
|
||
U->>U: check user status
|
||
U->>U: create session
|
||
else identity missing
|
||
U->>U: create user + registration context + default_display_user_id + own invite code
|
||
opt invite_code present
|
||
U->>U: resolve invite code + bind inviter relation
|
||
end
|
||
U->>U: create third_party identity + session
|
||
end
|
||
U-->>G: AuthToken + is_new_user + profile_completed
|
||
G-->>C: envelope(data=AuthToken)
|
||
```
|
||
|
||
三方 credential 校验必须在 `user-service` 内完成。gateway 不保存 Firebase service account、provider app secret 或公钥缓存状态。
|
||
|
||
生产路径中,`U->>P: verify Firebase ID token or provider credential` 必须是真实 Firebase/Admin SDK 校验、真实 provider 校验或服务端换票。静态 verifier 把 `credential` 当 subject 的实现只允许在本地测试替身中使用,不能用于线上注册入口。
|
||
|
||
新用户首次登录会直接得到 token,但 `profile_completed=false`。客户端必须跳转资料补全页,而不是再次调用注册接口。`registration_context` 来自客户端环境和 gateway 请求上下文,不等于用户完成资料。
|
||
|
||
### Complete Onboarding
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
|
||
C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code)
|
||
G->>G: verify access token
|
||
G->>U: CompleteOnboarding(user_id from token, username, avatar, gender, country, invite_code)
|
||
U->>U: validate username/avatar/gender/country
|
||
U->>U: resolve active country and region_id
|
||
opt invite_code present and no relation exists
|
||
U->>U: resolve invite code and bind inviter relation
|
||
end
|
||
U->>U: update profile + mark profile_completed
|
||
U->>U: re-sign access token with profile_completed=true
|
||
U-->>G: User + profile_completed=true + AuthToken(access_token only)
|
||
G-->>C: envelope(data=completion + token)
|
||
```
|
||
|
||
`CompleteOnboarding` 是注册页的原子提交点,避免用户资料已经更新但国家或完成状态失败造成半完成状态。注册完成后的资料修改仍走 `UpdateUserProfile`,国家修改仍走 `ChangeUserCountry`。由于 gateway profile gate 读取 access token 快照,完成 onboarding 后必须使用响应里的新 access token 才能解除 `PROFILE_REQUIRED`。
|
||
|
||
### Set Password
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
|
||
C->>G: POST /api/v1/auth/password/set + access token
|
||
G->>G: verify access token
|
||
G->>U: SetPassword(user_id from token, password)
|
||
U->>U: check user status
|
||
U->>U: hash password
|
||
U->>U: insert password_accounts by user_id
|
||
U-->>G: password_set=true
|
||
G-->>C: envelope(data={password_set:true})
|
||
```
|
||
|
||
`password_accounts.user_id` 是主键。重复设置说明用户已经有密码身份,返回 `PASSWORD_ALREADY_SET`。
|
||
|
||
### Password Login
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
|
||
C->>G: POST /api/v1/auth/account/login
|
||
G->>U: LoginPassword(display_user_id, password)
|
||
U->>U: expire pretty display_user_id if needed
|
||
U->>U: resolve current display_user_id
|
||
U->>U: lookup password by user_id
|
||
U->>U: verify password hash
|
||
U->>U: check user status
|
||
U->>U: create session
|
||
U-->>G: AuthToken
|
||
G-->>C: envelope(data=AuthToken)
|
||
```
|
||
|
||
`display_user_id` 是登录名,不是另一个账号体系。默认短号变更后旧短号不能继续登录;临时靓号有效期内默认短号不能登录;临时靓号过期后旧靓号不能继续登录。
|
||
|
||
### Refresh Token
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
|
||
C->>G: POST /api/v1/auth/token/refresh
|
||
G->>U: RefreshToken
|
||
U->>U: hash refresh token
|
||
U->>U: find active session
|
||
U->>U: revoke old refresh token
|
||
U->>U: create replacement refresh token
|
||
U->>U: expire pretty display_user_id if needed
|
||
U->>U: reload user current display_user_id
|
||
U-->>G: new AuthToken
|
||
G-->>C: envelope(data=AuthToken)
|
||
```
|
||
|
||
Refresh 返回当前 `display_user_id`,避免客户端持有旧短号。临时靓号已经过期时,Refresh 必须返回默认短号。
|
||
|
||
## Token Strategy
|
||
|
||
v1 使用:
|
||
|
||
- `access_token`: JWT,短有效期,默认 30 分钟。
|
||
- `refresh_token`: opaque random token,默认 30 天,只保存 hash。
|
||
- `session_id`: 服务端会话 ID,用于 logout、审计和多端管理。
|
||
|
||
JWT claims:
|
||
|
||
```json
|
||
{
|
||
"sub": "10001",
|
||
"user_id": "10001",
|
||
"display_user_id": "163000",
|
||
"default_display_user_id": "163000",
|
||
"display_user_id_kind": "default",
|
||
"display_user_id_expires_at_ms": 0,
|
||
"profile_completed": false,
|
||
"onboarding_status": "profile_required",
|
||
"app_code": "lalu",
|
||
"sid": "sess_xxx",
|
||
"iat": 1777000000,
|
||
"exp": 1777001800,
|
||
"typ": "access"
|
||
}
|
||
```
|
||
|
||
JWT 内的短号字段只是签发时的展示快照,不是身份判断依据。`user_id` 是数值型用户主键,但在 JWT claim 和 HTTP JSON 中必须用字符串承载,避免 64 位 ID 被 JSON number 丢精度;gateway 解析后再用 int64 调后端 RPC。如果临时靓号在 access token 有效期内过期,客户端需要通过 Refresh 或用户身份接口拿到恢复后的默认短号。
|
||
|
||
首版继续沿用本地 HS256 配置。中长期建议迁移到非对称签名:
|
||
|
||
- `user-service` 持私钥签发。
|
||
- `gateway-service` 持公钥校验。
|
||
- 公钥通过配置或 JWKS 缓存分发。
|
||
|
||
腾讯云 IM 不直接校验业务 JWT。客户端先用 access token 调 `/api/v1/im/usersig`,再用返回的 UserSig 登录腾讯云 IM。
|
||
|
||
## Error Codes
|
||
|
||
gateway HTTP envelope 的 `code` 不使用数字。
|
||
|
||
| Code | HTTP | Meaning |
|
||
| --- | ---: | --- |
|
||
| `OK` | 200 | success |
|
||
| `INVALID_JSON` | 400 | request body is invalid JSON |
|
||
| `INVALID_ARGUMENT` | 400 | field format or required field invalid |
|
||
| `INVALID_INVITE_CODE` | 400 | submitted invite code does not resolve to a valid inviter |
|
||
| `RATE_LIMITED` | 429 | public auth rate limit exceeded |
|
||
| `AUTH_FAILED` | 401 | account missing, password wrong, no password set, or provider rejected |
|
||
| `UNAUTHORIZED` | 401 | missing or invalid access token |
|
||
| `PROFILE_REQUIRED` | 403 | authenticated user has not completed required registration profile |
|
||
| `PASSWORD_ALREADY_SET` | 409 | user already has password identity |
|
||
| `COUNTRY_CHANGE_COOLDOWN` | 409 | country change is within the 30-day cooldown window |
|
||
| `USER_DISABLED` | 403 | user is disabled or banned |
|
||
| `SESSION_EXPIRED` | 401 | refresh token expired |
|
||
| `SESSION_REVOKED` | 401 | refresh token revoked |
|
||
| `UPSTREAM_ERROR` | 502 | user-service unavailable or returned unexpected error |
|
||
| `INTERNAL_ERROR` | 500 | gateway internal failure |
|
||
|
||
密码登录时,短号不存在、未设置密码和密码错误都映射为 `AUTH_FAILED`。
|
||
|
||
## Public Auth Rate Limit
|
||
|
||
gateway 对三方注册登录、密码登录、refresh 和 logout 做 Redis 固定窗口频控。当前频控发生在 JSON 解码后、调用 user-service 前;命中频控时返回 HTTP 429 和 `RATE_LIMITED`,请求不会进入 user-service。
|
||
|
||
| Dimension | Applies To | Purpose |
|
||
| --- | --- | --- |
|
||
| `ip` | third-party, password, refresh, logout | 限制单入口 IP 对 public auth 的总请求量 |
|
||
| `device_id` | third-party, password, refresh | 限制单设备安装批量注册或刷新 |
|
||
| `provider + ip` | third-party | 限制单 IP 对同一三方 provider 批量建号 |
|
||
| `account + ip` | password | 限制单 IP 对同一短号喷射尝试 |
|
||
| `account` | password | 限制同一短号被跨 IP 喷射尝试 |
|
||
| `session_id + ip` | logout | 限制 logout 重放 |
|
||
|
||
Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所有维度,避免部分 key 已递增但后续维度超限的非原子状态。公网可控的 provider、device_id、account、session_id 和入口 IP 在 Redis key 中只保留固定长度摘要,避免超长输入污染 key 空间。`auth_rate_limit.enabled=true` 时 gateway 启动必须连通 Redis;运行期 Redis 调用失败会 fail-closed 返回 HTTP 502 和 `UPSTREAM_ERROR`,避免入口在依赖故障时无保护放量。内存 backend 只用于单元测试或显式关闭 Redis 频控后的本地构造。
|
||
|
||
## Security Rules
|
||
|
||
- 明文密码只允许存在于 gateway 入参对象和 user-service hash 前的内存短路径中,不能写日志。
|
||
- `password_hash` 必须使用强 hash,不能使用 SHA、MD5 或可逆加密。
|
||
- refresh token 原文不能入库,只能入库 hash。
|
||
- 三方 provider secret、Firebase service account 和 Firebase project id 只属于 `user-service` 配置或服务端密钥系统。
|
||
- 生产三方 verifier 必须校验 Firebase ID token 或 provider credential 的真实性和用途,不能接受客户端自报 subject,不能把静态 verifier 配到真实 provider。
|
||
- 注册完成必须由 user-service 在同一事务内写入 `username/avatar/gender/country/region_id/profile_completed`,不能由 gateway 分多步拼状态。
|
||
- 邀请码解析和邀请关系绑定必须由 user-service 在注册或资料补全事务内完成,不能由 gateway 先查后写。
|
||
- 邀请关系不能只保存邀请码字符串;必须保存 `inviter_user_id` 和绑定时的邀请码快照。
|
||
- 登录失败日志不能包含明文密码、三方 credential、refresh token 原文。
|
||
- gateway 统一过滤内部错误,客户端只拿稳定错误码和 `request_id`。
|
||
- access token 校验失败不访问 `user-service`,避免每个业务请求都打用户服务。
|
||
- 设置密码必须从 access token 取 `user_id`,不接受客户端 body 指定用户。
|
||
- access token 中的 `display_user_id` 不能作为权限、账务、房间或 IM 身份依据。
|
||
|
||
## Development Contract
|
||
|
||
当前项目处于开发阶段,没有线上旧客户端或旧服务版本需要保留;HTTP、protobuf 和数据库契约按当前代码直接收敛:
|
||
|
||
- HTTP JSON 只接受当前文档字段,旧拼写和废弃字段不再读取。
|
||
- protobuf 按当前字段编号生成,删除的旧字段不保留解析分支。
|
||
- 新增 provider 必须通过配置 allowlist 控制,不能让客户端任意传 provider 后触发未知逻辑。
|
||
- 首版 Firebase 登录必须配置 `provider=firebase`、Firebase project id、Firebase verifier 和 `firebase.sign_in_provider` allowlist;不能只把 `firebase` 加入静态 allowlist。
|
||
- 新增直接 provider 必须同时新增专用 verifier、配置项和测试;不能只把 provider 名加入静态 allowlist。
|
||
- 三方登录创建用户必须使用事务,保证 `users` 注册上下文、`user_display_user_ids` 和 `third_party_identities` 不出现半绑定。
|
||
- 三方登录创建新用户时必须同时生成用户自己的邀请码。
|
||
- 注册页只调用 `CompleteOnboarding(username, avatar, gender, country, optional invite_code)`;不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 `device_id/platform` 放进注册页表单。
|
||
- `CompleteOnboarding` 成功后必须返回新的 access token;否则旧 access token 的 `profile_completed=false` 仍会被 gateway profile gate 拦截。
|
||
- 已有邀请关系或已完成资料的用户不能通过再次登录或再次 onboarding 修改邀请人。
|
||
- 设置密码必须使用 `user_id` 主键幂等判断,不能引入独立账号唯一键。
|
||
- 登录响应中的 `display_user_id` 表示当前有效短号,服务端权限、账务、房间和 IM 身份一律使用长 `user_id`。
|
||
|
||
## Test Matrix
|
||
|
||
必须覆盖:
|
||
|
||
- 三方身份首次登录创建用户、生成 `default_display_user_id`,并返回当前 `display_user_id` 和 `is_new_user=true`。
|
||
- 三方新用户首次登录返回 `profile_completed=false` 和 `onboarding_status=profile_required`。
|
||
- 三方新用户创建时写入 `device_id/platform/app_version/build_number/os_version/language/timezone` 等注册上下文,但不把这些字段作为注册页用户输入。
|
||
- 注册页提交 `username/avatar/gender/country` 后,user-service 在同一事务内写用户资料、解析 `region_id` 并返回 `profile_completed=true`。
|
||
- 新用户创建时生成自己的 active 邀请码,`GET /api/v1/users/me/overview` 可以展示该邀请码。
|
||
- 注册页提交有效 `invite_code` 后,user-service 写 `user_invite_relations(invited_user_id, inviter_user_id)`。
|
||
- 注册页提交不存在、停用、跨 App、owner 非 active 或自己的邀请码时返回 `INVALID_INVITE_CODE`。
|
||
- 已有三方身份再次登录时传入新 `invite_code` 不改变原邀请关系。
|
||
- 注册页完成后旧 access token 仍被 profile gate 拒绝;`CompleteOnboardingResponse.token.access_token` 能访问房间列表、IM UserSig、RTC Token 和付费链路。
|
||
- 注册完成缺少 `username`、缺少 `avatar`、缺少 `gender`、缺少 `country`、头像 URL 非法或国家不存在时返回 `INVALID_ARGUMENT`。
|
||
- 未完成注册用户访问房间列表、创建房间、进房、IM UserSig、RTC Token 或付费行为时返回 `PROFILE_REQUIRED`。
|
||
- 初次注册完成写入国家不消耗国家修改冷却;注册完成后的国家修改才受 `COUNTRY_CHANGE_COOLDOWN` 约束。
|
||
- Firebase verifier 拒绝未知 provider、伪造签名、错误 issuer/audience、过期 token、空 `sub`、不允许的 `firebase.sign_in_provider` 和 Firebase 配置缺失。
|
||
- 直接 provider verifier 拒绝伪造签名、错误 audience、过期 credential、nonce 不匹配和 provider 配置缺失。
|
||
- 真实 provider 配置不能回退到 `StaticThirdPartyVerifier`;静态 verifier 只在测试或 local/mock 配置中出现。
|
||
- 三方注册上下文和可选资料快照写入 `users`;任意写入 `users` 的注册字段超过 schema 长度,或 `birth/platform/country/language/timezone/avatar` 格式非法时返回 `INVALID_ARGUMENT`。
|
||
- 用户资料接口只修改 `username/avatar/birth`,非法生日返回 `INVALID_ARGUMENT`。
|
||
- 用户国家接口写 `user_country_change_logs`,冷却期内第二次修改返回 `COUNTRY_CHANGE_COOLDOWN`。
|
||
- 三方身份再次登录返回同一个 `user_id` 和 `is_new_user=false`。
|
||
- 三方新用户未设置密码前,短号密码登录返回 `AUTH_FAILED`。
|
||
- 已登录用户设置密码成功。
|
||
- 重复设置密码返回 `PASSWORD_ALREADY_SET`。
|
||
- 设置密码后可使用当前 `account + password` 登录。
|
||
- 短号不存在和密码错误都返回 `AUTH_FAILED`。
|
||
- 用户有 active 靓号时,密码登录必须使用靓号,默认短号返回 `AUTH_FAILED`。
|
||
- 靓号过期后,密码登录必须恢复使用默认短号,旧靓号返回 `AUTH_FAILED`。
|
||
- Refresh 在靓号过期后返回默认短号和 `display_user_id_kind=default`。
|
||
- 被禁用用户密码登录返回 `USER_DISABLED`。
|
||
- refresh token 成功轮换,旧 token 再次使用失败。
|
||
- logout 后 refresh token 失败。
|
||
- gateway auth API 响应始终使用 `{code,message,request_id,data}`。
|
||
- gateway 在 `SetPassword` 中只从 access token 传递 `user_id`。
|
||
- public auth 频控命中时返回 `RATE_LIMITED`,Redis 频控 backend 异常时 fail-closed,不进入 user-service。
|
||
|
||
## Open Decisions
|
||
|
||
这些点需要在实现前定死,不能靠代码猜:
|
||
|
||
- 首批三方 provider 名单,以及哪些走 Firebase Auth,哪些走直接 provider verifier。
|
||
- Firebase project id、service account/公钥校验方式、允许的 `firebase.sign_in_provider` 列表。
|
||
- 每个直接 provider 的 credential 类型、client_id/app_id、issuer/audience、nonce 策略和公钥/JWKS 缓存策略。
|
||
- 密码复杂度策略。
|
||
- access token 有效期和 refresh token 有效期。
|
||
- HS256 继续使用多久,以及迁移非对称签名的时间点。
|