diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 4526aa5234..8e5c3b363e 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -249,6 +249,12 @@ func InitialSettings() []model.SettingItem { {Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, {Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, {Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, + + // login lock settings + {Key: conf.LoginLockDuration, Value: "5", Type: conf.TypeNumber, Group: model.SITE, Flag: model.PRIVATE}, + {Key: conf.LoginMaxRetries, Value: "5", Type: conf.TypeNumber, Group: model.SITE, Flag: model.PRIVATE}, + {Key: conf.LoginIPWhitelist, Value: "", Type: conf.TypeText, Group: model.SITE, Flag: model.PRIVATE}, + {Key: conf.LoginIPBlacklist, Value: "", Type: conf.TypeText, Group: model.SITE, Flag: model.PRIVATE}, } additionalSettingItems := tool.Tools.Items() // 固定顺序 diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849cb..814cf6059d 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -161,6 +161,12 @@ const ( StreamMaxClientUploadSpeed = "max_client_upload_speed" StreamMaxServerDownloadSpeed = "max_server_download_speed" StreamMaxServerUploadSpeed = "max_server_upload_speed" + + // login lock + LoginLockDuration = "login_lock_duration" + LoginMaxRetries = "login_max_retries" + LoginIPWhitelist = "login_ip_whitelist" + LoginIPBlacklist = "login_ip_blacklist" ) const ( diff --git a/internal/model/user.go b/internal/model/user.go index 2dacd752eb..15613d6818 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -4,6 +4,8 @@ import ( "encoding/binary" "encoding/json" "fmt" + "net" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -32,10 +34,91 @@ const ( var LoginCache = cache.NewMemCache[int]() -var ( - DefaultLockDuration = time.Minute * 5 - DefaultMaxAuthRetries = 5 -) +const DefaultLockDurationMinutes = 5 +const DefaultMaxAuthRetries = 5 + +// CheckLoginLocked checks if the IP has exceeded the max retry limit. +// If locked, it refreshes the lock expiry and returns true. +// maxRetries <= 0 means the lock feature is disabled. +func CheckLoginLocked(ip string, maxRetries int, lockDuration time.Duration) bool { + if maxRetries <= 0 { + return false + } + count, ok := LoginCache.Get(ip) + if ok && count >= maxRetries { + LoginCache.Expire(ip, lockDuration) + return true + } + return false +} + +// RecordLoginAttempt records a failed login attempt for the IP and returns the new count. +func RecordLoginAttempt(ip string) int { + count, _ := LoginCache.Get(ip) + LoginCache.Set(ip, count+1) + return count + 1 +} + +// ClearLoginAttempts clears the login attempt counter for the given IP. +func ClearLoginAttempts(ip string) { + LoginCache.Del(ip) +} + +// IsIPWhitelisted returns true if the given IP matches any entry in the whitelist. +// The whitelist supports single IPs and CIDR notation (e.g., 192.168.1.0/24). +func IsIPWhitelisted(ip string, whitelist []string) bool { + return matchIPList(ip, whitelist) +} + +// IsIPBlacklisted returns true if the given IP matches any entry in the blacklist. +// The blacklist supports single IPs and CIDR notation (e.g., 10.0.0.0/8). +func IsIPBlacklisted(ip string, blacklist []string) bool { + return matchIPList(ip, blacklist) +} + +// matchIPList checks if the given IP matches any entry in the list. +func matchIPList(ip string, list []string) bool { + if len(list) == 0 { + return false + } + parsedIP := net.ParseIP(strings.TrimSpace(ip)) + if parsedIP == nil { + return false + } + for _, entry := range list { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.Contains(entry, "/") { + _, cidr, err := net.ParseCIDR(entry) + if err == nil && cidr.Contains(parsedIP) { + return true + } + } else { + if entry == ip { + return true + } + } + } + return false +} + +// ParseIPList parses a newline-separated string into a slice of IP/CIDR entries. +func ParseIPList(raw string) []string { + if raw == "" { + return nil + } + lines := strings.Split(raw, "\n") + var result []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + return result +} type User struct { ID uint `json:"id" gorm:"primaryKey"` // unique key diff --git a/server/ftp.go b/server/ftp.go index 8999c1f544..9f8f1b03d9 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,11 +115,20 @@ 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.") + maxRetries := setting.GetInt(conf.LoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.LoginLockDuration, model.DefaultLockDurationMinutes)) * time.Minute + + // check IP blacklist + if model.IsIPBlacklisted(ip, model.ParseIPList(setting.GetStr(conf.LoginIPBlacklist))) { + return nil, errors.New(model.TooManyAttempts) + } + + // check IP whitelist (bypasses lock) + whitelist := model.ParseIPList(setting.GetStr(conf.LoginIPWhitelist)) + if !model.IsIPWhitelisted(ip, whitelist) && model.CheckLoginLocked(ip, maxRetries, lockDuration) { + return nil, errors.New(model.TooManyAttempts) } + var userObj *model.User var err error if user == "anonymous" || user == "guest" { @@ -137,15 +147,19 @@ 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 !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return nil, err } } if userObj.Disabled || !userObj.CanFTPAccess() { - model.LoginCache.Set(ip, count+1) + if !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return nil, errors.New("user is not allowed to access via FTP") } - model.LoginCache.Del(ip) + model.ClearLoginAttempts(ip) ctx := context.Background() ctx = context.WithValue(ctx, conf.UserKey, userObj) diff --git a/server/handles/auth.go b/server/handles/auth.go index 7800690918..943eaab2ab 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,42 @@ func LoginHash(c *gin.Context) { } func loginHash(c *gin.Context, req *LoginReq) { - // check count of login + // check login lock ip := c.ClientIP() - count, ok := model.LoginCache.Get(ip) - if ok && count >= model.DefaultMaxAuthRetries { + maxRetries := setting.GetInt(conf.LoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.LoginLockDuration, model.DefaultLockDurationMinutes)) * time.Minute + + // check IP blacklist + blacklist := model.ParseIPList(setting.GetStr(conf.LoginIPBlacklist)) + if model.IsIPBlacklisted(ip, blacklist) { + common.ErrorStrResp(c, model.TooManyAttempts, 429) + return + } + + // check IP whitelist (bypasses lock) + whitelist := model.ParseIPList(setting.GetStr(conf.LoginIPWhitelist)) + if model.IsIPWhitelisted(ip, whitelist) { + // whitelisted IP, skip lock check + } else if model.CheckLoginLocked(ip, maxRetries, lockDuration) { common.ErrorStrResp(c, model.TooManyAttempts, 429) - model.LoginCache.Expire(ip, model.DefaultLockDuration) 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 !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } 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 !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return } // check 2FA @@ -67,7 +86,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 !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return } } @@ -78,7 +99,7 @@ func loginHash(c *gin.Context, req *LoginReq) { return } common.SuccessResp(c, gin.H{"token": token}) - model.LoginCache.Del(ip) + model.ClearLoginAttempts(ip) } type UserResp struct { diff --git a/server/handles/ldap_login.go b/server/handles/ldap_login.go index ba44615f7d..6e680fba56 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,31 @@ func LoginLdap(c *gin.Context) { return } - // check count of login + // check login lock 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) + maxRetries := setting.GetInt(conf.LoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.LoginLockDuration, model.DefaultLockDurationMinutes)) * time.Minute + + // check IP blacklist + blacklist := model.ParseIPList(setting.GetStr(conf.LoginIPBlacklist)) + if model.IsIPBlacklisted(ip, blacklist) { + common.ErrorStrResp(c, model.TooManyAttempts, 429) + return + } + + // check IP whitelist (bypasses lock) + whitelist := model.ParseIPList(setting.GetStr(conf.LoginIPWhitelist)) + if !model.IsIPWhitelisted(ip, whitelist) && model.CheckLoginLocked(ip, maxRetries, lockDuration) { + common.ErrorStrResp(c, model.TooManyAttempts, 429) return } err = common.HandleLdapLogin(req.Username, req.Password) if err != nil { if errors.Is(err, common.ErrFailedLdapAuth) { - model.LoginCache.Set(ip, count+1) + if !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } common.ErrorResp(c, err, 400) } else { common.ErrorResp(c, err, 500) @@ -51,7 +65,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 !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return } } @@ -63,5 +79,5 @@ func LoginLdap(c *gin.Context) { return } common.SuccessResp(c, gin.H{"token": token}) - model.LoginCache.Del(ip) + model.ClearLoginAttempts(ip) } diff --git a/server/sftp.go b/server/sftp.go index 37dc9870db..6141385d47 100644 --- a/server/sftp.go +++ b/server/sftp.go @@ -94,11 +94,20 @@ 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.") + maxRetries := setting.GetInt(conf.LoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.LoginLockDuration, model.DefaultLockDurationMinutes)) * time.Minute + + // check IP blacklist + if model.IsIPBlacklisted(ip, model.ParseIPList(setting.GetStr(conf.LoginIPBlacklist))) { + return nil, errors.New(model.TooManyAttempts) + } + + // check IP whitelist (bypasses lock) + whitelist := model.ParseIPList(setting.GetStr(conf.LoginIPWhitelist)) + if !model.IsIPWhitelisted(ip, whitelist) && model.CheckLoginLocked(ip, maxRetries, lockDuration) { + return nil, errors.New(model.TooManyAttempts) } + pass := string(password) userObj, err := op.GetUserByName(conn.User()) if err == nil { @@ -110,14 +119,18 @@ 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 !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return nil, err } if userObj.Disabled || !userObj.CanFTPAccess() { - model.LoginCache.Set(ip, count+1) + if !model.IsIPWhitelisted(ip, whitelist) { + model.RecordLoginAttempt(ip) + } return nil, errors.New("user is not allowed to access via SFTP") } - model.LoginCache.Del(ip) + model.ClearLoginAttempts(ip) return nil, nil } diff --git a/server/webdav.go b/server/webdav.go index 74523b0b30..06d18394c4 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,24 @@ func ServeWebDAV(c *gin.Context) { } func WebDAVAuth(c *gin.Context) { - // check count of login + // check login lock ip := c.ClientIP() guest, _ := op.GetGuest() - count, cok := model.LoginCache.Get(ip) - if cok && count >= model.DefaultMaxAuthRetries { + maxRetries := setting.GetInt(conf.LoginMaxRetries, model.DefaultMaxAuthRetries) + lockDuration := time.Duration(setting.GetInt(conf.LoginLockDuration, model.DefaultLockDurationMinutes)) * time.Minute + + // check IP blacklist + if model.IsIPBlacklisted(ip, model.ParseIPList(setting.GetStr(conf.LoginIPBlacklist))) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } + + // check IP whitelist (bypasses lock) + whitelist := model.ParseIPList(setting.GetStr(conf.LoginIPWhitelist)) + isWhitelisted := model.IsIPWhitelisted(ip, whitelist) + + if !isWhitelisted && model.CheckLoginLocked(ip, maxRetries, lockDuration) { if c.Request.Method == "OPTIONS" { common.GinAppendValues(c, conf.UserKey, guest) c.Next() @@ -60,7 +74,6 @@ func WebDAVAuth(c *gin.Context) { } c.Status(http.StatusTooManyRequests) c.Abort() - model.LoginCache.Expire(ip, model.DefaultLockDuration) return } username, password, ok := c.Request.BasicAuth() @@ -100,13 +113,15 @@ func WebDAVAuth(c *gin.Context) { c.Next() return } - model.LoginCache.Set(ip, count+1) + if !isWhitelisted { + model.RecordLoginAttempt(ip) + } c.Status(http.StatusUnauthorized) c.Abort() return } // at least auth is successful till here - model.LoginCache.Del(ip) + model.ClearLoginAttempts(ip) if user.Disabled || !user.CanWebdavRead() { if c.Request.Method == "OPTIONS" { common.GinAppendValues(c, conf.UserKey, guest)