# 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 ``` 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 继续使用多久,以及迁移非对称签名的时间点。