- 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:
1 parent
4be569d978
commit
793848b06f
31 files changed
+4639
-333
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user