hyapp-server/docs/auth-login-register-technical-design.md
2026-04-25 13:21:39 +08:00

549 lines
17 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` 唯一绑定用户 |
| access token 签发 | `user-service` | 登录成功后签发 |
| access token 校验 | `gateway-service` | 业务 API 和设置密码入口校验 |
| refresh token | `user-service` | 只保存 hash支持轮换和失效 |
| 房间身份态 | `room-service` | 只消费已鉴权 `actor_user_id`,不处理登录 |
| WebSocket 身份态 | `im-service` | 校验 access token不处理注册 |
`gateway-service` 不读取用户表,不校验密码 hash不调用三方平台做最终身份判断。
## 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": "wechat",
"credential": "provider_code_or_token",
"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",
"is_new_user": true
}
```
三方接口不拆注册和登录。新用户不会默认生成密码;只有 `users``user_display_user_ids``third_party_identities` 和首个 `auth_sessions` 会在同一事务中创建。
`display_user_id` 永远表示当前有效展示号。用户没有临时靓号时,它等于 `default_display_user_id`;用户有 active 靓号时,它等于靓号。
### 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`,后续如果要支持改密,必须新增独立改密接口和旧密码校验规则。
### 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 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`
用户主记录,只保存用户全局身份和状态。
| 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 |
| `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` 是登录身份的稳定主键。
### `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)
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 + 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。
### 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``im-service` 持公钥校验。
- 公钥通过配置或 JWKS 缓存分发。
## 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 |
| `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 |
| `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`
## Security Rules
- 明文密码只允许存在于 gateway 入参对象和 user-service hash 前的内存短路径中,不能写日志。
- `password_hash` 必须使用强 hash不能使用 SHA、MD5 或可逆加密。
- refresh token 原文不能入库,只能入库 hash。
- 三方 provider secret 只属于 `user-service` 配置。
- 登录失败日志不能包含明文密码、三方 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 后触发未知逻辑。
- 三方登录创建用户必须使用事务,保证 `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`
- 三方身份再次登录返回同一个 `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`
## Open Decisions
这些点需要在实现前定死,不能靠代码猜:
- 首批三方 provider 名单。
- 密码复杂度策略。
- access token 有效期和 refresh token 有效期。
- HS256 继续使用多久,以及迁移非对称签名的时间点。