hyapp-server/docs/auth-login-register-technical-design.md
2026-04-29 12:43:05 +08:00

740 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Login And Registration Technical Design
本文档定义 v1 登录、三方注册登录、密码设置和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。
## Scope
v1 只有一个用户创建入口:
- 三方身份登录即注册。`provider + provider_subject` 不存在时创建用户、生成 `default_display_user_id`、设置当前 `display_user_id`、绑定三方身份并签发 token。
v1 的密码能力只有一个前置条件:
- 用户必须已经通过三方登录拿到 access token之后才能设置密码。
v1 不支持:
- 账号密码注册。
- 用户名、邮箱、手机号作为独立登录账号。
- 未登录状态设置密码。
- 手机验证码登录。
- 匿名游客登录。
- 多账号主动合并。
- 客户端直接访问 `user-service`
- gateway 保存密码、三方凭证或 refresh token。
## Ownership
登录注册归属 `user-service`gateway 只做外部 HTTP JSON 入口和 gRPC 协议转换。
| Capability | Owner | Rule |
| --- | --- | --- |
| HTTP JSON API | `gateway-service` | 只暴露 `/api/v1/auth/*`,统一返回 envelope |
| 用户创建 | `user-service` | 只能由三方身份首次登录触发 |
| 展示短号 | `user-service` | 新用户创建时生成默认短号,后续登录用当前有效短号;临时靓号有效期内会覆盖默认短号 |
| 密码身份 | `user-service` | 已登录用户手动设置后才存在 |
| 三方身份绑定 | `user-service` | `provider + subject` 唯一绑定用户 |
| 三方凭证校验 | `user-service` | 生产环境必须由 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不调用三方平台做最终身份判断。三方 provider secret 只存在于 `user-service` verifier 配置或服务端密钥系统。
## 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": "google",
"credential": "google_id_token_or_auth_code",
"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": 10001,
"display_user_id": "100001",
"default_display_user_id": "100001",
"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",
"is_new_user": true
}
```
三方接口不拆注册和登录。新用户不会默认生成密码;注册资料和来源快照写入 `users`,同时 `user_display_user_ids``third_party_identities` 和首个 `auth_sessions` 会在同一事务中创建。`birth` 必须是 `yyyy-mm-dd``platform` 当前只接受 `android``ios`gateway 兼容客户端误拼的 `app_verison`,进入内部 RPC 时统一为 `app_version`。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip``register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。
注册字段必须在 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` | 2 | ISO 3166-1 alpha-2, normalized uppercase |
| `invite_code` | 64 | optional invite code |
| `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
生产环境的三方登录必须使用 provider 专用 verifier。`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 |
| --- | --- | --- |
| `google` | ID token or server auth code | signature/JWKS, issuer, audience, expiry, nonce when present |
| `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。
### 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`;国家不能混在这个接口中修改。
### 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 天冷却窗口只能成功修改一次;提交相同国家不写新日志,也不消耗新的冷却窗口。
### Password Login
```text
POST /api/v1/auth/password/login
```
Request:
```json
{
"display_user_id": "100001",
"password": "plain_password",
"device_id": "device_xxx"
}
```
Response `data`:
```json
{
"user_id": 10001,
"display_user_id": "100001",
"default_display_user_id": "100001",
"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`。如果用户申请了临时靓号,默认短号在靓号有效期内不能用于密码登录;靓号过期后,默认短号恢复登录能力。
### Refresh Token
```text
POST /api/v1/auth/token/refresh
```
Request:
```json
{
"refresh_token": "refresh_xxx",
"device_id": "device_xxx"
}
```
Response `data`:
```json
{
"user_id": 10001,
"display_user_id": "100001",
"default_display_user_id": "100001",
"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 ip = 8 [deprecated = true];
string device_id = 9;
string device = 10;
string avatar = 11;
string birth = 12;
string app_version = 13;
string source = 14;
string platform = 15;
string language = 16;
string os_version = 17;
string build_number = 18;
string timezone = 19;
string install_channel = 20;
string campaign = 21;
}
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;
}
```
`SetPasswordRequest.user_id` 必须来自 gateway 已校验 access token 后的上下文,不能来自客户端 JSON body。
## Data Model
### `users`
用户主记录保存用户全局身份、当前短号快照、注册资料和注册来源快照。三方 credential、密码明文、refresh token 原文都不能进入这张表。
| Column | Type | Rule |
| --- | --- | --- |
| `user_id` | bigint | primary key |
| `default_display_user_id` | varchar | user's permanent fallback 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 country or region code |
| `invite_code` | varchar | optional invite code |
| `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 |
| `status` | varchar | active, disabled, banned |
| `created_at_ms` | bigint | creation time |
| `updated_at_ms` | bigint | update time |
### `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(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 Provider
C->>G: POST /api/v1/auth/third-party/login
G->>U: LoginThirdParty(provider, credential, registration_profile)
U->>P: verify credential
P-->>U: provider_subject
U->>U: find identity
alt identity exists
U->>U: check user status
U->>U: create session
else identity missing
U->>U: create user/profile + default_display_user_id + third_party identity + session
end
U-->>G: AuthToken + is_new_user
G-->>C: envelope(data=AuthToken)
```
三方 credential 校验必须在 `user-service` 内完成。gateway 不保存 provider app secret。
生产路径中,`U->>P: verify credential` 必须是真实 provider 校验或服务端换票。静态 verifier 把 `credential` 当 subject 的实现只允许在本地测试替身中使用,不能用于线上注册入口。
### 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/password/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": "100001",
"default_display_user_id": "100001",
"display_user_id_kind": "default",
"display_user_id_expires_at_ms": 0,
"sid": "sess_xxx",
"iat": 1777000000,
"exp": 1777001800,
"typ": "access"
}
```
JWT 内的短号字段只是签发时的展示快照,不是身份判断依据。业务鉴权只能用 `user_id`。如果临时靓号在 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 |
| `RATE_LIMITED` | 429 | public auth rate limit exceeded |
| `AUTH_FAILED` | 401 | display_user_id missing, password wrong, no password set, or provider rejected |
| `UNAUTHORIZED` | 401 | missing or invalid access token |
| `PASSWORD_ALREADY_SET` | 409 | user already has password identity |
| `COUNTRY_CHANGE_COOLDOWN` | 409 | country change is within rolling 30-day cooldown |
| `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 批量建号 |
| `display_user_id + ip` | password | 限制单 IP 对同一短号喷射尝试 |
| `display_user_id` | password | 限制同一短号被跨 IP 喷射尝试 |
| `session_id + ip` | logout | 限制 logout 重放 |
Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所有维度,避免部分 key 已递增但后续维度超限的非原子状态。公网可控的 provider、device_id、display_user_id、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 只属于 `user-service` 配置或服务端密钥系统。
- 生产三方 verifier 必须校验 provider credential 的真实性和用途,不能接受客户端自报 subject不能把静态 verifier 配到真实 provider。
- 登录失败日志不能包含明文密码、三方 credential、refresh token 原文。
- gateway 统一过滤内部错误,客户端只拿稳定错误码和 `request_id`
- access token 校验失败不访问 `user-service`,避免每个业务请求都打用户服务。
- 设置密码必须从 access token 取 `user_id`,不接受客户端 body 指定用户。
- access token 中的 `display_user_id` 不能作为权限、账务、房间或 IM 身份依据。
## Compatibility Rules
当前项目仍在架构阶段,没有外部调用方,本次契约直接删除了旧账号注册接口。后续一旦进入联调或灰度,必须恢复兼容演进纪律:
- HTTP JSON 字段只能兼容扩展,不能重命名或删除已发布字段。
- protobuf 字段只能追加,不能复用字段编号。
- 新增 provider 必须通过配置 allowlist 控制,不能让客户端任意传 provider 后触发未知逻辑。
- 新增 provider 必须同时新增专用 verifier、配置项和测试不能只把 provider 名加入静态 allowlist。
- 三方登录创建用户必须使用事务,保证 `users` 注册资料、`user_display_user_ids``third_party_identities` 不出现半绑定。
- 设置密码必须使用 `user_id` 主键幂等判断,不能引入独立账号唯一键。
- 登录响应可以追加默认短号、当前短号来源和靓号过期时间;已发布后不能改变 `display_user_id` 表示当前有效短号的语义。
## Test Matrix
必须覆盖:
- 三方身份首次登录创建用户、生成 `default_display_user_id`,并返回当前 `display_user_id``is_new_user=true`
- 三方 verifier 拒绝未知 provider、伪造签名、错误 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`
- 设置密码后可使用当前 `display_user_id + 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 名单。
- 每个 provider 的 credential 类型、client_id/app_id、issuer/audience、nonce 策略和公钥/JWKS 缓存策略。
- 密码复杂度策略。
- access token 有效期和 refresh token 有效期。
- HS256 继续使用多久,以及迁移非对称签名的时间点。