diff --git a/drivers/alias/meta.go b/drivers/alias/meta.go index 72eb3c877e..f56cc8197c 100644 --- a/drivers/alias/meta.go +++ b/drivers/alias/meta.go @@ -15,6 +15,7 @@ type Addition struct { DownloadPartSize int `json:"download_part_size" default:"0" type:"number" required:"false" help:"Need to enable proxy. Unit: KB"` ProviderPassThrough bool `json:"provider_pass_through" type:"bool" default:"false"` DetailsPassThrough bool `json:"details_pass_through" type:"bool" default:"false"` + MoveDirect bool `json:"move_direct" type:"bool" default:"false"` } var config = driver.Config{ diff --git a/drivers/alias/util.go b/drivers/alias/util.go index 8e5eb8a843..d2e3acd56a 100644 --- a/drivers/alias/util.go +++ b/drivers/alias/util.go @@ -387,6 +387,40 @@ func (d *Alias) getCopyObjs(ctx context.Context, srcObj, dstDir model.Obj) (Bala return srcObjs, dstObjs, nil } +func (d *Alias) getMoveObjsDirect(ctx context.Context, tmpSrcObjs, dstObjs BalancedObjs) (BalancedObjs, BalancedObjs, error) { + // 按挂载点分组目标目录 + dstByMount := make(map[string][]model.Obj) + for _, o := range dstObjs { + storage, e := fs.GetStorage(o.GetPath(), &fs.GetStoragesArgs{}) + if e != nil { + continue + } + mp := storage.GetStorage().MountPath + dstByMount[mp] = append(dstByMount[mp], o) + } + + srcs := make(BalancedObjs, 0, len(tmpSrcObjs)) + dsts := make(BalancedObjs, 0) + + for _, src := range tmpSrcObjs { + storage, e := fs.GetStorage(src.GetPath(), &fs.GetStoragesArgs{}) + if e != nil { + continue + } + mp := storage.GetStorage().MountPath + if dstList, ok := dstByMount[mp]; ok && len(dstList) > 0 { + srcs = append(srcs, src) + dsts = append(dsts, dstList[0]) + if len(dstList) == 1 { + delete(dstByMount, mp) + } else { + dstByMount[mp] = dstList[1:] + } + } + } + return srcs, dsts, nil +} + func (d *Alias) getMoveObjs(ctx context.Context, srcObj, dstDir model.Obj) (BalancedObjs, BalancedObjs, error) { if d.PutConflictPolicy == DisabledWP { return nil, nil, errs.PermissionDenied @@ -399,6 +433,10 @@ func (d *Alias) getMoveObjs(ctx context.Context, srcObj, dstDir model.Obj) (Bala if err != nil { return nil, nil, err } + // MoveDirect: 源后端数少于目标后端数时,只在同后端上 move,跳过其他后端 + if d.MoveDirect && len(tmpSrcObjs) < len(dstObjs) { + return d.getMoveObjsDirect(ctx, tmpSrcObjs, dstObjs) + } if len(tmpSrcObjs) < len(dstObjs) { return nil, nil, ErrNotEnoughSrcObjs } diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 4526aa5234..e4bbc2f75a 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "strconv" + "time" "github.com/OpenListTeam/OpenList/v4/cmd/flags" "github.com/OpenListTeam/OpenList/v4/internal/conf" @@ -113,6 +114,10 @@ func InitialSettings() []model.SettingItem { {Key: conf.AllowIndexed, Value: "false", Type: conf.TypeBool, Group: model.SITE}, {Key: conf.AllowMounted, Value: "true", Type: conf.TypeBool, Group: model.SITE}, {Key: conf.RobotsTxt, Value: "User-agent: *\nAllow: /", Type: conf.TypeText, Group: model.SITE}, + {Key: conf.AuthLoginMaxRetries, Value: strconv.Itoa(model.DefaultMaxAuthRetries), Type: conf.TypeNumber, Group: model.SITE, Flag: model.PRIVATE, Help: "Max login retry attempts per IP. Set to -1 to disable rate limiting."}, + {Key: conf.AuthLoginLockDuration, Value: strconv.Itoa(int(model.DefaultLockDuration / time.Minute)), Type: conf.TypeNumber, Group: model.SITE, Flag: model.PRIVATE, Help: "Lock duration in minutes after exceeding max retries."}, + {Key: conf.AuthLoginIPWhitelist, Value: "", Type: conf.TypeText, Group: model.SITE, Flag: model.PRIVATE, Help: "Whitelisted IPs or CIDR ranges, one per line. IPs in this list are exempt from login rate limiting."}, + {Key: conf.AuthLoginIPBlacklist, Value: "", Type: conf.TypeText, Group: model.SITE, Flag: model.PRIVATE, Help: "Blacklisted IPs or CIDR ranges, one per line. Login from these IPs will be denied."}, // style settings {Key: conf.Logo, Value: "https://res.oplist.org/logo/logo.svg", MigrationValue: "https://cdn.oplist.org/gh/OpenListTeam/Logo@main/logo.svg", Type: conf.TypeText, Group: model.STYLE}, {Key: conf.Favicon, Value: "https://res.oplist.org/logo/logo.svg", MigrationValue: "https://cdn.oplist.org/gh/OpenListTeam/Logo@main/logo.svg", Type: conf.TypeString, Group: model.STYLE}, diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849cb..d728ae4688 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -10,12 +10,16 @@ const ( const ( // site - VERSION = "version" - SiteTitle = "site_title" - Announcement = "announcement" - AllowIndexed = "allow_indexed" - AllowMounted = "allow_mounted" - RobotsTxt = "robots_txt" + VERSION = "version" + SiteTitle = "site_title" + Announcement = "announcement" + AllowIndexed = "allow_indexed" + AllowMounted = "allow_mounted" + RobotsTxt = "robots_txt" + AuthLoginMaxRetries = "auth_login_max_retries" + AuthLoginLockDuration = "auth_login_lock_duration" + AuthLoginIPWhitelist = "auth_login_ip_whitelist" + AuthLoginIPBlacklist = "auth_login_ip_blacklist" Logo = "logo" // multi-lines text, L1: light, EOL: dark Favicon = "favicon" diff --git a/internal/conf/var.go b/internal/conf/var.go index 6b25bcfb5b..9cb32c441f 100644 --- a/internal/conf/var.go +++ b/internal/conf/var.go @@ -1,6 +1,7 @@ package conf import ( + "net" "net/url" "regexp" "sync" @@ -23,6 +24,8 @@ var ( var SlicesMap = make(map[string][]string) var FilenameCharMap = make(map[string]string) var PrivacyReg []*regexp.Regexp +var AuthLoginIPNets []*net.IPNet +var AuthLoginIPBlackNets []*net.IPNet var ( // 在HybridCache中使用[]byte缓存数据流的限制,内存为Go自动管理,直到GC @@ -65,6 +68,32 @@ func SendStoragesLoadedSignal() { } storagesLoadMu.Unlock() } + +// IsIPWhitelisted checks if the given IP is within any of the configured whitelist CIDR ranges. +func IsIPWhitelisted(ipStr string) bool { + return isIPInNets(ipStr, AuthLoginIPNets) +} + +// IsIPBlacklisted checks if the given IP is within any of the configured blacklist CIDR ranges. +func IsIPBlacklisted(ipStr string) bool { + return isIPInNets(ipStr, AuthLoginIPBlackNets) +} + +func isIPInNets(ipStr string, nets []*net.IPNet) bool { + if len(nets) == 0 { + return false + } + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, ipNet := range nets { + if ipNet.Contains(ip) { + return true + } + } + return false +} func ResetStoragesLoadSignal() { storagesLoadMu.Lock() select { diff --git a/internal/model/auth_limit.go b/internal/model/auth_limit.go new file mode 100644 index 0000000000..ff91df8bb0 --- /dev/null +++ b/internal/model/auth_limit.go @@ -0,0 +1,19 @@ +package model + +import "github.com/OpenListTeam/OpenList/v4/internal/conf" + +// IsAuthRateLimitExceeded checks if the auth rate limit has been exceeded for the given IP. +// When maxRetries <= 0, rate limiting is disabled and this always returns false. +func IsAuthRateLimitExceeded(count int, maxRetries int) bool { + return maxRetries > 0 && count >= maxRetries +} + +// ShouldSkipAuthRateLimit returns true if the IP is whitelisted and rate limiting should be skipped. +func ShouldSkipAuthRateLimit(ip string) bool { + return conf.IsIPWhitelisted(ip) +} + +// IsIPBlocked returns true if the IP is in the blacklist and login should be denied. +func IsIPBlocked(ip string) bool { + return conf.IsIPBlacklisted(ip) +} diff --git a/internal/op/hook.go b/internal/op/hook.go index 5cf01730d2..42542bdd66 100644 --- a/internal/op/hook.go +++ b/internal/op/hook.go @@ -2,6 +2,7 @@ package op import ( "context" + "net" "regexp" "strings" @@ -83,6 +84,12 @@ var settingItemHooks = map[string]SettingItemHook{ conf.SlicesMap[conf.IgnoreDirectLinkParams] = strings.Split(item.Value, ",") return nil }, + conf.AuthLoginIPWhitelist: func(item *model.SettingItem) error { + return updateAuthLoginIPNets(item.Value, &conf.AuthLoginIPNets) + }, + conf.AuthLoginIPBlacklist: func(item *model.SettingItem) error { + return updateAuthLoginIPNets(item.Value, &conf.AuthLoginIPBlackNets) + }, } func RegisterSettingItemHook(key string, hook SettingItemHook) { @@ -110,3 +117,34 @@ func callStorageHooks(typ string, storage driver.Driver) { func RegisterStorageHook(hook StorageHook) { storageHooks = append(storageHooks, hook) } + +func updateAuthLoginIPNets(value string, dst *[]*net.IPNet) error { + if value == "" { + *dst = nil + return nil + } + lines := strings.Split(value, "\n") + var nets []*net.IPNet + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // try as CIDR first, if no "/" present, append /32 for single IP + if !strings.Contains(line, "/") { + if strings.Contains(line, ":") { + line = line + "/128" + } else { + line = line + "/32" + } + } + _, ipNet, err := net.ParseCIDR(line) + if err != nil { + log.Errorf("failed to parse IP list entry %q: %v", line, err) + continue + } + nets = append(nets, ipNet) + } + *dst = nets + return nil +} diff --git a/server/ftp.go b/server/ftp.go index 8999c1f544..56798f6964 100644 --- a/server/ftp.go +++ b/server/ftp.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/conf" @@ -114,10 +115,16 @@ func (d *FtpMainDriver) ClientDisconnected(cc ftpserver.ClientContext) { func (d *FtpMainDriver) AuthUser(cc ftpserver.ClientContext, user, pass string) (ftpserver.ClientDriver, error) { ip := cc.RemoteAddr().String() - count, ok := model.LoginCache.Get(ip) - if ok && count >= model.DefaultMaxAuthRetries { - model.LoginCache.Expire(ip, model.DefaultLockDuration) - return nil, errors.New("Too many unsuccessful sign-in attempts have been made using an incorrect username or password, Try again later.") + if model.IsIPBlocked(ip) { + return nil, errors.New("Access denied: IP is blacklisted") + } + maxRetries := setting.GetInt(conf.AuthLoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.AuthLoginLockDuration, int(model.DefaultLockDuration/time.Minute))) * time.Minute + skipLimit := model.ShouldSkipAuthRateLimit(ip) + count, _ := model.LoginCache.Get(ip) + if !skipLimit && model.IsAuthRateLimitExceeded(count, maxRetries) { + model.LoginCache.Expire(ip, lockDuration) + return nil, errors.New(model.TooManyAttempts) } var userObj *model.User var err error @@ -137,12 +144,16 @@ func (d *FtpMainDriver) AuthUser(cc ftpserver.ClientContext, user, pass string) userObj, err = tryLdapLoginAndRegister(user, pass) } if err != nil { - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return nil, err } } if userObj.Disabled || !userObj.CanFTPAccess() { - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return nil, errors.New("user is not allowed to access via FTP") } model.LoginCache.Del(ip) diff --git a/server/handles/auth.go b/server/handles/auth.go index 7800690918..4c05bf3309 100644 --- a/server/handles/auth.go +++ b/server/handles/auth.go @@ -4,10 +4,12 @@ import ( "bytes" "encoding/base64" "image/png" + "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" "github.com/pquerna/otp/totp" @@ -41,25 +43,37 @@ func LoginHash(c *gin.Context) { } func loginHash(c *gin.Context, req *LoginReq) { - // check count of login + // check blacklist ip := c.ClientIP() - count, ok := model.LoginCache.Get(ip) - if ok && count >= model.DefaultMaxAuthRetries { + if model.IsIPBlocked(ip) { + common.ErrorStrResp(c, "Access denied: IP is blacklisted", 403) + return + } + // rate limiting + maxRetries := setting.GetInt(conf.AuthLoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.AuthLoginLockDuration, int(model.DefaultLockDuration/time.Minute))) * time.Minute + skipLimit := model.ShouldSkipAuthRateLimit(ip) + count, _ := model.LoginCache.Get(ip) + if !skipLimit && model.IsAuthRateLimitExceeded(count, maxRetries) { common.ErrorStrResp(c, model.TooManyAttempts, 429) - model.LoginCache.Expire(ip, model.DefaultLockDuration) + model.LoginCache.Expire(ip, lockDuration) return } // check username user, err := op.GetUserByName(req.Username) if err != nil { common.ErrorStrResp(c, model.InvalidUsernameOrPassword, 401) - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return } // validate password hash if err := user.ValidatePwdStaticHash(req.Password); err != nil { common.ErrorStrResp(c, model.InvalidUsernameOrPassword, 401) - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return } // check 2FA @@ -67,7 +81,9 @@ func loginHash(c *gin.Context, req *LoginReq) { if !totp.Validate(req.OtpCode, user.OtpSecret) { // 402 - need opt common.ErrorStrResp(c, model.Invalid2FACode, 402) - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return } } diff --git a/server/handles/ldap_login.go b/server/handles/ldap_login.go index ba44615f7d..19d8ec6487 100644 --- a/server/handles/ldap_login.go +++ b/server/handles/ldap_login.go @@ -1,6 +1,8 @@ package handles import ( + "time" + "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -27,19 +29,29 @@ func LoginLdap(c *gin.Context) { return } - // check count of login + // check blacklist ip := c.ClientIP() - count, ok := model.LoginCache.Get(ip) - if ok && count >= model.DefaultMaxAuthRetries { - common.ErrorStrResp(c, "Too many unsuccessful sign-in attempts have been made using an incorrect username or password, Try again later.", 429) - model.LoginCache.Expire(ip, model.DefaultLockDuration) + if model.IsIPBlocked(ip) { + common.ErrorStrResp(c, "Access denied: IP is blacklisted", 403) + return + } + // rate limiting + maxRetries := setting.GetInt(conf.AuthLoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.AuthLoginLockDuration, int(model.DefaultLockDuration/time.Minute))) * time.Minute + skipLimit := model.ShouldSkipAuthRateLimit(ip) + count, _ := model.LoginCache.Get(ip) + if !skipLimit && model.IsAuthRateLimitExceeded(count, maxRetries) { + common.ErrorStrResp(c, model.TooManyAttempts, 429) + model.LoginCache.Expire(ip, lockDuration) return } err = common.HandleLdapLogin(req.Username, req.Password) if err != nil { if errors.Is(err, common.ErrFailedLdapAuth) { - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } common.ErrorResp(c, err, 400) } else { common.ErrorResp(c, err, 500) @@ -51,7 +63,9 @@ func LoginLdap(c *gin.Context) { user, err = common.LdapRegister(req.Username) if err != nil { common.ErrorResp(c, err, 400) - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return } } diff --git a/server/sftp.go b/server/sftp.go index 37dc9870db..5a5d035383 100644 --- a/server/sftp.go +++ b/server/sftp.go @@ -94,10 +94,16 @@ func (d *SftpDriver) NoClientAuth(conn ssh.ConnMetadata) (*ssh.Permissions, erro func (d *SftpDriver) PasswordAuth(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { ip := conn.RemoteAddr().String() - count, ok := model.LoginCache.Get(ip) - if ok && count >= model.DefaultMaxAuthRetries { - model.LoginCache.Expire(ip, model.DefaultLockDuration) - return nil, errors.New("Too many unsuccessful sign-in attempts have been made using an incorrect username or password, Try again later.") + if model.IsIPBlocked(ip) { + return nil, errors.New("Access denied: IP is blacklisted") + } + maxRetries := setting.GetInt(conf.AuthLoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.AuthLoginLockDuration, int(model.DefaultLockDuration/time.Minute))) * time.Minute + skipLimit := model.ShouldSkipAuthRateLimit(ip) + count, _ := model.LoginCache.Get(ip) + if !skipLimit && model.IsAuthRateLimitExceeded(count, maxRetries) { + model.LoginCache.Expire(ip, lockDuration) + return nil, errors.New(model.TooManyAttempts) } pass := string(password) userObj, err := op.GetUserByName(conn.User()) @@ -110,11 +116,15 @@ func (d *SftpDriver) PasswordAuth(conn ssh.ConnMetadata, password []byte) (*ssh. userObj, err = tryLdapLoginAndRegister(conn.User(), pass) } if err != nil { - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return nil, err } if userObj.Disabled || !userObj.CanFTPAccess() { - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } return nil, errors.New("user is not allowed to access via SFTP") } model.LoginCache.Del(ip) diff --git a/server/webdav.go b/server/webdav.go index 74523b0b30..91a5c21306 100644 --- a/server/webdav.go +++ b/server/webdav.go @@ -5,6 +5,7 @@ import ( "net/http" "path" "strings" + "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -48,11 +49,25 @@ func ServeWebDAV(c *gin.Context) { } func WebDAVAuth(c *gin.Context) { - // check count of login + // check blacklist ip := c.ClientIP() guest, _ := op.GetGuest() - count, cok := model.LoginCache.Get(ip) - if cok && count >= model.DefaultMaxAuthRetries { + if model.IsIPBlocked(ip) { + if c.Request.Method == "OPTIONS" { + common.GinAppendValues(c, conf.UserKey, guest) + c.Next() + return + } + c.Status(http.StatusForbidden) + c.Abort() + return + } + // rate limiting + maxRetries := setting.GetInt(conf.AuthLoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.AuthLoginLockDuration, int(model.DefaultLockDuration/time.Minute))) * time.Minute + skipLimit := model.ShouldSkipAuthRateLimit(ip) + count, _ := model.LoginCache.Get(ip) + if !skipLimit && model.IsAuthRateLimitExceeded(count, maxRetries) { if c.Request.Method == "OPTIONS" { common.GinAppendValues(c, conf.UserKey, guest) c.Next() @@ -60,7 +75,7 @@ func WebDAVAuth(c *gin.Context) { } c.Status(http.StatusTooManyRequests) c.Abort() - model.LoginCache.Expire(ip, model.DefaultLockDuration) + model.LoginCache.Expire(ip, lockDuration) return } username, password, ok := c.Request.BasicAuth() @@ -100,7 +115,9 @@ func WebDAVAuth(c *gin.Context) { c.Next() return } - model.LoginCache.Set(ip, count+1) + if !skipLimit { + model.LoginCache.Set(ip, count+1) + } c.Status(http.StatusUnauthorized) c.Abort() return