diff --git a/Makefile b/Makefile index 78b8fca34..311ab3de2 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,4 @@ build_backend_on_darwin: build_all: build_frontend build_backend_on_linux -build_on_local: clean_assets build_frontend build_backend_on_darwin upx_bin +build_on_local: clean_assets build_frontend build_backend_on_darwin diff --git a/backend/app/api/v1/auth.go b/backend/app/api/v1/auth.go index e10f4f3a5..7898948b8 100644 --- a/backend/app/api/v1/auth.go +++ b/backend/app/api/v1/auth.go @@ -2,12 +2,14 @@ package v1 import ( "encoding/base64" + "github.com/1Panel-dev/1Panel/backend/app/api/v1/helper" "github.com/1Panel-dev/1Panel/backend/app/dto" "github.com/1Panel-dev/1Panel/backend/app/model" "github.com/1Panel-dev/1Panel/backend/constant" "github.com/1Panel-dev/1Panel/backend/global" "github.com/1Panel-dev/1Panel/backend/utils/captcha" + "github.com/1Panel-dev/1Panel/backend/utils/common" "github.com/gin-gonic/gin" ) @@ -26,7 +28,9 @@ func (b *BaseApi) Login(c *gin.Context) { return } - if req.AuthMethod != "jwt" && !req.IgnoreCaptcha { + ip := common.GetRealClientIP(c) + needCaptcha := global.IPTracker.NeedCaptcha(ip) + if needCaptcha { if err := captcha.VerifyCode(req.CaptchaID, req.Captcha); err != nil { helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) return @@ -48,9 +52,11 @@ func (b *BaseApi) Login(c *gin.Context) { user, err := authService.Login(c, req, string(entrance)) go saveLoginLogs(c, err) if err != nil { + global.IPTracker.SetNeedCaptcha(ip) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) return } + global.IPTracker.Clear(ip) helper.SuccessWithData(c, user) } @@ -134,16 +140,21 @@ func (b *BaseApi) CheckIsIntl(c *gin.Context) { } // @Tags Auth -// @Summary Load System Language -// @Success 200 {string} language -// @Router /auth/language [get] -func (b *BaseApi) GetLanguage(c *gin.Context) { +// @Summary Load System Setting for login +// @Success 200 {object} dto.LoginSetting +// @Router /auth/setting [get] +func (b *BaseApi) GetAuthSetting(c *gin.Context) { settingInfo, err := settingService.GetSettingInfo() if err != nil { helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) return } - helper.SuccessWithData(c, settingInfo.Language) + ip := common.GetRealClientIP(c) + needCaptcha := global.IPTracker.NeedCaptcha(ip) + helper.SuccessWithData(c, dto.LoginSetting{ + NeedCaptcha: needCaptcha, + Language: settingInfo.Language, + }) } func saveLoginLogs(c *gin.Context, err error) { diff --git a/backend/app/dto/auth.go b/backend/app/dto/auth.go index 2679193b0..bf70883db 100644 --- a/backend/app/dto/auth.go +++ b/backend/app/dto/auth.go @@ -23,13 +23,12 @@ 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"` - AuthMethod string `json:"authMethod" validate:"required,oneof=jwt session"` - Language string `json:"language" validate:"required,oneof=zh en tw ja ko ru ms 'pt-BR'"` + Name string `json:"name" validate:"required"` + Password string `json:"password" validate:"required"` + Captcha string `json:"captcha"` + CaptchaID string `json:"captchaID"` + AuthMethod string `json:"authMethod" validate:"required,oneof=jwt session"` + Language string `json:"language" validate:"required,oneof=zh en tw ja ko ru ms 'pt-BR'"` } type MFALogin struct { @@ -38,3 +37,8 @@ type MFALogin struct { Code string `json:"code" validate:"required"` AuthMethod string `json:"authMethod"` } + +type LoginSetting struct { + NeedCaptcha bool `json:"needCaptcha"` + Language string `json:"language"` +} diff --git a/backend/global/global.go b/backend/global/global.go index 6801170d5..e215d7713 100644 --- a/backend/global/global.go +++ b/backend/global/global.go @@ -2,6 +2,7 @@ package global import ( "github.com/1Panel-dev/1Panel/backend/configs" + "github.com/1Panel-dev/1Panel/backend/init/auth" "github.com/1Panel-dev/1Panel/backend/init/cache/badger_db" "github.com/1Panel-dev/1Panel/backend/init/session/psession" "github.com/dgraph-io/badger/v4" @@ -28,6 +29,8 @@ var ( MonitorCronID cron.EntryID OneDriveCronID cron.EntryID + IPTracker *auth.IPTracker + I18n *i18n.Localizer I18nForCmd *i18n.Localizer ) diff --git a/backend/init/auth/auth.go b/backend/init/auth/auth.go new file mode 100644 index 000000000..031431d44 --- /dev/null +++ b/backend/init/auth/auth.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/backend/router/ro_base.go b/backend/router/ro_base.go index f0f7182d9..c59b87c36 100644 --- a/backend/router/ro_base.go +++ b/backend/router/ro_base.go @@ -16,7 +16,7 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) { baseRouter.POST("/login", baseApi.Login) baseRouter.POST("/logout", baseApi.LogOut) baseRouter.GET("/demo", baseApi.CheckIsDemo) - baseRouter.GET("/language", baseApi.GetLanguage) + baseRouter.GET("/setting", baseApi.GetAuthSetting) baseRouter.GET("/intl", baseApi.CheckIsIntl) } } diff --git a/backend/server/server.go b/backend/server/server.go index f7ff2919d..912440b60 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -13,6 +13,7 @@ import ( "github.com/1Panel-dev/1Panel/backend/i18n" "github.com/1Panel-dev/1Panel/backend/init/app" + "github.com/1Panel-dev/1Panel/backend/init/auth" "github.com/1Panel-dev/1Panel/backend/init/business" "github.com/1Panel-dev/1Panel/backend/init/lang" @@ -52,6 +53,7 @@ func Start() { business.Init() rootRouter := router.Routers() + global.IPTracker = auth.NewIPTracker() tcpItem := "tcp4" if global.CONF.System.Ipv6 == "enable" { diff --git a/backend/utils/captcha/captcha.go b/backend/utils/captcha/captcha.go index c2f679256..61c4f07f6 100644 --- a/backend/utils/captcha/captcha.go +++ b/backend/utils/captcha/captcha.go @@ -11,13 +11,13 @@ import ( var store = base64Captcha.DefaultMemStore func VerifyCode(codeID string, code string) error { - if codeID == "" { - return constant.ErrCaptchaCode - } vv := store.Get(codeID, true) vv = strings.TrimSpace(vv) code = strings.TrimSpace(code) + if codeID == "" || code == "" { + return constant.ErrCaptchaCode + } if strings.EqualFold(vv, code) { return nil } diff --git a/frontend/src/api/interface/auth.ts b/frontend/src/api/interface/auth.ts index ec1a7f777..184fb5c49 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; @@ -26,4 +25,8 @@ export namespace Login { export interface ResAuthButtons { [propName: string]: any; } + export interface LoginSetting { + language: string; + needCaptcha: boolean; + } } diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts index f4b0f0692..e447bc626 100644 --- a/frontend/src/api/modules/auth.ts +++ b/frontend/src/api/modules/auth.ts @@ -21,8 +21,8 @@ export const checkIsDemo = () => { return http.get('/auth/demo'); }; -export const getLanguage = () => { - return http.get(`/auth/language`); +export const getAuthSetting = () => { + return http.get(`/auth/setting`); }; export const checkIsIntl = () => { diff --git a/frontend/src/views/login/components/login-form.vue b/frontend/src/views/login/components/login-form.vue index 630e4a967..197789aa2 100644 --- a/frontend/src/views/login/components/login-form.vue +++ b/frontend/src/views/login/components/login-form.vue @@ -176,7 +176,7 @@ import { ref, reactive, onMounted, computed, nextTick } from 'vue'; import { useRouter } from 'vue-router'; import type { ElForm } from 'element-plus'; -import { loginApi, getCaptcha, mfaLoginApi, checkIsDemo, getLanguage, checkIsIntl } from '@/api/modules/auth'; +import { loginApi, getCaptcha, mfaLoginApi, checkIsDemo, getAuthSetting, checkIsIntl } from '@/api/modules/auth'; import { GlobalStore, MenuStore, TabsStore } from '@/store'; import { MsgSuccess } from '@/utils/message'; import { useI18n } from 'vue-i18n'; @@ -318,7 +318,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', @@ -400,11 +399,12 @@ const checkIsSystemDemo = async () => { isDemo.value = res.data; }; -const loadLanguage = async () => { +const loadLoginSetting = async () => { try { - const res = await getLanguage(); - loginForm.language = res.data; - handleCommand(res.data); + const res = await getAuthSetting(); + loginForm.language = res.data.language; + globalStore.ignoreCaptcha = !res.data.needCaptcha; + handleCommand(loginForm.language); } catch (error) {} }; @@ -426,7 +426,7 @@ onMounted(() => { globalStore.isOnRestart = false; checkIsSystemIntl(); loginVerify(); - loadLanguage(); + loadLoginSetting(); document.title = globalStore.themeConfig.panelName; loginForm.agreeLicense = globalStore.agreeLicense; checkIsSystemDemo();