diff --git a/core/app/api/v2/auth.go b/core/app/api/v2/auth.go index 717beac96..c1b243d79 100644 --- a/core/app/api/v2/auth.go +++ b/core/app/api/v2/auth.go @@ -2,6 +2,7 @@ package v2 import ( "encoding/base64" + "github.com/1Panel-dev/1Panel/core/utils/common" "os" "path" @@ -29,12 +30,15 @@ func (b *BaseApi) Login(c *gin.Context) { return } - if !req.IgnoreCaptcha { + ip := common.GetRealClientIP(c) + needCaptcha := global.IPTracker.NeedCaptcha(ip) + if needCaptcha { if errMsg := captcha.VerifyCode(req.CaptchaID, req.Captcha); errMsg != "" { helper.BadAuth(c, errMsg, nil) return } } + entranceItem := c.Request.Header.Get("EntranceCode") var entrance []byte if len(entranceItem) != 0 { @@ -50,13 +54,18 @@ func (b *BaseApi) Login(c *gin.Context) { user, msgKey, err := authService.Login(c, req, string(entrance)) go saveLoginLogs(c, err) if msgKey == "ErrAuth" || msgKey == "ErrEntrance" { + if msgKey == "ErrAuth" { + global.IPTracker.SetNeedCaptcha(ip) + } helper.BadAuth(c, msgKey, err) return } if err != nil { + global.IPTracker.SetNeedCaptcha(ip) helper.InternalServer(c, err) return } + global.IPTracker.Clear(ip) helper.SuccessWithData(c, user) } @@ -142,15 +151,18 @@ func (b *BaseApi) GetLoginSetting(c *gin.Context) { helper.InternalServer(c, err) return } + ip := common.GetRealClientIP(c) + needCaptcha := global.IPTracker.NeedCaptcha(ip) res := &dto.LoginSetting{ - IsDemo: global.CONF.Base.IsDemo, - IsIntl: global.CONF.Base.IsIntl, - IsFxplay: global.CONF.Base.IsFxplay, - IsOffLine: global.CONF.Base.IsOffLine, - Language: settingInfo.Language, - MenuTabs: settingInfo.MenuTabs, - PanelName: settingInfo.PanelName, - Theme: settingInfo.Theme, + IsDemo: global.CONF.Base.IsDemo, + IsIntl: global.CONF.Base.IsIntl, + IsFxplay: global.CONF.Base.IsFxplay, + IsOffLine: global.CONF.Base.IsOffLine, + Language: settingInfo.Language, + MenuTabs: settingInfo.MenuTabs, + PanelName: settingInfo.PanelName, + Theme: settingInfo.Theme, + NeedCaptcha: needCaptcha, } helper.SuccessWithData(c, res) } diff --git a/core/app/dto/auth.go b/core/app/dto/auth.go index 79ae08561..3ee2d56e2 100644 --- a/core/app/dto/auth.go +++ b/core/app/dto/auth.go @@ -23,12 +23,11 @@ type MfaCredential struct { } type Login struct { - Name string `json:"name" validate:"required"` - Password string `json:"password" validate:"required"` - IgnoreCaptcha bool `json:"ignoreCaptcha"` - Captcha string `json:"captcha"` - CaptchaID string `json:"captchaID"` - Language string `json:"language" validate:"required,oneof=zh en 'zh-Hant' ko ja ru ms 'pt-BR' tr 'es-ES'"` + Name string `json:"name" validate:"required"` + Password string `json:"password" validate:"required"` + Captcha string `json:"captcha"` + CaptchaID string `json:"captchaID"` + Language string `json:"language" validate:"required,oneof=zh en 'zh-Hant' ko ja ru ms 'pt-BR' tr 'es-ES'"` } type MFALogin struct { diff --git a/core/app/dto/setting.go b/core/app/dto/setting.go index c49cbca2c..6bc242f8a 100644 --- a/core/app/dto/setting.go +++ b/core/app/dto/setting.go @@ -241,12 +241,13 @@ type AppstoreConfig struct { } type LoginSetting struct { - IsDemo bool `json:"isDemo"` - IsIntl bool `json:"isIntl"` - IsOffLine bool `json:"isOffLine"` - IsFxplay bool `json:"isFxplay"` - Language string `json:"language"` - MenuTabs string `json:"menuTabs"` - PanelName string `json:"panelName"` - Theme string `json:"theme"` + IsDemo bool `json:"isDemo"` + IsIntl bool `json:"isIntl"` + IsOffLine bool `json:"isOffLine"` + IsFxplay bool `json:"isFxplay"` + Language string `json:"language"` + MenuTabs string `json:"menuTabs"` + PanelName string `json:"panelName"` + Theme string `json:"theme"` + NeedCaptcha bool `json:"needCaptcha"` } diff --git a/core/app/service/auth.go b/core/app/service/auth.go index 1ecf5c651..1538503f2 100644 --- a/core/app/service/auth.go +++ b/core/app/service/auth.go @@ -3,8 +3,6 @@ package service import ( "crypto/hmac" "encoding/base64" - "strconv" - "github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/repo" "github.com/1Panel-dev/1Panel/core/buserr" @@ -13,6 +11,7 @@ import ( "github.com/1Panel-dev/1Panel/core/utils/encrypt" "github.com/1Panel-dev/1Panel/core/utils/mfa" "github.com/gin-gonic/gin" + "strconv" ) type AuthService struct{} diff --git a/core/global/global.go b/core/global/global.go index 1f861546b..afb5e97a8 100644 --- a/core/global/global.go +++ b/core/global/global.go @@ -1,6 +1,7 @@ package global import ( + "github.com/1Panel-dev/1Panel/core/init/auth" "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/go-playground/validator/v10" "github.com/nicksnyder/go-i18n/v2/i18n" @@ -28,6 +29,8 @@ var ( Cron *cron.Cron ScriptSyncJobID cron.EntryID + + IPTracker *auth.IPTracker ) type DBOption func(*gorm.DB) *gorm.DB diff --git a/core/init/auth/ip_tracker.go b/core/init/auth/ip_tracker.go new file mode 100644 index 000000000..031431d44 --- /dev/null +++ b/core/init/auth/ip_tracker.go @@ -0,0 +1,99 @@ +package auth + +import ( + "sync" + "time" +) + +const ( + MaxIPCount = 100 + ExpireDuration = 30 * time.Minute +) + +type IPRecord struct { + NeedCaptcha bool + LastUpdate time.Time +} + +type IPTracker struct { + records map[string]*IPRecord + ipOrder []string + mu sync.RWMutex +} + +func NewIPTracker() *IPTracker { + return &IPTracker{ + records: make(map[string]*IPRecord), + ipOrder: make([]string, 0), + } +} + +func (t *IPTracker) NeedCaptcha(ip string) bool { + t.mu.Lock() + defer t.mu.Unlock() + + record, exists := t.records[ip] + if !exists { + return false + } + + if time.Since(record.LastUpdate) > ExpireDuration { + t.removeIPUnsafe(ip) + return false + } + + return record.NeedCaptcha +} + +func (t *IPTracker) SetNeedCaptcha(ip string) { + t.mu.Lock() + defer t.mu.Unlock() + + if record, exists := t.records[ip]; exists { + if time.Since(record.LastUpdate) > ExpireDuration { + t.removeIPUnsafe(ip) + } else { + record.NeedCaptcha = true + record.LastUpdate = time.Now() + return + } + } + + if len(t.records) >= MaxIPCount { + t.removeOldestUnsafe() + } + + t.records[ip] = &IPRecord{ + NeedCaptcha: true, + LastUpdate: time.Now(), + } + t.ipOrder = append(t.ipOrder, ip) +} + +func (t *IPTracker) Clear(ip string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.removeIPUnsafe(ip) +} + +func (t *IPTracker) removeIPUnsafe(ip string) { + delete(t.records, ip) + + for i, storedIP := range t.ipOrder { + if storedIP == ip { + t.ipOrder = append(t.ipOrder[:i], t.ipOrder[i+1:]...) + break + } + } +} + +func (t *IPTracker) removeOldestUnsafe() { + if len(t.ipOrder) == 0 { + return + } + + oldestIP := t.ipOrder[0] + delete(t.records, oldestIP) + t.ipOrder = t.ipOrder[1:] +} diff --git a/core/server/server.go b/core/server/server.go index bd803d1ce..872aa42d3 100644 --- a/core/server/server.go +++ b/core/server/server.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/gob" "fmt" + "github.com/1Panel-dev/1Panel/core/init/auth" "net" "net/http" "os" @@ -54,6 +55,8 @@ func Start() { gin.SetMode(gin.ReleaseMode) } + global.IPTracker = auth.NewIPTracker() + tcpItem := "tcp4" if global.CONF.Conn.Ipv6 == constant.StatusEnable { tcpItem = "tcp" diff --git a/frontend/src/api/interface/auth.ts b/frontend/src/api/interface/auth.ts index 9aa8d4a0b..39d008cd6 100644 --- a/frontend/src/api/interface/auth.ts +++ b/frontend/src/api/interface/auth.ts @@ -2,7 +2,6 @@ export namespace Login { export interface ReqLoginForm { name: string; password: string; - ignoreCaptcha: boolean; captcha: string; captchaID: string; authMethod: string; @@ -36,5 +35,6 @@ export namespace Login { panelName: string; theme: string; isOffLine: boolean; + needCaptcha: boolean; } } diff --git a/frontend/src/views/login/components/login-form.vue b/frontend/src/views/login/components/login-form.vue index f37ea7b35..7bc3ba69b 100644 --- a/frontend/src/views/login/components/login-form.vue +++ b/frontend/src/views/login/components/login-form.vue @@ -220,7 +220,6 @@ const loginFormRef = ref(); const loginForm = reactive({ name: '', password: '', - ignoreCaptcha: true, captcha: '', captchaID: '', authMethod: 'session', @@ -318,7 +317,6 @@ const login = (formEl: FormInstance | undefined) => { let requestLoginForm = { name: loginForm.name, password: encryptPassword(loginForm.password), - ignoreCaptcha: globalStore.ignoreCaptcha, captcha: loginForm.captcha, captchaID: captcha.captchaID, authMethod: 'session', @@ -418,6 +416,7 @@ const getSetting = async () => { isFxplay.value = res.data.isFxplay; globalStore.isFxplay = isFxplay.value; globalStore.isOffLine = res.data.isOffLine; + globalStore.ignoreCaptcha = !res.data.needCaptcha; document.title = res.data.panelName; i18n.warnHtmlMessage = false;