外观
登录是怎么回事
字数
1407 字
预计阅读
6 分钟
前端开发者对登录最熟悉的部分是"拿到 token,存起来,每次请求带上"。这一章讲讲后端那一侧发生了什么,以及为什么这个项目不把 token 存在 localStorage 里。
HTTP 没有记忆
HTTP 请求之间是互相独立的:服务端收到第二个请求时,并不知道它和第一个请求来自同一个人。所以登录成功后,服务端要发给你一个"凭证",你以后每次请求都带上它,服务端就知道你是谁了。
这个凭证就是令牌(token)。
两种令牌
项目使用两种令牌,分工不同:
| 访问令牌 access token | 刷新令牌 refresh token | |
|---|---|---|
| 作用 | 每个请求都带上,证明"我是谁" | 只用来换一个新的访问令牌 |
| 有效期 | 30 分钟 | 7 天(勾选"保持登录")或关闭浏览器即失效 |
| 前端存在哪里 | 内存(Pinia,不持久化) | HttpOnly cookie(JS 读不到) |
| 怎么发送 | Authorization: Bearer xxx 请求头 | 浏览器自动带上,只发给 /api/auth |
为什么要分成两种?
如果只有一个长期有效的令牌,一旦被偷,攻击者在很长时间内都能冒充你。分成两种之后:
- 每个请求都要携带的访问令牌有效期很短,被偷了也很快失效;
- 有效期长的刷新令牌很少被发送,并且 JS 读不到,很难被偷走。
为什么不存 localStorage?
localStorage 里的数据,页面上任何一段 JS 都能读到。如果网站存在 XSS 漏洞(比如某个富文本没有过滤干净,或者引入的第三方脚本被篡改),攻击者的脚本就能直接读出令牌并发送出去。
- 访问令牌存在内存里:刷新页面就没了,但也不会一直留在浏览器里;
- 刷新令牌存在 HttpOnly cookie 里:JS 完全读不到,浏览器只会把它自动发送给
/api/auth。
刷新页面之后怎么办? 内存里的访问令牌确实没了,所以页面加载时,前端会先调用一次 /api/auth/refresh:浏览器自动带上 cookie,换回一个新的访问令牌。用户察觉不到这个过程。
令牌在服务端是什么样的
项目没有使用 JWT。令牌只是一串随机字符,本身不包含任何信息。服务端在 Redis 里保存一份"令牌 → 会话"的对应关系:
text
qw:auth:at:<令牌的哈希值> → { userId, deptId, 登录 IP, 登录时间, 过期时间, … }注意,Redis 里存的是令牌的哈希值,不是令牌本身。所以就算 Redis 的数据泄露了,攻击者也拿不到可以直接使用的令牌。
为什么不用 JWT?
JWT 的优点是服务端不需要存储,但缺点是签发之后没法撤销:用户改了密码、被管理员强制下线,已经发出去的 JWT 在过期之前仍然有效。后台管理系统经常需要"立即踢人下线",所以用服务端存储的令牌更合适。既然每次都要查 Redis,JWT 的签名也就没有意义了。
完整流程
text
1. 登录 POST /api/auth/login { username, password }
← { accessToken } + Set-Cookie: refresh=…(HttpOnly)
2. 请求 GET /api/iam/users Authorization: Bearer <accessToken>
服务端:到 Redis 查会话 → 找到了 → 继续处理
3. 过期 GET /api/iam/users ← 401
前端:POST /api/auth/refresh(自动带上 cookie)
← 新的 accessToken + 新的 refresh cookie
前端:用新令牌自动重发刚才的请求
4. 退出 POST /api/auth/logout → 服务端删除会话第 3 步是在 http.ts 的响应拦截器里自动完成的。多个标签页同时发现令牌过期时,会用浏览器的 Web Locks API 排队,只刷新一次。
一些安全细节
这些项目已经处理好了,了解一下就行:
- 刷新令牌每次使用后都会更换。如果一个旧的刷新令牌被再次使用,说明它很可能被偷了,服务端会吊销整个会话;
- 有绝对上限:活跃使用时访问令牌会自动续期,但最长不超过 12 小时(勾选"保持登录"时为 7 天),到期必须重新登录;
- 权限立即生效:管理员修改了你的角色后,你的下一个请求就会使用新的权限,不需要重新登录;
- 强制下线立即生效:管理员踢你下线时,会删除你的会话,并通过 WebSocket 通知你的页面,页面会立即跳转到登录页;
- 修改密码后,你在其他设备上的登录都会失效。
在代码里获取当前用户
大多数时候你不需要手动获取当前用户:created_by 会自动填写,数据范围会自动生效。确实需要的话,项目通过 CLS(可以理解为"每个请求独立的全局变量")保存当前的登录信息:
ts
import { clsGet } from '../../../../core/context/cls.js'
const me = clsGet('principal') // { userId, deptId, … },未登录时为 undefined