mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
fix(auth): make logout POST+CSRF and propagate session loss to other tabs
- Switch /logout from GET to POST with CSRFMiddleware so it matches the
SPA's existing HttpUtil.post('/logout') call (previously 404'd silently)
and blocks GET-based logout via image tags or link prefetchers. Handler
now returns JSON; the SPA already navigates client-side.
- Return 401 (instead of 404) from /panel/api/* when the caller is a
browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor
redirects to the login page on logout-in-another-tab, cookie expiry,
and server restart. Anonymous callers still get 404 to keep endpoints
hidden from casual scanners.
- One-shot the 401 redirect in axios-init.js and hang the rejected
promise so queued polls don't stack reloads or surface error toasts
while the browser is navigating away.
- Add the CSP nonce to the runtime-injected <script> in dist.go so the
panel loads under the existing script-src 'nonce-...' policy.
- Update api-docs endpoints.js: GET /logout doc entry was missing.
This commit is contained in:
parent
364c5a35e3
commit
4e7687e2fe
5 changed files with 24 additions and 45 deletions
|
|
@ -2,24 +2,16 @@ import axios from 'axios';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
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';
|
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||||
|
|
||||||
// Cached session CSRF token. The legacy panel injects it via a
|
|
||||||
// <meta name="csrf-token"> 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 csrfToken = null;
|
||||||
let csrfFetchPromise = null;
|
let csrfFetchPromise = null;
|
||||||
|
let sessionExpired = false;
|
||||||
|
|
||||||
function readMetaToken() {
|
function readMetaToken() {
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
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() {
|
async function fetchCsrfToken() {
|
||||||
try {
|
try {
|
||||||
const basePath = window.X_UI_BASE_PATH;
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
|
|
@ -91,19 +83,16 @@ export function setupAxios() {
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// 401 → session is gone. In production, the panel routes
|
if (!sessionExpired) {
|
||||||
// are gated by Go's checkLogin which redirects to base_path
|
sessionExpired = true;
|
||||||
// serving the login page; a reload is enough. In dev, Vite
|
if (import.meta.env.DEV) {
|
||||||
// serves /index.html directly at "/", so a reload would put
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
// the user right back on the dashboard and the interceptor
|
window.location.href = `${basePath}login.html`;
|
||||||
// would loop. Navigate to the dev login entry instead.
|
} else {
|
||||||
if (import.meta.env.DEV) {
|
window.location.reload();
|
||||||
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.
|
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||||
const cfg = error.config;
|
const cfg = error.config;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export const sections = [
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/logout',
|
path: '/logout',
|
||||||
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
|
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
||||||
|
|
@ -29,25 +29,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
|
||||||
return a
|
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 <apiToken> — 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) {
|
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
auth := c.GetHeader("Authorization")
|
auth := c.GetHeader("Authorization")
|
||||||
if strings.HasPrefix(auth, "Bearer ") {
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
tok := strings.TrimPrefix(auth, "Bearer ")
|
tok := strings.TrimPrefix(auth, "Bearer ")
|
||||||
if a.settingService.MatchApiToken(tok) {
|
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 {
|
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||||
session.SetAPIAuthUser(c, u)
|
session.SetAPIAuthUser(c, u)
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +43,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !session.IsLogin(c) {
|
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
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
|
||||||
}
|
}
|
||||||
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||||
|
|
||||||
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
nonceAttr := ""
|
||||||
|
if nonce := c.GetString("csp_nonce"); nonce != "" {
|
||||||
|
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
|
||||||
|
}
|
||||||
|
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||||
if name != "login.html" {
|
if name != "login.html" {
|
||||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||||
|
|
|
||||||
|
|
@ -39,15 +39,10 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
|
||||||
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/logout", a.logout)
|
|
||||||
// Public CSRF endpoint — the SPA login page (served by Vite in
|
|
||||||
// dev or by serveDistPage in prod) needs a token to POST /login,
|
|
||||||
// but the panel-side /panel/csrf-token sits behind checkLogin.
|
|
||||||
// EnsureCSRFToken creates a session token even for anonymous
|
|
||||||
// callers, so any pre-login flow can bootstrap from here.
|
|
||||||
g.GET("/csrf-token", a.csrfToken)
|
g.GET("/csrf-token", a.csrfToken)
|
||||||
|
|
||||||
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||||
|
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +135,7 @@ func loginFailureReason(err error) string {
|
||||||
return "invalid credentials"
|
return "invalid credentials"
|
||||||
}
|
}
|
||||||
|
|
||||||
// logout handles user logout by clearing the session and redirecting to the login page.
|
// logout clears the session. The SPA performs the navigation client-side.
|
||||||
func (a *IndexController) logout(c *gin.Context) {
|
func (a *IndexController) logout(c *gin.Context) {
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
|
|
@ -150,7 +145,7 @@ func (a *IndexController) logout(c *gin.Context) {
|
||||||
logger.Warning("Unable to clear session on logout:", err)
|
logger.Warning("Unable to clear session on logout:", err)
|
||||||
}
|
}
|
||||||
c.Header("Cache-Control", "no-store")
|
c.Header("Cache-Control", "no-store")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// csrfToken returns the session CSRF token. Public — the login page
|
// csrfToken returns the session CSRF token. Public — the login page
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue