Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drivers/alias/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
38 changes: 38 additions & 0 deletions drivers/alias/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions internal/bootstrap/data/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"strconv"
"time"

"github.com/OpenListTeam/OpenList/v4/cmd/flags"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
Expand Down Expand Up @@ -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},
Expand Down
16 changes: 10 additions & 6 deletions internal/conf/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions internal/conf/var.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package conf

import (
"net"
"net/url"
"regexp"
"sync"
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions internal/model/auth_limit.go
Original file line number Diff line number Diff line change
@@ -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)
}
38 changes: 38 additions & 0 deletions internal/op/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package op

import (
"context"
"net"
"regexp"
"strings"

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
23 changes: 17 additions & 6 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,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
Expand All @@ -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)
Expand Down
30 changes: 23 additions & 7 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,47 @@ 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
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 !skipLimit {
model.LoginCache.Set(ip, count+1)
}
return
}
}
Expand Down
28 changes: 21 additions & 7 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,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)
Expand All @@ -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
}
}
Expand Down
Loading