鉴权认的是谁
面向 VOZEB PRO。鉴权要练的不是登录页怎么排,而是:每一扇门怎么把凭证收成同一个问题——现在这个请求算谁。 08 说有两扇门;这篇说门上挂什么证件、服务端信哪一部分、什么时候绝不能混用。
先用一个全程例子把后文钉住。小王用浏览器登录,点「生成图片」,然后把页面关了。中间出现过三种「我是谁」,别混:
| 时刻 | 谁在敲门 | 手里的东西 | 服务端认成谁 |
|---|---|---|---|
| 打开登录页提交账号密码 | 小王的浏览器 | 账号 + 密码 | 还没有会话,先核凭证,核过才发手腕带 |
| 点生成、刷进度、看自己的图 | 小王的浏览器 | Cookie vozeb_pro_session |
小王(查会话行对上哈希) |
| 页面关了,图还在出 | generation-worker |
Bearer 维护令牌 +「这单是小王的」 |
先认「自己人在催单」,业务仍记在小王头上 |
顶栏 Zustand 里显示「小王」只是上次接口抄下来的名牌。真正放行的是上表中间那一列。
0. 什么时候用这套思路
先问三句,有一句对上就该掏出这篇,而不是先引一套 Passport / JWT 中间件。
- 敲门的是浏览器,还是自己家的进程? 证件不会是同一种。
- 浏览器里那串东西,服务端能不能在不查库的情况下信它说的 userId / role? 能,说明你做成了可伪造的声明,不是本仓的会话。
- 催单员拿到令牌之后,能不能随便填一个用户当自己? 能,说明维护身份和业务归属黏在一起了。
适合:
- ✅ 新写一个要「当前用户」的 API,不知道该不该传
request给getCurrentUser - ✅ 给 Worker / 定时扫库加维护入口,想复用登录 Cookie 或自己签一个 JWT
- ✅ 前端想把 token 放进 localStorage / Zustand 当登录态
- ✅ 登出只清了 React 状态,接口却还能用
- ✅ 要把当前用户对象返回给浏览器,不确定哪些字段能出站
- ✅ 读
StudyVault/02-鉴权与会话,或看到vozeb_pro_session/VOZEB_PRO_MAINTENANCE_TOKEN
不要硬套:
- ⚠️ 媒体短时 HMAC、预签名 S3(那是读路径的一次性门票,见 07,不是登录会话)
- ⚠️ 渠道 API Key、支付密钥(服务端出站用,永不进浏览器)
- ⚠️ 页面上「显示一下用户名」(那是
serializeCurrentUser的展示副本,不是鉴权本身) - ⚠️ 纯公开页(画廊未登录可读的那部分)——没有「当前用户」就不要假装有
在 VOZEB PRO 中的定位: 用户进站靠不透明 Cookie
vozeb_pro_session;Worker 进站靠环境变量维护令牌。getCurrentUser把两种证明收成PublicUser | null。没有独立 Auth 服务;会话行和用户行住在同一套库。
1. 核心公式
鉴权 = 两种进站凭证 → 一个「当前是谁」→ Route 才开门。
| 谁来 | 证件 | 证明了什么 | 没证明什么 |
|---|---|---|---|
| 浏览器 | vozeb_pro_session = sessionId.token |
这台浏览器持有一条未过期的服务端会话 | 用户是管理员、可以催别人的单 |
| Worker | Authorization: Bearer <维护令牌> |
这是自己家的催单进程 | 令牌持有者等于某个超级用户 |
业务(扣积分、媒体主人、限额)永远认 用户行上的 user.id,不认「令牌很强所以随便指定一个人」。
饭店对照:顾客手腕带(Cookie)证明你订过桌;后厨内部对讲暗号(维护令牌)证明催菜的是自己人。催菜的人不能把暗号一亮就说「这桌算我的」。
例子:同一栋楼,两张通行证
小王进店,前台给他一只写着乱码的手腕带(Cookie)。收银、取菜都看这只带,不看他口头说「我是小王」。
厨房里的催菜铃是对讲机暗号(维护令牌)。暗号对了,才允许问「3 号桌那份牛排好了没」。催菜员不能靠暗号说「今晚所有桌算我请客」——账单还是 3 号桌小王的。
2. 什么场景用哪一种证件
用会话 Cookie
- 用户点的任何写操作:生成、保存提示词、改资料、下单
- 用户读自己的私有媒体、会话、积分
- 浏览器里的
services/api默认就会带上 Cookie,不必再塞 Authorization
例子: 小王点「保存提示词」。POST /api/my-prompts 不会在 JSON 里再写一遍 token。浏览器自动带上 vozeb_pro_session。Route 里 getCurrentUser() 不传 request,认的就是这颗 Cookie。换成维护令牌打这个接口,应当 401——那不是催单门。
用维护令牌
generation-worker打/api/maintenance/...- 进程内部需要「以维护身份替某用户把任务往前推」时,用
maintenanceWorkerHeaders/maintenanceWorkerContext,仍先校验令牌 - 不要用它给浏览器做免登、不要把它写进前端环境变量
例子: 小王关了页,视频还在跑。Worker 每 2 秒 POST /api/maintenance/generation-tasks/run,头是 Authorization: Bearer <环境变量里那串>,再带 x-vozeb-pro-worker-user-id: 小王的id。门卫先核对令牌是不是自己人,再确认小王账号还是 active,然后才催小王那一行任务。若有人把令牌写进前端 .env、在浏览器里带 Bearer 打用户 API,那是把后厨暗号发给了顾客。
两种都不要、或另走一条
| 场景 | 走哪 |
|---|---|
| 展示已登录用户的图 | Cookie(或 07 的短时媒体签名),不是维护令牌 |
| 上游来读一张参考图 | 媒体 HMAC;download=original 不能升级成匿名原件 |
| 登录本身 | 限流 + 校验账号密码,然后才 createSession |
| 完全公开的作品页 | 单独的公开入口和限流,不要把私人 Route 改成「有链接就行」 |
例子: 小王把生成结果插进 Canvas。节点里存的是 /api/reference-assets/permanent/2026/08/16/images/....webp,不是维护令牌,也不是登录 Cookie 的拷贝。打开画布时浏览器仍靠会话 Cookie(或一张短时媒体签名)去拉图。签名过期了应再签一张,不要改成「把维护令牌拼进图片 URL」。
3. 用户证件:不透明会话,不是 JWT
登录成功只做这一件事:造一条服务端说了算的会话,把浏览器看不懂的值写进 Cookie。
凭证 → authenticateUser → createSession(userId)
→ 库里只存 token 的哈希
→ Set-Cookie: vozeb_pro_session=sessionId.token
httpOnly + sameSite=lax +(HTTPS 时)secure
例子:小王登录成功后手里到底有什么
服务端新开一行会话,比如 id 是 sess_aaa。真正的密码段是 tok_bbb(随机串)。
- 写进 Cookie、发给浏览器的是:
sess_aaa.tok_bbb - 写进数据库的是:
sess_aaa+hash(tok_bbb)+ 过期时间
小王用开发者工具看见 Cookie,也解不出「我是 user_123、我是 admin」。他若把 tok_bbb 改成 tok_hack,下次请求哈希对不上,直接当没登录。管理员在后台删掉 sess_aaa 这一行,小王立刻全站退出,不必等「token 七天后再过期」。
之后每个用户请求:
Cookie → getUserBySession
→ 有这一行?哈希对得上?没过期?
→ PublicUser 或 null
为什么要这么拧:
| 设计 | 防的是什么 |
|---|---|
| 不透明(浏览器解不出 userId/role) | 改 Cookie 自称管理员 |
sessionId 找行,token 当密码,库里只存哈希 |
库被拖走不能直接拿去登录 |
| 每个请求查会话行 | 登出 / 封号 / 删会话立刻失效,不必等 JWT 过期 |
httpOnly |
XSS 不能 document.cookie 把会话偷走 |
sameSite=lax |
别的站点不能随便带着这颗 Cookie 打 POST |
secure 看真实协议(反代时看转发头,且必须先配可信跳数) |
明文 HTTP 把会话打出去 |
前端 Zustand 里的当前用户不是登录态。 那是上次接口返回的展示副本。权威在 Cookie + 库。登出 = deleteSession + 清 Cookie,不是 user = null。
例子:只清前端会怎样
小王点退出,若页面只执行 useUserStore.setState({ user: null }),顶栏看起来是游客,但 Cookie 还在。他再点一次生成,getCurrentUser() 仍能从 Cookie 找回小王,单照开、积分照扣。正确登出必须让库里的 sess_aaa 消失,并且 Set-Cookie 把 vozeb_pro_session 清掉。
返回给浏览器的 serializeCurrentUser 是裁过的:id、名字、积分、套餐可以有;上游 Base URL、SMTP、支付密钥、Skill 指令不能有。公开设置同理。
例子:回包里多带了什么叫泄密
可以给前端:{ id, displayName, pointsBalance, planName }。
不能给前端:{ openaiKey, smtpPass, channelBaseUrl, skillInstructions }。
后者属于服务端出站,进了浏览器就等于把后厨钥匙挂在大厅告示牌上。
4. 催单证件:维护令牌 + 落到真实用户
Worker 没有浏览器,不能「先登录再带 Cookie」。证件是环境变量 VOZEB_PRO_MAINTENANCE_TOKEN(至少 32 字符)。
maintenance-auth.ts 的规矩:
Authorization: Bearer ...与配置令牌做时间安全比较(防按响应时间猜令牌)- 对上了,才算维护请求
- 可选头
x-vozeb-pro-worker-user-id:这张单在替谁往前推 - 用这个 id 去取用户,必须存在且 active,否则仍当没人
例子:令牌对了也不等于想当谁就当谁
Worker 带了正确的 Bearer,但把头里的 userId 改成「管理员小李」或一个已封禁账号。门卫应当:令牌这一关通过,取用户这一关失败或拒绝当 admin 冒充,不催、不扣小李的积分。正确头是当初开单的小王,且小王仍是 active。
getCurrentUser(request?) 的顺序:
先看 Cookie → 有合法会话就用会话用户
否则若传入了 request → 再看维护令牌 + worker userId
否则 null
不传 request 就只认 Cookie。 维护 Route 若忘了传,Worker 永远是路人。用户 Route 不要传一个不可信的 Request 就指望「顺便」接受令牌——维护入口应当显式、收口。
例子:忘了传 request
维护 Route 里写成 getCurrentUser()。Worker 请求没有 Cookie,函数看完 Cookie 就是 null,直接 401。Compose 日志会像「Worker 在打,业务说没登录」。补上 getCurrentUser(request),或先 isAuthorizedMaintenanceRequest(request),催单才认得出自己人。
反过来:普通的 /api/image-tasks 若「顺便」接受 Bearer 维护令牌,浏览器或脚本就能绕过小王登录、用暗号开单。所以用户门只认 Cookie,催单门只认令牌,两扇门不要开条暗道。
令牌证明的是「自己人在催单」,不是「超级管理员想当谁就当谁」。扣积分、写 storageKey 主人、限额,仍认头里那个已存在且未封禁的用户。
进程内继续往下传时,用令牌 HMAC 签的 maintenanceWorkerContext,不要伪造一颗用户 Cookie 给自己。
5. 和「JWT 放 localStorage」差在哪
| 本仓库 | 常见 JWT 放前端 | |
|---|---|---|
| 浏览器拿着什么 | 看不懂的 sessionId.token |
自己能解码的声明 |
| 服务端存什么 | 哈希 + 过期 | 往往只验签名 |
| 踢人下线 | 删会话行立刻失效 | 到期前很难作废 |
| Worker | 单独的长令牌 | 经常硬塞一个「系统 JWT」 |
| XSS | httpOnly 读不到 |
JS 能读就能被偷 |
代价是每个请求查一次会话。对本单体可接受:会话本来就在同一套库,不必为了「无状态」把可作废性丢掉。
不要为了「前后端分离一点」把会话搬进 localStorage。那是在拆 08 的开单门证件,不是在现代化。
例子:JWT 放 localStorage 会怎样
若登录后把 { userId: 小王, role: user, exp: 七天后 } 塞进 localStorage,页面 JS 能读,XSS 也能读,偷走就能冒充小王直到过期。小王在别的电脑点退出,这台电脑上的 JWT 还活着。本仓的做法是:浏览器只持有看不懂的 Cookie,退出删的是库里那一行,所有电脑一起失效。
6. Route 里怎么落地
用户 API:
const user = await getCurrentUser(); // 不传 request
if (!user) return 401;
// 之后只认 user.id
维护 API:
if (!isAuthorizedMaintenanceRequest(request)) return 401;
// 或 getCurrentUser(request),且必须把 request 传入
await runXxxBatch(...);
登录 Route:先限流,再校验凭证,再 createSession + setSessionCookie。限流是「打太勤」,会话是「你是谁」,两步不要合成一步。
readJsonBody 只管 body 干净,不管你是谁。
例子:两段代码分别在防什么
小王打 POST /api/image-tasks,body 是 { prompt: "一只猫", clientRequestId: "..." }。
getCurrentUser()回答:这是小王,还是匿名——你是谁readJsonBody回答:JSON 能不能解析——你说了什么- 限流回答:小王这一分钟是不是点了太多次——打得勤不勤
登录接口也一样:先限流防有人拿脚本撞密码,再核账号密码,最后才发 Cookie。不能「密码对了就不限流」,也不能「限流过了就不核密码」。
7. 写代码时的五问
- 这个入口的来客是浏览器还是自家进程?证件有没有用错扇门的那一种?
- 调用
getCurrentUser时传不传request?维护入口忘了传,等于没鉴权。 - 浏览器能不能在不经过服务端的情况下,改手里的字符串就换一个 userId / role?
- 登出有没有删会话行?只清 Zustand 算没做。
- 返回给前端的用户 / 设置对象里,有没有上游地址、密钥、Skill 指令?
8. 读完能指挥自己(或 AI)做什么
- 「用户 API 只认 Cookie:
getCurrentUser()不传 request;没有人就 401。」 - 「维护 API 验
VOZEB_PRO_MAINTENANCE_TOKEN;用户落到 header 里那个 active 的 userId,禁止令牌直接当 admin。」 - 「会话不透明,库里只存 token 哈希;不要 JWT 进 localStorage。」
- 「登出删会话 + 清
vozeb_pro_session,不要只清前端 store。」 - 「
serializeCurrentUser/ 公开设置不得带上游、SMTP、支付密钥、Skill 指令。」
相关文档
- 两扇门,一张单 — 开单门与催单门;这篇是门上的证件
- 旁路 Worker 的本质 — 为什么催单员不能用用户 Cookie
- 读路径是门卫 — 媒体 HMAC ≠ 登录会话
- 全栈开发的核心思维 — 权威不在 Zustand
StudyVault/02-鉴权与会话/鉴权与会话.mdweb/src/lib/auth/session.tsweb/src/lib/server/maintenance-auth.tsweb/src/app/api/auth/login/route.ts