diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index cae11195..95f5c689 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -2,24 +2,16 @@ import axios from 'axios'; import qs from 'qs'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); -// Public CSRF endpoint — works pre-login (the panel-scoped -// /panel/csrf-token sits behind checkLogin and would 401 a fresh -// login page that hasn't authenticated yet). const CSRF_TOKEN_PATH = '/csrf-token'; -// Cached session CSRF token. The legacy panel injects it via a -// tag rendered by Go; the new SPA pages -// fetch it once from /panel/csrf-token instead. Module-level so -// every axios POST sees the latest value. let csrfToken = null; let csrfFetchPromise = null; +let sessionExpired = false; function readMetaToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; } -// Fetch the token via a bare fetch() (not axios) so the call doesn't -// recurse through this same interceptor. async function fetchCsrfToken() { try { const basePath = window.X_UI_BASE_PATH; @@ -91,19 +83,16 @@ export function setupAxios() { async (error) => { const status = error.response?.status; if (status === 401) { - // 401 → session is gone. In production, the panel routes - // are gated by Go's checkLogin which redirects to base_path - // serving the login page; a reload is enough. In dev, Vite - // serves /index.html directly at "/", so a reload would put - // the user right back on the dashboard and the interceptor - // would loop. Navigate to the dev login entry instead. - if (import.meta.env.DEV) { - const basePath = window.X_UI_BASE_PATH || '/'; - window.location.href = `${basePath}login.html`; - } else { - window.location.reload(); + if (!sessionExpired) { + sessionExpired = true; + if (import.meta.env.DEV) { + const basePath = window.X_UI_BASE_PATH || '/'; + window.location.href = `${basePath}login.html`; + } else { + window.location.reload(); + } } - return Promise.reject(error); + return new Promise(() => { }); } // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. const cfg = error.config; diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 86658bd2..c92ee389 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -49,6 +49,7 @@ export const sections = [ method: 'POST', path: '/logout', summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.', + response: '{\n "success": true\n}', }, { method: 'GET', diff --git a/web/controller/api.go b/web/controller/api.go index 8aaeaefa..9344541a 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -29,25 +29,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) * return a } -// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests -// to hide the existence of API endpoints from unauthorized users. -// -// Two auth paths are accepted: -// 1. Authorization: Bearer — used by remote central panels -// polling this instance as a node. Matches via constant-time compare. -// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit. -// 2. Existing session cookie — used by browsers logged into the panel UI. -// -// Anything else falls through to a 404 so the API endpoints remain hidden. func (a *APIController) checkAPIAuth(c *gin.Context) { auth := c.GetHeader("Authorization") if strings.HasPrefix(auth, "Bearer ") { tok := strings.TrimPrefix(auth, "Bearer ") if a.settingService.MatchApiToken(tok) { - // Handlers like InboundController.addInbound assume a logged-in - // user (inbound.UserId = user.Id). Bearer callers have no - // session, so attach the first user as a fallback. Single-user - // panels are the norm here. if u, err := a.userService.GetFirstUser(); err == nil { session.SetAPIAuthUser(c, u) } @@ -57,7 +43,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) { } } if !session.IsLogin(c) { - c.AbortWithStatus(http.StatusNotFound) + if c.GetHeader("X-Requested-With") == "XMLHttpRequest" { + c.AbortWithStatus(http.StatusUnauthorized) + } else { + c.AbortWithStatus(http.StatusNotFound) + } return } c.Next() diff --git a/web/controller/dist.go b/web/controller/dist.go index 51bd3574..fd1b35a9 100644 --- a/web/controller/dist.go +++ b/web/controller/dist.go @@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) { } csrfMeta := []byte(``) - script := `