refactor(登录): 登录态从 Cookie 换成 access token + refresh token
Deploy / deploy (push) Canceled after 0s

- access token 是 15 分钟的 JWT,走 Authorization: Bearer;refresh token 7 天,每次换都轮换
- 退出登录、改密码、禁用账号仍然立刻生效:每个请求核对 Redis 会话和库里新加的 user.token_version
- 在服务器后台改密码 / 禁用,机房那边登着的也会一起下线(原来两边 Redis 分开,管不到)
- WebSocket 握手借子协议带 token;JWT 密钥存在本站 Redis,部署不用加配置
- 去掉 COOKIE_SECURE;上线后所有人要重新登录一次

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-20 19:22:52 +08:00
1 parent 4be569d978
commit 793848b06f
31 files changed
+4639 -333

No files matched your search

+8 -4
View File
@@ -95,10 +95,14 @@ shared → ./src/shared
### HTTP Client
`utils/api.ts` — Axios instance with interceptors (`baseURL: "/api"`,
`withCredentials`). It unwraps both the axios envelope and the backend's
`{ data }` envelope, so callers get the payload directly. All API calls proxy
through the dev server (see `vite.config.ts`).
`utils/api.ts` — Axios instance with interceptors (`baseURL: "/api"`). It unwraps both
the axios envelope and the backend's `{ data }` envelope, so callers get the payload
directly. All API calls proxy through the dev server (see `vite.config.ts`).
登录态是 `Authorization: Bearer` 的 access token(15 分钟)+ refresh token,都在
localStorage,换 token 的逻辑全在 `utils/auth.ts`。**不走 axios 的请求用 `authFetch`
代替裸 `fetch`**,否则不带 token、也不会在 `token-expired` 时换一张重试。
WebSocket 的 token 借子协议带(浏览器设不了请求头),见契约 `WS_AUTH_PROTOCOL`。
### Contract guard (`utils/contract.ts`)
+3 -4
View File
@@ -60,6 +60,7 @@ import {
type TodaySubmissionStatistics,
} from "@oj2/contract"
import api from "utils/api"
import { authFetch } from "utils/auth"
import { contract } from "utils/contract"
import { toProblemRow } from "utils/transforms"
import type {
@@ -532,9 +533,8 @@ export function reportExerciseAttempt(
exerciseId: number,
payload: { correct: boolean; answer?: string },
) {
return fetch(`/api/exercises/${exerciseId}/attempts`, {
return authFetch(`/api/exercises/${exerciseId}/attempts`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -559,9 +559,8 @@ export function reportLearnProgress(
tutorialId: number,
payload: { seconds: number; opened: boolean },
) {
return fetch(`/api/tutorials/${tutorialId}/progress`, {
return authFetch(`/api/tutorials/${tutorialId}/progress`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
+8 -5
View File
@@ -1,4 +1,4 @@
import type { LoginRequest, RegisterRequest } from "@oj2/contract"
import type { AuthTokens, LoginRequest, RefreshTokenRequest, RegisterRequest } from "@oj2/contract"
import type {
ClassActivity,
MarkAchievementsRead,
@@ -8,20 +8,23 @@ import type {
WebsiteConfig,
} from "@oj2/contract"
import api from "utils/api"
import { getRefreshToken, saveTokens } from "utils/auth"
import { contractDeferred } from "utils/contract"
import { toProblemRow } from "utils/transforms"
import type { PendingAchievement, Profile, Tag } from "utils/types"
export function login(data: { username: string; password: string }) {
return api.post("auth/login", data satisfies LoginRequest)
export async function login(data: { username: string; password: string }) {
saveTokens(await api.post<AuthTokens>("auth/login", data satisfies LoginRequest))
}
export function signup(data: { username: string; email: string; password: string }) {
return api.post("users", data satisfies RegisterRequest)
}
export function logout() {
return api.delete("auth/session")
/** 吊销服务端那张会话。本地的 token 由调用方清(user store 的 clearProfile) */
export async function logout() {
const refreshToken = getRefreshToken()
if (refreshToken) await api.post("auth/logout", { refreshToken } satisfies RefreshTokenRequest)
}
export async function getProfile(username: string = ""): Promise<Profile | null> {
+22 -2
View File
@@ -1,8 +1,10 @@
import { WS_AUTH_PROTOCOL } from "@oj2/contract"
import { createDiscreteApi } from "naive-ui"
import { ref, getCurrentScope, onScopeDispose, type Ref } from "vue"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { getAccessToken } from "utils/auth"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import type { ClassBoardCell } from "utils/types"
@@ -93,6 +95,8 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
*/
protected closedByUser = false
protected reviveBound = false
/** 正在等 access token、连接还没建出来,见 connect() */
protected opening = false
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
@@ -117,11 +121,27 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
this.bindReviveListeners()
this.status.value = "connecting"
if (this.opening) return
// 握手要带 access token,快过期了得先换一张 —— 所以真正建连接是异步的
this.opening = true
void getAccessToken()
.catch(() => null)
.then((token) => {
this.opening = false
// 等 token 的这段时间里被 disconnect() 了,就不建了
if (this.closedByUser) return
this.open(token)
})
}
/** 建连接、挂回调。只由 connect() 调 */
private open(token: string | null) {
try {
// 所有回调都闭包住这个局部 ws 而不是读 this.ws:一条被换掉的旧连接
// 迟到的 onclose / onerror 不该去改现在这条连接的状态
const ws = new WebSocket(this.url)
// 迟到的 onclose / onerror 不该去改现在这条连接的状态。
// 浏览器的 WebSocket 设不了请求头,token 借子协议带过去,见契约 WS_AUTH_PROTOCOL
const ws = new WebSocket(this.url, token ? [WS_AUTH_PROTOCOL, token] : undefined)
this.ws = ws
ws.binaryType = "arraybuffer"
+2 -2
View File
@@ -115,8 +115,8 @@ export const useUserStore = defineStore("user", () => {
for (const [key, value] of keep) if (value !== null) storage.set(key, value)
}
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由 ——
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地的话,服务端那张
// 会话还活着,refresh token 被人拿走照样能用。跳转留给调用方,store 里不碰路由 ——
// 调用方要**整页**跳走,别用 router:各个 store 里还留着这个人的数据。
async function signOut() {
await logout()
+39 -4
View File
@@ -1,6 +1,11 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
import axios, {
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from "axios"
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import { getAccessToken, refreshAccessToken } from "./auth"
import { STORAGE_KEY } from "./constants"
import storage from "./storage"
@@ -91,10 +96,16 @@ export function handleGlobalApiError(code: string, message?: string) {
const instance = axios.create({
baseURL: "/api",
withCredentials: true,
})
instance.interceptors.request.use((config) => {
/** 已经因为 token-expired 重试过一次的请求,别再进第二轮,也别再被请求拦截器换 token */
type RetriableConfig = InternalAxiosRequestConfig & { authRetried?: boolean }
instance.interceptors.request.use(async (config: RetriableConfig) => {
if (!config.authRetried) {
const token = await getAccessToken()
if (token) config.headers.set("Authorization", `Bearer ${token}`)
}
if (config.params) {
config.params = Object.fromEntries(
Object.entries(config.params).filter(
@@ -112,12 +123,36 @@ instance.interceptors.response.use(
((response: AxiosResponse) => response.data.data) as unknown as (
response: AxiosResponse,
) => AxiosResponse,
(error) => {
async (error) => {
const payload = error.response?.data as ErrorPayload | undefined
const code = payload?.error?.code ?? "network-error"
// 没有响应体 = 请求根本没到后端(断网、反代挂了、超时),学生看到的就是这一句
const message = payload?.error?.message ?? "网络异常,请检查网络后重试"
// access token 不能用了(过期、被吊销):拿 refresh token 换一张,原请求重发一次。
// 重发的结果照样走这两个拦截器,调用方拿到的和一次成功没有区别
const config = error.config as RetriableConfig | undefined
if (code === "token-expired" && config && !config.authRetried) {
config.authRetried = true
const sent = String(config.headers.get("Authorization") ?? "").replace(/^Bearer /, "")
const outcome = await refreshAccessToken(sent)
if (outcome.token) {
config.headers.set("Authorization", `Bearer ${outcome.token}`)
return instance.request(config)
}
if (outcome.code === "network-error") {
return Promise.reject(new ApiError("network-error", "网络异常,请检查网络后重试"))
}
if (outcome.code === "account-disabled") {
handleGlobalApiError("account-disabled")
return Promise.reject(new ApiError("account-disabled", "账号已被禁用,请联系老师"))
}
// 登录真的过期了。拿掉 token 重发:要登录的接口回 login-required 弹登录框,
// 可选登录的(首页、题目列表……)照常当游客看,不平白弹框
config.headers.delete("Authorization")
return instance.request(config)
}
handleGlobalApiError(code, message)
return Promise.reject(new ApiError(code, message))
},
+168
View File
@@ -0,0 +1,168 @@
import type { AuthTokens } from "@oj2/contract"
import { STORAGE_KEY } from "./constants"
import storage from "./storage"
/**
* 登录态的两张 token,存 localStorage。
*
* 不用 sessionStorage:「在新标签页打开」是全站常用操作(提交详情、题目、统计……),
* sessionStorage 不跨标签页,开出去的页面全是未登录。
*
* access token 15 分钟过期,到期前由这里拿 refresh token 去换,后端的口径见
* apps/api/src/auth/session.ts。
*/
/** 剩不到这么久就提前换:别让请求带着还剩两秒的 token 出门、到后端正好过期 */
const REFRESH_AHEAD_MS = 60_000
interface StoredAccess {
token: string
/** 本机时钟的毫秒时刻。用后端给的秒数加本机时钟算,机房电脑时钟不准也不受影响 */
expiresAt: number
}
export function saveTokens(tokens: AuthTokens) {
storage.set(STORAGE_KEY.ACCESS_TOKEN, {
token: tokens.accessToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
} satisfies StoredAccess)
// null = 换 token 的宽限期,沿用本地那张,见契约 AuthTokens 的注释
if (tokens.refreshToken) storage.set(STORAGE_KEY.REFRESH_TOKEN, tokens.refreshToken)
}
export function clearTokens() {
storage.remove(STORAGE_KEY.ACCESS_TOKEN)
storage.remove(STORAGE_KEY.REFRESH_TOKEN)
}
export function getRefreshToken(): string | null {
const value = storage.get(STORAGE_KEY.REFRESH_TOKEN)
return typeof value === "string" ? value : null
}
function readAccess(): StoredAccess | null {
const value = storage.get(STORAGE_KEY.ACCESS_TOKEN) as StoredAccess | null
return value && typeof value.token === "string" && typeof value.expiresAt === "number"
? value
: null
}
/**
* 换 token 的结果。`network-error` 时**不清** token:断网、后端重启都是暂时的,
* 清掉等于让学生重新登录;只有后端明确说 refresh token 不认了才清。
*/
export type RefreshOutcome =
| { token: string; code?: undefined }
| { token: null; code: "refresh-invalid" | "account-disabled" | "network-error" }
let inflight: Promise<RefreshOutcome> | null = null
/**
* 拿 refresh token 换一张新的 access token。同一页面里同时只有一次在飞。
*
* `stale` 是刚被后端拒掉的那张:排队的这段时间里别的标签页可能已经换过了,
* 本地存的要是另一张、而且还新鲜,直接用它,不再去换。
*/
export function refreshAccessToken(stale?: string) {
inflight ??= withRefreshLock(() => runRefresh(stale)).finally(() => (inflight = null))
return inflight
}
/**
* 跨标签页排队。`navigator.locks` 只在安全上下文里有,机房走 http 直连 IP 时是
* undefined —— 那就不排,撞车由后端的宽限期兜住(REFRESH_GRACE_MS)。
*/
function withRefreshLock<T>(task: () => Promise<T>): Promise<T> {
const locks = (navigator as Navigator & { locks?: LockManager }).locks
return locks ? (locks.request("oj2-token-refresh", task) as Promise<T>) : task()
}
async function runRefresh(stale?: string): Promise<RefreshOutcome> {
const access = readAccess()
if (access && access.token !== stale && access.expiresAt - Date.now() > REFRESH_AHEAD_MS) {
return { token: access.token }
}
const refreshToken = getRefreshToken()
if (!refreshToken) return { token: null, code: "refresh-invalid" }
let response: Response
try {
// 裸 fetch:走 utils/api 的 axios 会再进它的拦截器,绕回这里
response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
})
} catch {
return { token: null, code: "network-error" }
}
const body = (await response.json().catch(() => null)) as {
data?: AuthTokens
error?: { code?: string }
} | null
if (response.ok && body?.data?.accessToken) {
saveTokens(body.data)
return { token: body.data.accessToken }
}
const code = body?.error?.code
if (code === "refresh-invalid" || code === "account-disabled") {
clearTokens()
return { token: null, code }
}
// 5xx、网关的错误页:当成暂时的,同上
return { token: null, code: "network-error" }
}
/**
* 发请求前取一张能用的 access token:快过期了先换。没登录返回 null。
* 换的时候断网就先用手上这张碰碰运气,后端不认的话请求方会再走一次换 token。
*/
export async function getAccessToken() {
const access = readAccess()
if (access && access.expiresAt - Date.now() > REFRESH_AHEAD_MS) return access.token
if (!access && !getRefreshToken()) return null
const outcome = await refreshAccessToken()
if (outcome.token) return outcome.token
return outcome.code === "network-error" ? (access?.token ?? null) : null
}
/** 后端回的错误码。读的是副本,原响应体留给调用方 */
async function responseCode(response: Response) {
const body = (await response
.clone()
.json()
.catch(() => null)) as { error?: { code?: string } } | null
return body?.error?.code
}
/**
* 不走 axios 的请求(AI 流式接口、离开页面时的 keepalive 上报)用它代替 fetch:
* 带上 token,后端回 `token-expired` 就换一张重试一次 —— 和 utils/api.ts 拦截器
* 是同一套规矩。
*/
export async function authFetch(input: string, init: RequestInit = {}) {
const send = (token: string | null) =>
fetch(input, {
...init,
headers: {
...(init.headers as Record<string, string> | undefined),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
const token = await getAccessToken()
const response = await send(token)
if (response.status !== 401 || !token) return response
if ((await responseCode(response)) !== "token-expired") return response
const outcome = await refreshAccessToken(token)
// 账号被禁用:换 token 那一步就知道了,原请求不必再发。造一个和后端同形的响应,
// 调用方按原样处理(弹「账号已被禁用」而不是登录框)
if (outcome.code === "account-disabled") {
return new Response(
JSON.stringify({ error: { code: "account-disabled", message: "账号已被禁用,请联系老师" } }),
{ status: 403, headers: { "Content-Type": "application/json" } },
)
}
// 换不到就拿掉 token 重发:要登录的接口回 login-required 弹登录框,可选登录的当游客
return send(outcome.token)
}
+2
View File
@@ -149,6 +149,8 @@ export { USER_TYPE, PROBLEM_PERMISSION } from "@oj2/contract"
export const STORAGE_KEY = {
AUTHED: "authed",
ACCESS_TOKEN: "accessToken",
REFRESH_TOKEN: "refreshToken",
LANGUAGE: "problemLanguage",
ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
+7 -1
View File
@@ -1,12 +1,18 @@
import axios from "axios"
import { createDiscreteApi } from "naive-ui"
import { getAccessToken } from "./auth"
// 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,
// 所以不能复用 utils/api 的拦截器(它会把 response.data.data 取出来)。
const http = axios.create({
baseURL: "/api/admin",
responseType: "blob",
withCredentials: true,
})
http.interceptors.request.use(async (config) => {
const token = await getAccessToken()
if (token) config.headers.set("Authorization", `Bearer ${token}`)
return config
})
// 脱离 n-message-provider 也能弹,理由同 utils/api.ts
+2 -1
View File
@@ -1,4 +1,5 @@
import { handleGlobalApiError } from "./api"
import { authFetch } from "./auth"
export interface JSONEventStreamHandlers<T = any> {
onMessage: (data: T, event?: string) => void
@@ -152,7 +153,7 @@ export async function streamAI<Done = Record<string, unknown>>(
body: unknown,
handlers: AIStreamHandlers<Done>,
) {
const response = await fetch(`/api/${path}`, {
const response = await authFetch(`/api/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),