fix: restrict password login to existing phone accounts

This commit is contained in:
2026-04-26 01:11:45 +08:00
parent c4b9b8173f
commit 0a0f3f1bd8
33 changed files with 489 additions and 778 deletions

View File

@@ -96,7 +96,9 @@
- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。
- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。
- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。
- `密码登录` 页签包含手机号/邮箱、密码、主按钮和忘记密码入口。
- `密码登录` 页签包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号
- 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。
- `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`
- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。
- 移动端页签保持等分点击区域,输入框与按钮宽度仍随弹窗收缩。

View File

@@ -188,14 +188,14 @@ MVP 阶段建议采用最稳妥规则:
1. 用户名密码注册
2. 游客正式入口
3. 账号密码找回
3. 邮箱登录
4. 实名认证
5. 社交好友体系
6. 多微信绑定同一账号
说明:
当前用户名密码模式可仅保留为开发环境兜底能力,不作为正式前台入口
密码登录不是注册入口,也不是邮箱入口;它只作为手机号验证码登录的补充方式。用户必须先通过手机号验证码登录形成正式账号,并在已登录账号中心设置过密码后,后续才能用“手机号 + 密码”登录
---
@@ -388,6 +388,24 @@ MVP 阶段建议采用最稳妥规则:
MVP 阶段不需要单独设置密码。
## 6.1.1 密码登录补充方式
密码登录只补充手机号验证码登录,不建立新的账号体系。
落地规则:
- 入参只允许 `phone``password`,不支持邮箱、用户名或叙世号。
- 手机号不存在时,不创建账号,返回统一的登录失败。
- 手机号存在但账号未设置过密码时,不允许密码登录。
- 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。
- 忘记密码 / 重置密码必须先完成该手机号的短信验证码校验;手机号不存在时不创建账号。
前台约束:
- 密码页签的账号输入框文案固定为 `手机号`
- 密码页签主按钮固定为 `登录`,不能出现 `注册/登录`
- 短信验证码页签可继续承担“手机号不存在时创建正式账号并登录”的能力,但按钮文案不应暗示密码注册。
## 6.2 微信登录
微信登录按终端拆分:
@@ -611,7 +629,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
因此本期不是推翻重做,而是:
1. 保留 `users` 作为账号主表
2. 废弃“用户名密码自动注册”作为正式入口
2. 废弃“用户名密码自动注册”作为任何正式入口
3. 增加手机号与微信身份层
4. 增加验证码表与会话表
@@ -619,7 +637,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
## 8. 接口设计
所有接口均由 Express 后端承接。
所有接口均由 `server-rs` 后端承接。
## 8.1 手机号登录相关
@@ -698,6 +716,28 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
## 8.3 会话与账号信息
### `POST /api/auth/entry`
用途:
- 使用已设置密码的手机号账号登录
入参:
- `phone`
- `password`
出参:
- `token`
- `user`
约束:
- 不支持邮箱、用户名或叙世号。
- 不承担注册能力。
- 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。
### `GET /api/auth/me`
返回建议扩展为:

View File

@@ -1,6 +1,6 @@
# 密码登录历史落地设计
# 密码登录入口历史落地设计
> 2026-04-24 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经设置密码的账号。密码修改与重置以 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准;本文中“密码自动建号”仅保留为历史基线说明,不再作为当前落地依据
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
日期:`2026-04-21`
@@ -8,17 +8,25 @@
这份文档用于指导 `M2` 中以下两条任务的首版落地:
1. `实现密码登录`
2. `实现账号自动创建 / 幂等登录兼容策略`
1. `实现手机号密码登录`
2. `移除密码登录自动注册 / 自动建号语义`
目标是先把当前 Node 已经稳定运行的 `/api/auth/entry` 语义迁到 Rust 工作区,并冻结:
目标是 `/api/auth/entry` Rust 工作区冻结为手机号验证码账号的补充登录方式
1. `api-server` 对外暴露的最小兼容接口。
2. `module-auth` 负责的密码登录用例边界
3. 自动建号与并发幂等兼容规则
1. `api-server` 对外暴露 `phone + password` 的最小接口。
2. `module-auth` 负责已存在手机号账号的密码校验
3. 密码入口不创建账号,不接收邮箱、用户名或叙世号
4. 登录成功后与 JWT、refresh cookie 的衔接方式。
## 2. 当前基线
## 1.1 当前冻结结论
1. 密码登录不是注册入口。
2. 密码登录是手机号验证码登录的补充方式。
3. 只有已存在、已绑定手机号、并已设置密码的账号可以通过密码登录。
4. 未知手机号、未设置密码、密码错误统一返回 `401 UNAUTHORIZED`,避免通过密码入口探测账号状态。
5. 手机号验证码登录仍是新用户注册/首次登录的唯一入口。
## 2. 历史基线
当前 Node `/api/auth/entry` 主链已经具备如下语义:
@@ -29,7 +37,7 @@
5. 同时创建 refresh session并把原始 refresh token 写入 HttpOnly cookie。
6. 并发创建同一用户名时,后到的请求会回退为“查已存在账号并校验密码”,不因唯一键冲突直接失败。
这条链路既是当前前端匿名/游客恢复的基础,也是真实 `/api/auth/entry` contract 的既有事实,因此 Rust 首版必须兼容
这条链路曾经是前端匿名/游客恢复的基础。2026-04-25 起该历史语义已废弃Rust 当前实现必须以“手机号账号已设置密码后登录”为准,不再兼容密码自动建号
## 3. 设计输入
@@ -41,12 +49,12 @@
4. [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
5. [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
关键冻结点:
当前冻结点:
1. `password_hash` 当前继续由 `user_account` 承担,不进入 `auth_identity`
2. `sub` 必须是稳定 `user_id`
3. 登录成功后必须继续同时生成 access token 和 refresh session。
4. 自动建号兼容必须保留,不能因为迁到 Rust 就删除
4. 密码登录不再保留自动建号兼容,旧开发游客自动建号链路必须迁出 `/api/auth/entry`
## 4. 首版落地范围
@@ -54,14 +62,14 @@
1. `module-auth` 中的密码登录用例。
2. `api-server` 中的 `POST /api/auth/entry`
3. 用户名校验、密码哈希校验与自动建号
3. 手机号归一化、密码哈希校验与未设置密码拒绝
4. 登录成功后的 access token 与 refresh cookie 主链打通。
本阶段明确不包含:
1. SpacetimeDB 真正的 `user_account` / `refresh_session` reducer 写入。
2. `/api/auth/me``/api/auth/logout``/api/auth/refresh` 的正式业务闭环。
3. 手机验证码与微信登录链路。
3. 新增邮箱登录或独立密码注册链路。
## 5. crate 边界
@@ -69,9 +77,9 @@
负责:
1. 用户名与密码的领域校验。
1. 手机号与密码的领域校验。
2. 密码登录主用例。
3. 自动建号与并发幂等兼容策略
3. 已存在手机号账号与已设置密码约束
4. 输出登录成功所需的最小用户快照。
不负责:
@@ -106,11 +114,11 @@
### 6.1 请求体
固定沿用当前 contract
当前 contract
```json
{
"username": "guest_001",
"phone": "13800138000",
"password": "secret123"
}
```
@@ -124,9 +132,9 @@
"token": "<access-token>",
"user": {
"id": "user_xxx",
"username": "guest_001",
"displayName": "guest_001",
"phoneNumberMasked": null,
"username": "phone_xxx",
"displayName": "138****8000",
"phoneNumberMasked": "138****8000",
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false
@@ -136,11 +144,11 @@
同时响应头必须写回 refresh cookie。
## 7. 用户名与密码规则
## 7. 手机号与密码规则
当前阶段继续对齐 Node 基线
当前阶段固定
1. `username` 只允许 `3``24` 位字母、数字、下划线
1. `phone` 只接受中国大陆手机号,服务端统一归一化为 `E.164` 后查询
2. `password` 长度必须在 `6``128` 位之间。
任一校验失败时:
@@ -148,37 +156,30 @@
1. 返回 `400 BAD_REQUEST`
2. 错误文案继续保持中文
## 8. 自动建号与幂等兼容
## 8. 登录校验规则
### 8.1 自动建
### 8.1 未知手机
`username` 不存在时:
`phone` 归一化后找不到账号时:
1. 用当前请求里的 `password` 生成密码哈希
2. 创建一条本地账号。
3. `display_name = username`
4. `login_provider = password`
5. `account_status = active`
6. `token_version = 1`
1. 返回 `401 UNAUTHORIZED`
2. 创建账号。
3. 不写 `password_hash`
### 8.2 已存在账号
### 8.2 未设置密码
`username` 已存在时:
账号存在但 `password_login_enabled = false` 时:
1. 返回 `401 UNAUTHORIZED`
2. 不区分“未设置密码”和“密码错误”的外部文案。
### 8.3 已设置密码
当账号存在且已设置密码时:
1. 校验密码哈希。
2. 校验失败返回 `401 UNAUTHORIZED`
3. 校验成功继续登录
### 8.3 并发幂等兼容
若两个请求并发创建同一用户名:
1. 允许其中一个请求先创建成功。
2. 后一个请求若命中唯一键冲突,不直接失败。
3. 后一个请求必须重新查询该用户名。
4. 若查到账号,则按“已存在账号”路径继续校验密码。
这保证了当前前端重复调用 `/api/auth/entry` 时可以恢复同一账号,而不是随机失败。
3. 校验成功签发 access token 与 refresh cookie
## 9. 首版存储策略
@@ -226,10 +227,10 @@
当前阶段至少覆盖:
1. 首次密码登录自动建号成功
2. 同用户名同密码可重复登录同一账号
3.用户名不同密码返回 `401`
4. 非法用户名返回 `400`
1. 未知手机号密码登录返回 `401`,且不创建账号
2. 已登录手机号账号设置密码后可用 `phone + password` 登录
3.手机号错误密码返回 `401`
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。
@@ -239,7 +240,7 @@
1. `module-auth` 不再只是 README占位被真实 crate 实现替换。
2. `POST /api/auth/entry` 可在 Rust 侧独立跑通。
3. 自动建号与幂等兼容行为可验证。
3. 密码入口不注册、不接收邮箱/用户名的行为可验证。
4. JWT 与 refresh cookie 登录成功主链打通。
5. 文档、任务清单与测试同步完成。

View File

@@ -18,8 +18,8 @@
沿用现有 `POST /api/auth/entry`
1. 请求字段沿用 `username``password`前端固定把手机号填入 `username`
2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力
1. 请求字段固定为 `phone``password`,前端只提交手机号
2. 后端按标准手机号归一化后查找账号,兼容邮箱、用户名、叙世号或历史开发游客标识
3. 手机号不存在时返回 `401`,不创建账号。
4. 手机号存在但未设置密码时返回 `401`
5. 校验成功后签发 access token并写入 refresh cookie。
@@ -41,7 +41,7 @@
1. 不需要 Bearer 登录态。
2. 请求字段:`phone``code``newPassword`
3. 使用 `reset_password` 短信场景校验验证码。
4. 手机号不存在时返回 `404`,避免用密码重置隐式注册账号。
4. 手机号不存在时返回 `401`,避免用密码重置隐式注册账号,并避免泄露手机号注册状态
5. 重置成功后签发新的 access token并写入 refresh cookie便于用户直接进入登录态。
### 2.4 发送重置验证码
@@ -62,7 +62,7 @@
登录弹窗不再拆独立注册页签:
1. 面板直接展示手机号和密码输入,用于已设置密码账号登录。
2. 登录按钮文本固定为 `注册/登录`避免用户在登录和首次进入之间做页面切换
2. 密码登录按钮文本固定为 `登录`不允许暗示密码入口具备注册能力
3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。
4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录。
5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。

View File

@@ -206,6 +206,8 @@
1. 密码登录仍由 `user_account.password_hash` 承担
2. 本轮不引入 `password` provider identity
3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或叙世号作为登录身份
4. 密码登录不创建账号,新账号只由手机号验证码登录创建
### 9.2 `POST /api/auth/phone/login`

View File

@@ -18,7 +18,7 @@
当前 Node 鉴权主链已经依赖 `users` 主表完成以下能力:
1. `POST /api/auth/entry`用户名密码登录,不存在则自动创建账号
1. `POST /api/auth/entry`手机号密码登录,仅允许已存在且已设置密码的手机号账号登录
2. `POST /api/auth/phone/login`:手机号验证码登录,不存在则自动创建账号
3. `GET /api/auth/me`:读取当前账号基础信息
4. `POST /api/auth/logout`:提升 `token_version`,让当前 access token 失效
@@ -99,8 +99,9 @@
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | `String` | 是 | 主键,继续沿用当前 `user_*` 前缀格式。 |
| `username` | `String` | 是 | 当前密码登录用户名;手机号/微信创建的系统账号同样要写入唯一用户名。 |
| `password_hash` | `String` | | 密码登录校验字段;手机号/微信建账号时继续写随机密码哈希,保持兼容。 |
| `username` | `String` | 是 | 系统账号名;不再作为前台密码登录标识,手机号/微信创建账号时仍写入唯一系统用户名。 |
| `password_hash` | `Option<String>` | | 用户显式设置或重置密码后才写入;手机号/微信建账号默认不可用密码登录。 |
| `password_login_enabled` | `bool` | 是 | 是否允许密码登录;只有用户设置或重置密码后才为 `true`。 |
| `token_version` | `u32` | 是 | access token 统一失效计数,默认 `1`。 |
| `display_name` | `String` | 是 | 账号展示名;密码账号默认用户名,手机号账号默认脱敏手机号,微信待绑定账号默认微信昵称或“微信旅人”。 |
| `login_provider` | `String` | 是 | 当前账号的主登录归属,枚举固定为 `password``phone``wechat`。 |
@@ -131,9 +132,9 @@
### 6.2 必须具备的查询索引
1. `username`
作用:支撑 `POST /api/auth/entry`
作用:系统账号唯一约束与内部排查,不作为前台密码登录入口
2. `primary_phone_e164`
作用:支撑 `POST /api/auth/phone/login``POST /api/auth/phone/change`
作用:支撑 `POST /api/auth/entry``POST /api/auth/phone/login``POST /api/auth/phone/change`
3. `account_status + updated_at`
作用:后续管理端、审计排查与禁用账号扫描
4. `merged_to_user_id`
@@ -188,13 +189,12 @@
写入规则:
1. 先按 `username` 查询
2. 若不存在,则创建一条 `active` 账号
3. `login_provider = password`
4. `display_name = username`
5. `primary_phone_e164 = null`
6. `phone_verified_at = null`
7. `last_login_at = 当前时间`
1. 只读取请求中的 `phone``password`
2. 先把 `phone` 归一化为 `primary_phone_e164` 后查询账号
3. 若手机号不存在,返回 `401`,不创建账号。
4. 若账号存在但 `password_login_enabled = false``password_hash = null`,返回 `401`
5. 若账号存在且已设置密码,校验 `password_hash`
6. 校验成功后只更新登录会话与 `last_login_at`,不改变账号主归属。
### 8.2 `POST /api/auth/phone/login`

View File

@@ -47,7 +47,7 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
### `user_account`
- 作用:用户账号主表,保存用户名、公开叙世号、手机号掩码、登录方式、密码登录开关和 token 版本。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option<String>`, `password_login_enabled: bool`, `token_version: u64`
- 索引:`username`, `public_user_code`
```sql