linguaielts-api / fronted /src /api /client.js
AnhviNguyen
fix: move startup tasks to background, lazy import NeuralProphet/matplotlib
1e7cc3a
Raw
History Blame Contribute Delete
5.85 kB
/**
* src/api/client.js
* ──────────────────
* Axios instance with automatic token refresh on 401.
*
* Auth flow:
* 1. Every request carries the access token (Authorization: Bearer …)
* 2. On 401 (expired) → silently POST /api/auth/refresh (sends httpOnly
* refresh cookie automatically) → store new access token → retry
* 3. If refresh also fails → clear session → redirect /login
*
* CSRF: double-submit pattern.
* The backend sets a readable csrf_token cookie (non-httpOnly, path=/).
* We read it and echo it back in X-CSRF-Token on mutating requests.
* /auth/refresh and /auth/logout are exempt on the backend side.
*/
import axios from 'axios'
import { clearTokens, getAccessToken, setTokens } from '@/api/tokenStore.js'
const apiClient = axios.create({
baseURL: '/api',
timeout: 80000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // always send cookies (refresh token, csrf_token)
})
// ── CSRF helper ───────────────────────────────────────────────────────────────
function getCsrfToken() {
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
return match ? decodeURIComponent(match[1]) : null
}
// ── Request interceptor: attach access token + CSRF ───────────────────────────
apiClient.interceptors.request.use(
(config) => {
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
delete config.headers['content-type']
}
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
const method = (config.method || 'get').toLowerCase()
if (['post', 'put', 'patch', 'delete'].includes(method)) {
const csrf = getCsrfToken()
if (csrf) config.headers['X-CSRF-Token'] = csrf
}
return config
},
(error) => Promise.reject(error),
)
// ── Token refresh state ───────────────────────────────────────────────────────
let _refreshing = false
let _pendingQueue = [] // requests waiting while refresh is in progress
function _flushQueue(error, token = null) {
_pendingQueue.forEach((p) => (error ? p.reject(error) : p.resolve(token)))
_pendingQueue = []
}
// ── Response interceptor: handle 401 / 403 ───────────────────────────────────
apiClient.interceptors.response.use(
(response) => response,
async (err) => {
const original = err.config
const status = err.response?.status
// ── Automatic token refresh on 401 ──────────────────────────────────────
const isRefreshEndpoint = original?.url?.includes('/auth/refresh')
const isLoginEndpoint = original?.url?.includes('/auth/login')
const isGoogleEndpoint = original?.url?.includes('/auth/google')
if (status === 401 && original && !original._retry && !isRefreshEndpoint && !isLoginEndpoint && !isGoogleEndpoint) {
// Queue concurrent requests until refresh completes
if (_refreshing) {
return new Promise((resolve, reject) => {
_pendingQueue.push({ resolve, reject })
}).then((newToken) => {
original.headers.Authorization = `Bearer ${newToken}`
return apiClient(original)
})
}
original._retry = true
_refreshing = true
try {
// Use bare axios so this call never goes through the response interceptor
// (prevents infinite 401 → refresh → 401 loops)
const { data } = await axios.post(
'/api/auth/refresh',
{},
{ withCredentials: true },
)
const newToken = data.access_token
setTokens(newToken)
apiClient.defaults.headers.common.Authorization = `Bearer ${newToken}`
original.headers.Authorization = `Bearer ${newToken}`
_flushQueue(null, newToken)
return apiClient(original)
} catch (refreshError) {
_flushQueue(refreshError, null)
_forceLogout()
return Promise.reject(refreshError)
} finally {
_refreshing = false
}
}
// ── Refresh token itself is invalid / expired → force logout ────────────
// Only force logout if we were actively rotating a token (_refreshing),
// NOT during initial session check from the router guard — that case is
// handled gracefully by refreshSession()'s own catch block.
if (status === 401 && isRefreshEndpoint && _refreshing) {
_flushQueue(new Error('Refresh token expired or revoked'), null)
_refreshing = false
_forceLogout()
}
// ── Account locked ───────────────────────────────────────────────────────
if (status === 403 && err.response?.data?.detail === 'Account is locked') {
_forceLogout()
}
return Promise.reject(err)
},
)
/**
* Clear local auth state and redirect to login.
* Lazy-imports the auth store to avoid circular dependency at module load.
*/
async function _forceLogout() {
try {
const { useAuthStore } = await import('@/stores/auth.js')
await useAuthStore().resetLocalAuth()
} catch {
clearTokens()
}
const { default: router } = await import('@/router/index.js')
if (router.currentRoute.value.path !== '/login') {
await router.push('/login')
}
}
export default apiClient