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、规范化后的 account 和 password_hash |
使用账号或 device_id 替代内部用户标识,或保存明文密码 |
| WebSocketTicket |
允许一条 WSS 完成初始化 |
证明用户身份、注册登入或访问业务数据 |
| WebSocketConnectionContext |
在当前 WS 连接内存中保存 device_id、user_id 和登入状态 |
持久化、跨连接恢复或保存密码与 JWT |
| JWT |
证明客户端持有服务端为当前 device_id 签发的凭据 |
直接声明可信 user_id 或保存业务数据 |
| AuthContext |
保存单条业务消息解析出的 user_id、device_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_id 与 user_id 组成 device_id → user_id 映射;
- 客户端提交其他
device_id 不能改变当前连接上下文;
- WebSocket 连接关闭后,该连接的
device_id、user_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 使用服务端配置的固定算法签名,验证端只允许该算法;
- 每次验证都检查签名、
iss、aud、exp 和 device_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_id、ws_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_id、device_id、JWT 和业务载荷 |
所有写命令必须携带 request_id。具体字段、错误码和 WebSocket 关闭码由后续接口 Proposal 定义。
6.4 user_id 解析
每条业务消息按固定顺序处理:
- 验证 JWT 签名和标准字段;
- 从 JWT 取得
device_id;
- 确认请求中的
device_id 与 JWT 中的值一致;
- 确认该
device_id 与当前 WebSocket 连接上下文中的值一致;
- 确认当前连接已登入,并从连接上下文取得
user_id;
- 生成
AuthContext(user_id, device_id, jwt_expires_at);
- 同时使用
user_id 和业务对象 ID 检查数据归属;
- 验证通过后才调用业务处理器。
任何客户端字段都不能覆盖步骤 5 从当前连接上下文取得的 user_id。
七、边界与失败行为
| 情况 |
服务端行为 |
客户端行为 |
| HTTP Bootstrap 请求合法 |
返回新的 wss_url、ws_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 连接不一致 |
业务消息被拒绝,不调用业务处理器 |
| 业务对象属于其他用户 |
返回统一不可见结果,不泄露对象是否存在 |
Proposal:HTTP Bootstrap 与 WebSocket 账号密码登入注册
关联信息
device_id并完成账号密码注册登入;成功后只签发一个 JWT一、背景
TimeFlow 需要用最小方案区分不同用户,并保证日程、提醒和语音结果只能由所属用户访问。
本 Proposal 只确立以下规则:
device_id不是硬件标识,也不是永久值。它由服务端通过 WebSocket 分发,可以在下一次登入时变化,客户端不得自行生成或把它当作用户身份。device_id只是用来标识“一次 WebSocket 连接”,不是标识真实手机,也不是用户身份。二、用户故事与目标
2.1 用户故事
device_id → user_id映射确定业务数据归属。2.2 目标
本期支持:
wss_url、一次性ws_ticket和过期时间;ws_ticket后建立可用连接;device_id;user_id写入当前 WebSocket 连接内存;user_id写入当前 WebSocket 连接内存;device_id和必要标准字段;device_id与当前连接上下文取得user_id;三、非目标
本期不包含:
device_id;四、用户体验
4.1 HTTP 申请 WebSocket 连接
HTTP 只负责申请 WebSocket 连接参数,不接收账号、密码、
device_id或 JWT。规则:
ws_ticket是短期、一次性的连接票据,只允许当前 WSS 完成初始化;ws_ticket不能表示用户身份,不能访问业务数据,也不能单独换取 JWT;user_id,也不建立用户映射;wss_url必须使用wss://,并且域名必须在客户端允许列表内;device_id和 JWT 不得放入 URL 查询参数。4.2 WebSocket 分发 device_id
客户端建立 WSS 并提交
ws_ticket后,服务端验证票据,再生成并下发device_id:规则:
device_id只能由服务端生成并通过当前 WebSocket 下发;device_id在绑定用户前只属于当前登入流程,并具有短期有效时间;user_id为空;device_id;device_id,新值不要求与旧值相同;device_id。4.3 注册
注册页面只包含:
账号重复、密码不符合规则或
device_id不是当前 WebSocket 下发的值时,不创建任何部分数据,也不签发 JWT。4.4 登入
登入页面只包含账号和密码。
device_id由客户端自动携带,用户不需要填写。登入成功前,当前连接的
user_id保持为空。账号不存在或密码错误时统一返回“账号或密码错误”,不泄露账号是否存在。4.5 业务访问
每条需要访问用户数据的 WebSocket 业务消息都携带
device_id + JWT:客户端提交的
user_id不参与身份确定。JWT 到期、device_id与当前连接不一致或当前连接未登入时,服务端拒绝业务消息,客户端回到登入页。五、对象与规则
5.1 对象职责
user_id、规范化后的account和password_hashdevice_id替代内部用户标识,或保存明文密码device_id、user_id和登入状态device_id签发的凭据user_id或保存业务数据user_id、device_id和到期时间user_id改变身份5.2 账号和密码
User(user_id, account, password_hash)保存账户数据;account;account施加唯一约束,作为并发注册时账号唯一性的最终依据;MD5(password),并将结果保存到password_hash;password_hash比较;password_hash的生成和验证方案;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_id与user_id组成device_id → user_id映射;device_id不能改变当前连接上下文;device_id、user_id和登入状态全部释放;5.4 JWT
服务端只签发一种 JWT。JWT 最少包含:
device_idissaudiatexp规则:
user_id,服务端必须将 JWT 中的device_id与当前连接比较,再从连接上下文取得user_id;iss、aud、exp和device_id;六、HTTP 与 WebSocket 协议
6.1 消息顺序
device_id前,客户端不得发送注册或登入命令;ws_ticket未通过验证前,服务端不得分发device_id;user_id;6.2 HTTP Bootstrap
成功响应:
{ "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.commandrequest_id、ws_ticket;验证成功后允许服务端分发device_idauth.device.assigned.eventdevice_id和分配有效时间auth.register.commandrequest_id、账号、密码、自动携带的device_id;成功返回 JWTauth.login.commandrequest_id、账号、密码、自动携带的device_id;成功返回 JWTrequest_id、device_id、JWT 和业务载荷所有写命令必须携带
request_id。具体字段、错误码和 WebSocket 关闭码由后续接口 Proposal 定义。6.4 user_id 解析
每条业务消息按固定顺序处理:
device_id;device_id与 JWT 中的值一致;device_id与当前 WebSocket 连接上下文中的值一致;user_id;AuthContext(user_id, device_id, jwt_expires_at);user_id和业务对象 ID 检查数据归属;任何客户端字段都不能覆盖步骤 5 从当前连接上下文取得的
user_id。七、边界与失败行为
wss_url、ws_ticket和过期时间wss_url不属于客户端允许域名ws_ticket无效、过期或已消费device_id,关闭当前 WSSdevice_id就注册或登入device_id不是当前分发值device_id与当前连接不一致,或连接未登入device_id与 JWT 中的值不同user_iduser_iduser_id八、安全与隐私原则
wss_url必须属于客户端内置的允许域名;ws_ticket使用安全随机源生成,短期有效且只能成功消费一次;ws_ticket、账号、密码、device_id和 JWT 均不得放入 URL 查询参数;device_id由服务端使用安全随机源生成,不能使用可预测序列;device_id具有短期有效时间且只能成功绑定一次;device_id;ws_ticket和完整device_id不进入日志、埋点或错误上报;user_id;user_id和业务对象 ID 限定数据范围。九、验收标准
wss_url、一次性ws_ticket和过期时间,不处理账号密码ws_ticket初始化 WSSdevice_idws_ticketdevice_id,当前 WSS 被关闭User,将user_id写入当前 WS 连接内存并签发一个 JWTdevice_id由客户端自动携带user_id写入当前 WS 连接内存并签发一个 JWTdevice_id + JWTdevice_id一致,再取得user_iduser_iddevice_id与当前 WS 连接不一致