Bläddra i källkod

fix(auth): invalidate sessions when 2FA is enabled, fix dev 401 loop

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.
MHSanaei 1 dag sedan
förälder
incheckning
bbefe91011

+ 2 - 6
frontend/src/api/axios-init.js

@@ -85,12 +85,8 @@ export function setupAxios() {
       if (status === 401) {
       if (status === 401) {
         if (!sessionExpired) {
         if (!sessionExpired) {
           sessionExpired = true;
           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(() => { });
         return new Promise(() => { });
       }
       }

+ 0 - 21
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
 package netsafe
 
 
 import (
 import (
@@ -14,8 +9,6 @@ import (
 	"time"
 	"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 {
 func IsBlockedIP(ip net.IP) bool {
 	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
 	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
 		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
 		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
@@ -23,9 +16,6 @@ func IsBlockedIP(ip net.IP) bool {
 
 
 type allowPrivateCtxKey struct{}
 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 {
 func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
 	return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
 	return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
 }
 }
@@ -37,9 +27,6 @@ func AllowPrivateFromContext(ctx context.Context) bool {
 
 
 var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
 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) {
 func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
 	host, port, err := net.SplitHostPort(addr)
 	host, port, err := net.SplitHostPort(addr)
 	if err != nil {
 	if err != nil {
@@ -73,16 +60,8 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn
 	return nil, lastErr
 	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])?)*$`)
 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) {
 func NormalizeHost(addr string) (string, error) {
 	addr = strings.TrimSpace(addr)
 	addr = strings.TrimSpace(addr)
 	if addr == "" {
 	if addr == "" {

+ 0 - 1
web/controller/index.go

@@ -135,7 +135,6 @@ func loginFailureReason(err error) string {
 	return "invalid credentials"
 	return "invalid credentials"
 }
 }
 
 
-// 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 {

+ 6 - 0
web/controller/setting.go

@@ -76,7 +76,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
 		return
 	}
 	}
+	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
 	err = a.settingService.UpdateAllSetting(allSetting)
 	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)
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 }
 }
 
 

+ 8 - 0
web/service/user.go

@@ -102,6 +102,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 	return user, nil
 	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 {
 func (s *UserService) UpdateUser(id int, username string, password string) error {
 	db := database.GetDB()
 	db := database.GetDB()
 	hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
 	hashedPassword, err := crypto.HashPasswordAsBcrypt(password)