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.
This commit is contained in:
@@ -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 || '/';
|
||||||
const basePath = window.X_UI_BASE_PATH || '/';
|
window.location.replace(basePath);
|
||||||
window.location.href = `${basePath}login.html`;
|
|
||||||
} else {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return new Promise(() => { });
|
return new Promise(() => { });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user