Skip to content

Proposal:账号密码登入注册 #146

Description

@Alexander-Noah

Proposal:HTTP Bootstrap 与 WebSocket 账号密码登入注册

关联信息

  • 项目:TimeFlow
  • 模块:账户认证
  • 类型:Proposal
  • 客户端范围:Android App
  • 本版决策点:HTTP 只申请 WebSocket 连接;WebSocket 下发动态 device_id 并完成账号密码注册登入;成功后只签发一个 JWT

一、背景

TimeFlow 需要用最小方案区分不同用户,并保证日程、提醒和语音结果只能由所属用户访问。

本 Proposal 只确立以下规则:

客户端先通过 HTTP 申请 wss_url 和一次性 ws_ticket,再建立 WSS。服务端验证 ws_ticket 后通过 WebSocket 下发 device_id。用户通过 WebSocket 使用账号、密码注册或登入;服务端验证成功后将 user_id 写入当前 WebSocket 连接内存,形成 device_id → user_id 映射并签发一个 JWT。后续业务消息携带 device_id + JWT

device_id 不是硬件标识,也不是永久值。它由服务端通过 WebSocket 分发,可以在下一次登入时变化,客户端不得自行生成或把它当作用户身份。device_id只是用来标识“一次 WebSocket 连接”,不是标识真实手机,也不是用户身份。


二、用户故事与目标

2.1 用户故事

  • 作为新用户,我希望只填写账号和密码就能注册。
  • 作为已有用户,我希望只填写账号和密码就能登入。
  • 作为已登入用户,我希望使用一个 JWT 访问属于自己的数据。
  • 作为系统,我希望通过当前 WebSocket 连接保存的 device_id → user_id 映射确定业务数据归属。

2.2 目标

本期支持:

  • 用户自定义账号和密码;
  • HTTP Bootstrap 只分配 wss_url、一次性 ws_ticket 和过期时间;
  • WebSocket 验证 ws_ticket 后建立可用连接;
  • WebSocket 主动分发 device_id
  • 注册时将新 user_id 写入当前 WebSocket 连接内存;
  • 登入时将账号对应的 user_id 写入当前 WebSocket 连接内存;
  • 注册或登入成功后只签发一个 JWT;
  • JWT 只保存服务端下发的 device_id 和必要标准字段;
  • 每条业务消息通过 JWT 中的 device_id 与当前连接上下文取得 user_id
  • JWT 或当前 WebSocket 连接上下文无效时拒绝业务访问。

三、非目标

本期不包含:

  • 用户资料和账号修改;
  • 团队、组织、角色和管理员权限;
  • 账号合并与数据迁移;
  • 使用硬件序列号、广告标识或系统账号生成 device_id
  • 通过 HTTP 提交注册账号、登入账号或密码;
  • 确定数据库表名、JWT 签名算法、密钥存储产品或代码目录。

四、用户体验

4.1 HTTP 申请 WebSocket 连接

HTTP 只负责申请 WebSocket 连接参数,不接收账号、密码、device_id 或 JWT。

客户端发送 POST /ws/bootstrap
  ↓
HTTP 服务生成一次性 ws_ticket
  ↓
返回 wss_url + ws_ticket + expires_at
  ↓
客户端连接 wss_url
  ↓
发送 ws.connect.command,携带 ws_ticket

规则:

  • ws_ticket 是短期、一次性的连接票据,只允许当前 WSS 完成初始化;
  • ws_ticket 不能表示用户身份,不能访问业务数据,也不能单独换取 JWT;
  • HTTP Bootstrap 不写入 user_id,也不建立用户映射;
  • wss_url 必须使用 wss://,并且域名必须在客户端允许列表内;
  • 账号、密码、device_id 和 JWT 不得放入 URL 查询参数。
  • expires_at代表ws的失效时间

4.2 WebSocket 分发 device_id

客户端建立 WSS 并提交 ws_ticket 后,服务端验证票据,再生成并下发 device_id

客户端发送 ws.connect.command
  ↓
服务端验证 ws_ticket 未过期、未消费
  ↓
原子消费 ws_ticket
  ↓
服务端生成随机、不透明的 device_id
  ↓
将 device_id 保存到当前 WebSocket 连接内存,user_id 为空
  ↓
服务端发送 auth.device.assigned.event
  ↓
客户端仅在当前登入流程内保存 device_id
  ↓
客户端展示注册页或登入页

规则:

  • device_id 只能由服务端生成并通过当前 WebSocket 下发;
  • device_id 在绑定用户前只属于当前登入流程,并具有短期有效时间;
  • 登入前当前 WebSocket 连接中的 user_id 为空;
  • 客户端只使用最近一次收到的 device_id
  • 服务端可以分发新的 device_id,新值不要求与旧值相同;
  • 用户界面不展示、不允许编辑 device_id

4.3 注册

注册页面只包含:

  • 账号:必填,由用户自行设置;
  • 密码:必填,必须满足密码规则。
用户填写账号和密码
  ↓
客户端发送 auth.register.command
  ↓
消息自动携带当前 WebSocket 下发的 device_id
  ↓
服务端验证 device_id、账号、密码和账号唯一性
  ↓
计算 MD5(password)
  ↓
同一事务创建 User(user_id, account, password_hash)
  ↓
将 user_id 写入当前 WebSocket 连接内存
  ↓
服务端签发只包含该 device_id 的 JWT
  ↓
用户进入主界面

账号重复、密码不符合规则或 device_id 不是当前 WebSocket 下发的值时,不创建任何部分数据,也不签发 JWT。

4.4 登入

登入页面只包含账号和密码。device_id 由客户端自动携带,用户不需要填写。

用户填写账号和密码
  ↓
客户端发送 auth.login.command
  ↓
消息自动携带当前 WebSocket 下发的 device_id
  ↓
服务端验证 device_id、账号和密码
  ↓
根据账号查询 User
  ↓
计算 MD5(password) 并与 password_hash 比较
  ↓
将 user_id 写入当前 WebSocket 连接内存
  ↓
服务端签发只包含该 device_id 的 JWT
  ↓
用户进入主界面

登入成功前,当前连接的 user_id 保持为空。账号不存在或密码错误时统一返回“账号或密码错误”,不泄露账号是否存在。

4.5 业务访问

每条需要访问用户数据的 WebSocket 业务消息都携带 device_id + JWT

客户端发送业务消息 + device_id + JWT
  ↓
服务端验证 JWT
  ↓
比较请求 device_id 与 JWT.device_id
  ↓
比较当前 WebSocket 连接中的 device_id
  ↓
从当前 WebSocket 连接内存取得 user_id
  ↓
生成 AuthContext(user_id, device_id, jwt_expires_at)
  ↓
执行业务授权与处理

客户端提交的 user_id 不参与身份确定。JWT 到期、device_id 与当前连接不一致或当前连接未登入时,服务端拒绝业务消息,客户端回到登入页。


五、对象与规则

5.1 对象职责

对象 唯一职责 不得用于
User 保存 user_id、规范化后的 accountpassword_hash 使用账号或 device_id 替代内部用户标识,或保存明文密码
WebSocketTicket 允许一条 WSS 完成初始化 证明用户身份、注册登入或访问业务数据
WebSocketConnectionContext 在当前 WS 连接内存中保存 device_iduser_id 和登入状态 持久化、跨连接恢复或保存密码与 JWT
JWT 证明客户端持有服务端为当前 device_id 签发的凭据 直接声明可信 user_id 或保存业务数据
AuthContext 保存单条业务消息解析出的 user_iddevice_id 和到期时间 接受客户端提交的 user_id 改变身份

5.2 账号和密码

  • 使用 User(user_id, account, password_hash) 保存账户数据;
  • 账号去除首尾空白并转为小写后,直接保存到 account
  • 数据库对 account 施加唯一约束,作为并发注册时账号唯一性的最终依据;
  • 密码由用户自行设置,只能通过 WSS 提交;
  • 注册时计算一次 MD5(password),并将结果保存到 password_hash
  • 登入时计算一次用户输入密码的 MD5,再与 password_hash 比较;
  • 服务端不保存明文密码或可逆密文;
  • 不增加独立密码表、盐值、算法前缀、Pepper、密钥或多层哈希;
  • MD5 属于哈希而不是加密,安全性较低,本期仅作为简化方案;
  • 后续需要提高安全性时,再整体替换 password_hash 的生成和验证方案;
  • 服务端对注册和登入进行账号、IP 与全局维度限流。

5.3 ws_ticket、device_id 与连接上下文

  • ws_ticket 由 HTTP Bootstrap 生成,是高强度随机、不透明值;
  • ws_ticket 具有短期有效时间,验证成功后立即消费;
  • ws_ticket 只允许当前申请流程中的一条 WSS 完成初始化,不签发 JWT;
  • device_id 是服务端生成的高强度随机、不透明值;
  • device_id 不从 Android 硬件或系统标识派生;
  • device_id 下发后只保存在当前 WebSocket 连接内存中,此时 user_id 为空;
  • 注册或登入成功后,服务端将唯一 user_id 写入当前 WebSocket 连接上下文;
  • 当前连接中的 device_iduser_id 组成 device_id → user_id 映射;
  • 客户端提交其他 device_id 不能改变当前连接上下文;
  • WebSocket 连接关闭后,该连接的 device_iduser_id 和登入状态全部释放;
  • 连接上下文不建立数据库表,不持久化。

5.4 JWT

服务端只签发一种 JWT。JWT 最少包含:

Claim 含义
device_id 服务端通过当前 WebSocket 下发并已绑定用户的标识
iss TimeFlow 签发者标识
aud TimeFlow Android App 使用对象
iat 签发时间
exp 到期时间

规则:

  • JWT 不直接保存 user_id,服务端必须将 JWT 中的 device_id 与当前连接比较,再从连接上下文取得 user_id
  • JWT 使用服务端配置的固定算法签名,验证端只允许该算法;
  • 每次验证都检查签名、issaudexpdevice_id
  • JWT 采用固定有效期,不提供其他凭据;
  • JWT 到期后,用户重新输入账号和密码登入;
  • JWT 不进入 URL、日志、分析埋点或错误上报;
  • JWT 载荷不保存密码、日程、提醒或其他业务数据。

六、HTTP 与 WebSocket 协议

6.1 消息顺序

POST /ws/bootstrap
  ↓
返回 wss_url + ws_ticket + expires_at
  ↓
客户端建立 WSS
  ↓
发送 ws.connect.command(ws_ticket)
  ↓
服务端消费 ws_ticket
  ↓
服务端发送 auth.device.assigned.event
  ↓
READY_FOR_LOGIN
  ├─ auth.register.command → 返回 JWT
  └─ auth.login.command    → 返回 JWT
  ↓
业务消息携带 device_id + JWT
  ↓
服务端逐条生成 AuthContext 并处理
  • 服务端未下发 device_id 前,客户端不得发送注册或登入命令;
  • ws_ticket 未通过验证前,服务端不得分发 device_id
  • 未完成注册或登入前,服务端不得处理业务消息;
  • 业务消息必须携带 JWT;
  • 服务端不依赖客户端提交的 user_id
  • JWT 或当前连接上下文校验失败时不调用业务处理器。

6.2 HTTP Bootstrap

POST /ws/bootstrap
Content-Type: application/json

成功响应:

{
  "wss_url": "wss://api.timeflow.example/ws",
  "ws_ticket": "short-lived-one-time-ticket",
  "expires_at": "2026-08-03T20:10:00+08:00"
}

该接口不接收用户认证信息,也不向 WebSocket 连接上下文写入 user_id。重复提交可以生成新的 ws_ticket,但不得延长或重新启用旧票据。

6.3 最小 WebSocket 消息类型

消息类型 方向 输入或结果
ws.connect.command 客户端 → 服务端 携带 request_idws_ticket;验证成功后允许服务端分发 device_id
auth.device.assigned.event 服务端 → 客户端 下发 device_id 和分配有效时间
auth.register.command 客户端 → 服务端 request_id、账号、密码、自动携带的 device_id;成功返回 JWT
auth.login.command 客户端 → 服务端 request_id、账号、密码、自动携带的 device_id;成功返回 JWT
业务消息 客户端 → 服务端 request_iddevice_id、JWT 和业务载荷

所有写命令必须携带 request_id。具体字段、错误码和 WebSocket 关闭码由后续接口 Proposal 定义。

6.4 user_id 解析

每条业务消息按固定顺序处理:

  1. 验证 JWT 签名和标准字段;
  2. 从 JWT 取得 device_id
  3. 确认请求中的 device_id 与 JWT 中的值一致;
  4. 确认该 device_id 与当前 WebSocket 连接上下文中的值一致;
  5. 确认当前连接已登入,并从连接上下文取得 user_id
  6. 生成 AuthContext(user_id, device_id, jwt_expires_at)
  7. 同时使用 user_id 和业务对象 ID 检查数据归属;
  8. 验证通过后才调用业务处理器。

任何客户端字段都不能覆盖步骤 5 从当前连接上下文取得的 user_id


七、边界与失败行为

情况 服务端行为 客户端行为
HTTP Bootstrap 请求合法 返回新的 wss_urlws_ticket 和过期时间 使用返回参数建立 WSS
wss_url 不属于客户端允许域名 不适用 拒绝连接并报告配置错误
ws_ticket 无效、过期或已消费 不分发 device_id,关闭当前 WSS 重新申请连接参数
尚未收到 device_id 就注册或登入 拒绝命令 等待服务端分发
提交的 device_id 不是当前分发值 不创建映射或 JWT 使用最近一次分发值重新提交
账号格式无效或已经存在 不创建用户 提示修改账号
密码不符合规则 不创建用户或 JWT 提示修改密码
账号不存在或密码错误 返回统一认证错误 保留在登入页
JWT 签名、签发者或使用对象无效 拒绝业务消息 删除 JWT,回到登入页
JWT 到期 拒绝业务消息 删除 JWT,重新登入
JWT 中的 device_id 与当前连接不一致,或连接未登入 拒绝业务消息 删除 JWT,重新登入
请求 device_id 与 JWT 中的值不同 拒绝业务消息,不查询业务数据 使用当前有效凭据重新登入
客户端伪造 user_id 忽略伪造值,使用当前连接上下文中的 user_id 不展示其他用户数据
业务对象不属于当前连接的 user_id 返回统一不可见结果 不展示目标对象

八、安全与隐私原则

  • 生产环境的认证与业务通信只允许 WSS;
  • HTTP Bootstrap 只允许 HTTPS;
  • wss_url 必须属于客户端内置的允许域名;
  • ws_ticket 使用安全随机源生成,短期有效且只能成功消费一次;
  • ws_ticket、账号、密码、device_id 和 JWT 均不得放入 URL 查询参数;
  • device_id 由服务端使用安全随机源生成,不能使用可预测序列;
  • 未绑定的 device_id 具有短期有效时间且只能成功绑定一次;
  • JWT 验证算法由服务端固定,不接受客户端任意选择;
  • JWT 验证签名、签发者、使用对象、到期时间和 device_id
  • 密码、JWT、完整 ws_ticket 和完整 device_id 不进入日志、埋点或错误上报;
  • 注册、登入和 JWT 验证执行必要的限流与审计;
  • 服务端只从当前 WebSocket 连接上下文获取可信 user_id
  • 每个业务查询都同时使用当前连接的 user_id 和业务对象 ID 限定数据范围。

九、验收标准

场景 预期结果
调用 HTTP Bootstrap 只返回 wss_url、一次性 ws_ticket 和过期时间,不处理账号密码
使用有效 ws_ticket 初始化 WSS 票据被消费,服务端分发随机、不透明且可变化的 device_id
使用无效、过期或已消费 ws_ticket 服务端不分发 device_id,当前 WSS 被关闭
注册页面 用户只需填写账号和密码
新账号注册成功 计算一次密码 MD5,创建 User,将 user_id 写入当前 WS 连接内存并签发一个 JWT
登入页面 用户只需填写账号和密码,device_id 由客户端自动携带
正确账号密码登入 比较密码 MD5,将查询到的 user_id 写入当前 WS 连接内存并签发一个 JWT
业务消息携带一致的 device_id + JWT 服务端校验消息、JWT 和当前 WS 连接中的 device_id 一致,再取得 user_id
JWT 直接携带或业务消息伪造 user_id 该值不参与身份确定,不能访问其他用户数据
JWT 中的 device_id 与当前 WS 连接不一致 业务消息被拒绝,不调用业务处理器
业务对象属于其他用户 返回统一不可见结果,不泄露对象是否存在

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions