From bbefe91011be44c51ab6edf9f874a6a11a8fe261 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 13 May 2026 14:08:16 +0200 Subject: [PATCH] fix(auth): invalidate sessions when 2FA is enabled, fix dev 401 loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UserService.BumpLoginEpoch and call it from updateSetting when TwoFactorEnable flips false → true. Existing cookies (issued under the looser no-2FA policy) get a 401 on their next request and are forced through the login flow. Disabling 2FA is a relaxation and does not bump the epoch — sessions stay valid. Also fix the dev-mode 401 redirect: targeting `${basePath}login.html` breaks when basePath isn't "/" (Vite has no file at e.g. "/test/login.html"; the SPA fallback loops the 401). Navigate to basePath instead — Vite's bypassMigratedRoute and Go's index handler both serve login.html for that path. Strip stale doc-comment from netsafe and IndexController.logout in line with the project's no-inline-comments convention. --- frontend/src/api/axios-init.js | 8 ++------ util/netsafe/netsafe.go | 21 --------------------- web/controller/index.go | 1 - web/controller/setting.go | 6 ++++++ web/service/user.go | 8 ++++++++ 5 files changed, 16 insertions(+), 28 deletions(-) diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index 95f5c689..2ea235c5 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -85,12 +85,8 @@ export function setupAxios() { if (status === 401) { 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(); - } + const basePath = window.X_UI_BASE_PATH || '/'; + window.location.replace(basePath); } return new Promise(() => { }); } diff --git a/util/netsafe/netsafe.go b/util/netsafe/netsafe.go index d889cf2f..cb4b329d 100644 --- a/util/netsafe/netsafe.go +++ b/util/netsafe/netsafe.go @@ -1,8 +1,3 @@ -// Package netsafe provides SSRF-safe HTTP dialing primitives. A dialer -// installed via SSRFGuardedDialContext resolves the host, rejects -// private/internal IPs unless the per-request context whitelists them, -// and dials the resolved IP directly so the IP checked is the IP used — -// closing the DNS-rebinding TOCTOU window. package netsafe import ( @@ -14,8 +9,6 @@ import ( "time" ) -// IsBlockedIP returns true for loopback, RFC1918 private, link-local -// (including 169.254.169.254 cloud-metadata), and unspecified addresses. func IsBlockedIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() @@ -23,9 +16,6 @@ func IsBlockedIP(ip net.IP) bool { type allowPrivateCtxKey struct{} -// ContextWithAllowPrivate marks a context as permitting outbound requests -// to private/internal IPs. Use only for callers (e.g. LAN-resident nodes) -// where the admin has opted in explicitly. func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context { return context.WithValue(ctx, allowPrivateCtxKey{}, allow) } @@ -37,9 +27,6 @@ func AllowPrivateFromContext(ctx context.Context) bool { var defaultDialer = &net.Dialer{Timeout: 10 * time.Second} -// SSRFGuardedDialContext is a net/http Transport.DialContext implementation -// that enforces IsBlockedIP unless the context opts in via -// ContextWithAllowPrivate. func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { @@ -73,16 +60,8 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn return nil, lastErr } -// hostnamePattern accepts RFC 1123 hostnames (letters, digits, hyphens, -// dots). Bracketed IPv6 forms ("[::1]") are stripped before this check -// runs in NormalizeHost. var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`) -// NormalizeHost validates that addr is a plain hostname or IP literal with -// no embedded path/userinfo/port/scheme — anything that could be used to -// smuggle URL components past callers that string-format URLs from user -// input. Returns the bare host (no brackets); callers wrap IPv6 via -// net.JoinHostPort as needed. func NormalizeHost(addr string) (string, error) { addr = strings.TrimSpace(addr) if addr == "" { diff --git a/web/controller/index.go b/web/controller/index.go index 24ba26e4..b284202d 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -135,7 +135,6 @@ func loginFailureReason(err error) string { return "invalid credentials" } -// logout clears the session. The SPA performs the navigation client-side. func (a *IndexController) logout(c *gin.Context) { user := session.GetLoginUser(c) if user != nil { diff --git a/web/controller/setting.go b/web/controller/setting.go index 7c4ec7b1..dd21eebb 100644 --- a/web/controller/setting.go +++ b/web/controller/setting.go @@ -76,7 +76,13 @@ func (a *SettingController) updateSetting(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) return } + oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable() err = a.settingService.UpdateAllSetting(allSetting) + if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { + if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil { + err = bumpErr + } + } jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) } diff --git a/web/service/user.go b/web/service/user.go index 28970e7b..00de4280 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -102,6 +102,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode return user, nil } +func (s *UserService) BumpLoginEpoch() error { + db := database.GetDB() + return db.Model(model.User{}). + Where("1 = 1"). + Update("login_epoch", gorm.Expr("login_epoch + 1")). + Error +} + func (s *UserService) UpdateUser(id int, username string, password string) error { db := database.GetDB() hashedPassword, err := crypto.HashPasswordAsBcrypt(password)