登录改用访问令牌 + 刷新令牌,不用 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:
1 parent
a3eb563b70
commit
5bb2940969
11 files changed
+332
-110
No files matched your search
+24
-12
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user