Skip to content
6 changes: 6 additions & 0 deletions internal/bootstrap/data/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
// 固定顺序
Expand Down
6 changes: 6 additions & 0 deletions internal/conf/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
91 changes: 87 additions & 4 deletions internal/model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/binary"
"encoding/json"
"fmt"
"net"
"strings"
"time"

"github.com/OpenListTeam/OpenList/v4/internal/errs"
Expand Down Expand Up @@ -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
Expand Down
28 changes: 21 additions & 7 deletions server/ftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
Expand Down Expand Up @@ -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" {
Expand All @@ -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)
Expand Down
37 changes: 29 additions & 8 deletions server/handles/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -41,33 +43,52 @@ 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
if user.OtpSecret != "" {
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
}
}
Expand All @@ -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 {
Expand Down
32 changes: 24 additions & 8 deletions server/handles/ldap_login.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand All @@ -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
}
}
Expand All @@ -63,5 +79,5 @@ func LoginLdap(c *gin.Context) {
return
}
common.SuccessResp(c, gin.H{"token": token})
model.LoginCache.Del(ip)
model.ClearLoginAttempts(ip)
}
27 changes: 20 additions & 7 deletions server/sftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
Loading