登录改用访问令牌 + 刷新令牌,不用 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
}
+10 -4
View File
@@ -1,6 +1,7 @@
// 当前登录的用户。页面启动时先问一次服务器,之后登录、退出时更新。
// 当前登录的用户。页面启动时用保存的令牌问一次服务器,之后登录、退出时更新。
import { ref } from 'vue'
import { api } from './api'
import { clearTokens, refreshToken, setTokens, type Tokens } from './tokens'
export interface User {
id: number
@@ -13,18 +14,23 @@ export interface User {
export const currentUser = ref<User | null>(null)
/** 页面启动时调用:已登录(cookie 有效)就取到用户 */
/** 页面启动时调用:保存的令牌还有效(或能刷新)就取到用户 */
export async function loadCurrentUser() {
if (!refreshToken()) return
currentUser.value = await api<{ user: User }>('GET', '/api/me')
.then((r) => r.user)
.catch(() => null)
}
export async function login(username: string, password: string) {
currentUser.value = (await api<{ user: User }>('POST', '/api/login', { username, password })).user
const result = await api<Tokens & { user: User }>('POST', '/api/login', { username, password })
setTokens(result)
currentUser.value = result.user
}
export async function logout() {
await api('POST', '/api/logout').catch(() => {})
const token = refreshToken()
clearTokens()
currentUser.value = null
if (token) await api('POST', '/api/logout', { refreshToken: token }).catch(() => {})
}
+19 -14
View File
@@ -1,4 +1,5 @@
import { reactive, shallowReactive } from 'vue'
import { validAccessToken } from '../tokens'
import type {
BoardState,
ClientMessage,
@@ -43,20 +44,24 @@ export function createRunSession() {
let actionSentAt = 0
function connect() {
connecting ??= new Promise<void>((resolve) => {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${location.host}/ws`)
socket = ws
ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
ws.onopen = () => resolve()
ws.onclose = () => {
state.status = 'stopped'
state.board = null
socket = undefined
connecting = undefined
resolve()
}
})
connecting ??= validAccessToken().then(
(token) =>
new Promise<void>((resolve) => {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
// 浏览器的 WebSocket 不能带请求头,访问令牌放在网址参数里(只在建立连接时检查)
const ws = new WebSocket(`${protocol}://${location.host}/ws?token=${encodeURIComponent(token ?? '')}`)
socket = ws
ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
ws.onopen = () => resolve()
ws.onclose = () => {
state.status = 'stopped'
state.board = null
socket = undefined
connecting = undefined
resolve()
}
}),
)
return connecting
}
+95
View File
@@ -0,0 +1,95 @@
// 登录令牌:访问令牌(15 分钟)和刷新令牌(30 天)都放在 localStorage,多个标签页共用。
// 访问令牌快过期时先用刷新令牌换一对新的;同时有多个请求要刷新时只刷新一次。
// 刷新令牌每用一次就作废:另一个标签页刚刷新过时,这边的旧令牌会失败,这时改用它存下的新令牌。
const ACCESS_KEY = 'iot-sim:access-token'
const REFRESH_KEY = 'iot-sim:refresh-token'
export interface Tokens {
accessToken: string
refreshToken: string
}
function read(key: string) {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
export function setTokens(tokens: Tokens) {
try {
localStorage.setItem(ACCESS_KEY, tokens.accessToken)
localStorage.setItem(REFRESH_KEY, tokens.refreshToken)
} catch {
// 浏览器禁用了本地存储:只能本次页面内有效
memory = tokens
}
}
export function clearTokens() {
memory = null
try {
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
} catch {
// 忽略
}
}
/** 本地存储不可用时退回内存 */
let memory: Tokens | null = null
const accessToken = () => read(ACCESS_KEY) ?? memory?.accessToken ?? null
export const refreshToken = () => read(REFRESH_KEY) ?? memory?.refreshToken ?? null
/** 访问令牌是否会在一分钟内过期(读 JWT 里的 exp,不校验签名) */
function expiresSoon(token: string) {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) as { exp?: number }
return !payload.exp || payload.exp * 1000 < Date.now() + 60_000
} catch {
return true
}
}
let refreshing: Promise<boolean> | null = null
/** 用刷新令牌换新令牌;返回是否成功。登录已过期(服务器拒绝)时清掉令牌 */
export function refreshTokens(): Promise<boolean> {
refreshing ??= (async () => {
const tried = refreshToken()
if (!tried) return false
let response: Response
try {
response = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: tried }),
})
} catch {
return false // 网络问题:保留令牌,下次再试
}
if (response.ok) {
setTokens((await response.json()) as Tokens)
return true
}
// 另一个标签页已经用这个刷新令牌换过了新的
const latest = refreshToken()
if (latest && latest !== tried) return true
clearTokens()
return false
})().finally(() => {
refreshing = null
})
return refreshing
}
/** 可用的访问令牌:快过期时先刷新;没登录或登录已过期返回 null */
export async function validAccessToken(): Promise<string | null> {
const token = accessToken()
if (token && !expiresSoon(token)) return token
if (!(await refreshTokens())) return null
return accessToken()
}