登录改用访问令牌 + 刷新令牌,不用 cookie

- 访问令牌:JWT(HS256),15 分钟,Authorization: Bearer;WebSocket 放在网址参数里
- 刷新令牌:30 天,每次刷新换新的,数据库只存哈希;退出登录即作废
- 重置密码、删除学生:令牌版本加一并删除刷新令牌,所有设备立即退出
- 签名密钥首次使用时生成,存在数据库 settings 表
- 浏览器:令牌存 localStorage;快过期先刷新,401 时刷新一次再重试;多个标签页同时刷新不冲突

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 18:20:41 +08:00
1 parent a3eb563b70
commit 5bb2940969
11 files changed
+332 -110

No files matched your search

+24 -12
View File
@@ -1,5 +1,7 @@
// 调用服务器接口。出错时抛出 ApiError,message 是服务器给的中文说明;
// 登录过期(401)时回到登录页。
// 请求带上访问令牌(快过期时先刷新);登录过期(刷新也失败)时回到登录页。
import { refreshTokens, validAccessToken } from './tokens'
export class ApiError extends Error {
constructor(
message: string,
@@ -13,19 +15,29 @@ export class ApiError extends Error {
export const apiHooks = { onUnauthorized: () => {} }
export async function api<T>(method: string, path: string, json?: unknown, init?: RequestInit): Promise<T> {
let response: Response
try {
response = await fetch(path, {
method,
headers: json === undefined ? undefined : { 'Content-Type': 'application/json' },
body: json === undefined ? undefined : JSON.stringify(json),
...init,
})
} catch {
throw new ApiError('连不上服务器,请检查网络', 0)
}
const response = await send(method, path, json, init)
const data = (await response.json().catch(() => ({}))) as T & { message?: string }
if (response.status === 401 && path !== '/api/login') apiHooks.onUnauthorized()
if (!response.ok) throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status)
return data
}
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
const headers: Record<string, string> = {}
if (json !== undefined) headers['Content-Type'] = 'application/json'
if (path !== '/api/login') {
const token = await validAccessToken()
if (token) headers.Authorization = `Bearer ${token}`
}
let response: Response
try {
response = await fetch(path, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), ...init })
} catch {
throw new ApiError('连不上服务器,请检查网络', 0)
}
if (response.status === 401 && path !== '/api/login' && !retried && (await refreshTokens())) {
return send(method, path, json, init, true)
}
return response
}