176 lines
6.9 KiB
Markdown
176 lines
6.9 KiB
Markdown
# 微信登录与教师身份实施方案
|
||
|
||
本文记录已经实现的“微信身份换取后端会话”体系,以及部署和旧数据迁移步骤。完成生产配置与双账号验收前不应公开发布小程序。
|
||
|
||
## 当前状态
|
||
|
||
代码已实现数据库迁移、微信换码接口、随机会话令牌、统一 Bearer 鉴权、小程序自动登录与单次重试,以及旧用户归属迁移命令。部署时仍需完成:
|
||
|
||
1. 在 5700U 的 `server/.env` 填写 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。
|
||
2. 重建镜像并运行数据库迁移。
|
||
3. 使用目标微信账号首次登录,记录“我的”页面显示的新内部 UUID。
|
||
4. 备份数据库后运行 `migrate-user-owner`,把旧开发 UUID 数据交给该账号。
|
||
5. 使用两个微信账号验证数据隔离,确认生产 `ALLOW_DEVELOPMENT_USER_HEADER=false`。
|
||
|
||
## 目标流程
|
||
|
||
```text
|
||
小程序调用 wx.login()
|
||
-> 将一次性 code 发送到 Rust POST /api/v1/auth/wechat/login
|
||
-> Rust 使用服务端 AppID、AppSecret 调用微信 code2Session
|
||
-> 微信返回 openid(unionid 可选)
|
||
-> Rust 查找或创建内部教师 UUID
|
||
-> Rust 签发项目自己的随机会话令牌
|
||
-> 小程序以 Authorization: Bearer <token> 调用普通接口和上传录音
|
||
```
|
||
|
||
微信官方入口:
|
||
|
||
- [`wx.login`](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html)
|
||
- [`code2Session`](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html)
|
||
- [小程序登录流程](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)
|
||
|
||
`WECHAT_APP_SECRET` 只能保存在 Rust 服务环境变量中。小程序不得包含 AppSecret,也不得接收或保存微信 `session_key`。当前阶段只需要 `openid` 识别当前小程序中的教师。
|
||
|
||
## 数据模型
|
||
|
||
新增三张表:
|
||
|
||
```text
|
||
users
|
||
id uuid primary key
|
||
status text not null
|
||
created_at timestamptz not null
|
||
updated_at timestamptz not null
|
||
|
||
wechat_identities
|
||
app_id text not null
|
||
openid text not null
|
||
unionid text null
|
||
user_id uuid not null references users(id)
|
||
created_at timestamptz not null
|
||
last_login_at timestamptz not null
|
||
primary key (app_id, openid)
|
||
|
||
auth_sessions
|
||
id uuid primary key
|
||
user_id uuid not null references users(id)
|
||
token_hash bytea not null unique
|
||
expires_at timestamptz not null
|
||
last_used_at timestamptz not null
|
||
revoked_at timestamptz null
|
||
created_at timestamptz not null
|
||
```
|
||
|
||
业务表继续使用现有 `owner_id` UUID,不直接使用 `openid`。这样微信身份变化、未来增加手机号或管理员账号时,不需要重写业务数据。
|
||
|
||
## 后端接口
|
||
|
||
### `POST /api/v1/auth/wechat/login`
|
||
|
||
请求:
|
||
|
||
```json
|
||
{ "code": "wx.login 返回的一次性 code" }
|
||
```
|
||
|
||
处理要求:
|
||
|
||
1. 校验 code 非空并限制长度;进程内最多同时执行 8 个微信换码请求,反向代理仍应按来源限制登录频率。
|
||
2. Rust 使用 `WECHAT_APP_ID`、`WECHAT_APP_SECRET` 调用 `code2Session`。
|
||
3. 微信返回错误码、缺少 `openid` 或网络失败时返回明确的 `401`/`502`,日志不得记录 AppSecret、session_key 或完整 code。
|
||
4. 按 `(app_id, openid)` 原子地查找或创建本地用户。
|
||
5. 生成至少 32 字节的加密安全随机令牌,只把 SHA-256 哈希写入数据库。
|
||
6. 原始令牌只在本次响应中返回,建议有效期 30 天。
|
||
|
||
响应:
|
||
|
||
```json
|
||
{
|
||
"access_token": "后端随机会话令牌",
|
||
"expires_at": "2026-08-20T00:00:00Z",
|
||
"user_id": "内部教师 UUID"
|
||
}
|
||
```
|
||
|
||
同时增加:
|
||
|
||
- `GET /api/v1/auth/me`:返回当前内部用户 ID 和会话到期时间。
|
||
- `POST /api/v1/auth/logout`:撤销当前令牌。
|
||
|
||
## 业务接口鉴权
|
||
|
||
实现统一的 Axum 身份 extractor:
|
||
|
||
1. 读取 `Authorization: Bearer <token>`。
|
||
2. 对 token 做 SHA-256 后查询 `auth_sessions`。
|
||
3. 拒绝不存在、已撤销、已过期或用户已停用的会话。
|
||
4. 将数据库中的 `user_id` 作为业务 `owner_id`,客户端不能提交或覆盖它。
|
||
|
||
普通 JSON 请求和 `wx.uploadFile` 必须使用相同的 Bearer token。不得把 `openid`、`user_id` 或固定 UUID 当成可信请求头。
|
||
|
||
本地开发可暂时保留 `X-User-Id`,但必须由显式环境变量控制:
|
||
|
||
```env
|
||
ALLOW_DEVELOPMENT_USER_HEADER=false
|
||
```
|
||
|
||
生产默认和 `compose.deploy.yml` 必须为 `false`。只有本地测试环境可设置为 `true`,并在日志中显示开发鉴权已开启。
|
||
|
||
## 小程序登录状态
|
||
|
||
新增独立 `utils/auth.ts`:
|
||
|
||
1. 从微信 Storage 读取后端 access token 和到期时间。
|
||
2. 没有有效 token 时调用 `wx.login()`,再调用后端登录接口。
|
||
3. 使用共享的 `loginPromise` 合并并发登录,避免多个页面同时换取 code。
|
||
4. 所有 `wx.request` 和 `wx.uploadFile` 自动附加 Bearer token。
|
||
5. 收到一次 `401` 时清理本地 token、重新登录并重试原请求一次,禁止无限重试。
|
||
6. 退出登录时先调用后端撤销接口,再清理 Storage。
|
||
|
||
“我的”页面移除固定开发 UUID,改为显示登录状态和内部教师 ID;不显示 `openid`、session_key 或完整 access token。
|
||
|
||
## 现有开发数据归属
|
||
|
||
当前数据属于固定开发用户:
|
||
|
||
```text
|
||
5a4da7f0-d70c-465f-bcab-124c504aa9f0
|
||
```
|
||
|
||
不能把这些数据自动交给“部署后第一个登录的人”,否则存在抢占风险。采用一次性管理命令迁移:
|
||
|
||
1. 先发布微信登录功能,由目标微信账号完成一次登录并取得新的内部用户 UUID。
|
||
2. 在服务端运行受控 CLI:
|
||
|
||
```text
|
||
docker compose -f compose.deploy.yml run --rm --no-deps api \
|
||
migrate-user-owner \
|
||
--from 5a4da7f0-d70c-465f-bcab-124c504aa9f0 \
|
||
--to <新用户UUID>
|
||
```
|
||
|
||
3. CLI 在单个数据库事务中更新学生档案、预设、反馈记录和反馈会话的 `owner_id`。
|
||
4. 遇到目标账号已有同类数据或唯一约束冲突时中止并报告,不做部分迁移。
|
||
5. 迁移前备份数据库,迁移后比较每张表的记录数并进行页面验收。
|
||
|
||
CLI 不接收 `openid` 或 AppSecret;它只合并两个经过后端确认的内部 UUID,便于审计和回滚。
|
||
|
||
## 分阶段交付
|
||
|
||
1. 数据库迁移、微信 API 客户端、登录接口和会话存储。
|
||
2. 后端统一 Bearer extractor,同时在本地保留受控的开发头兼容模式。
|
||
3. 小程序登录管理、普通请求和录音上传的自动鉴权与一次重试。
|
||
4. 自动化测试:code2Session 模拟、令牌过期/撤销、跨用户隔离、上传鉴权和并发登录。
|
||
5. 部署环境变量并验证目标微信账号登录。
|
||
6. 运行一次性旧数据迁移,关闭生产 `X-User-Id`,最后再提交体验版测试。
|
||
|
||
## 发布验收
|
||
|
||
- 两个不同微信账号看到的数据互相隔离。
|
||
- 客户端伪造 `X-User-Id`、`openid` 或 `user_id` 无法越权。
|
||
- access token 过期或撤销后必须重新登录。
|
||
- 普通接口和录音上传均能在 token 刷新后重试一次。
|
||
- AppSecret、session_key 和令牌未出现在代码、Git、日志或错误响应中。
|
||
- 原开发用户数据仅归属指定的目标微信账号。
|