diff --git a/go.mod b/go.mod index 04ef999d..cd50c7bf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/beevik/ntp v1.5.0 github.com/coder/websocket v1.8.14 github.com/creack/pty v1.1.24 + github.com/dchest/captcha v1.1.0 github.com/expr-lang/expr v1.17.7 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/httplog/v3 v3.3.0 diff --git a/go.sum b/go.sum index 2b43e86e..38008f95 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ= +github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= diff --git a/internal/data/setting.go b/internal/data/setting.go index 4e6f2fc0..4893cd58 100644 --- a/internal/data/setting.go +++ b/internal/data/setting.go @@ -248,27 +248,29 @@ func (r *settingRepo) GetPanel() (*request.SettingPanel, error) { } return &request.SettingPanel{ - Name: name, - Channel: channel, - Locale: r.conf.App.Locale, - Entrance: r.conf.HTTP.Entrance, - OfflineMode: offlineMode, - AutoUpdate: autoUpdate, - Lifetime: r.conf.Session.Lifetime, - IPHeader: r.conf.HTTP.IPHeader, - BindDomain: r.conf.HTTP.BindDomain, - BindIP: r.conf.HTTP.BindIP, - BindUA: r.conf.HTTP.BindUA, - WebsitePath: websitePath, - BackupPath: backupPath, - HiddenMenu: hiddenMenu, - CustomLogo: customLogo, - Port: r.conf.HTTP.Port, - HTTPS: r.conf.HTTP.TLS, - ACME: r.conf.HTTP.ACME, - PublicIP: publicIP, - Cert: crt, - Key: key, + Name: name, + Channel: channel, + Locale: r.conf.App.Locale, + Entrance: r.conf.HTTP.Entrance, + EntranceError: r.conf.HTTP.EntranceError, + LoginCaptcha: r.conf.HTTP.LoginCaptcha, + OfflineMode: offlineMode, + AutoUpdate: autoUpdate, + Lifetime: r.conf.Session.Lifetime, + IPHeader: r.conf.HTTP.IPHeader, + BindDomain: r.conf.HTTP.BindDomain, + BindIP: r.conf.HTTP.BindIP, + BindUA: r.conf.HTTP.BindUA, + WebsitePath: websitePath, + BackupPath: backupPath, + HiddenMenu: hiddenMenu, + CustomLogo: customLogo, + Port: r.conf.HTTP.Port, + HTTPS: r.conf.HTTP.TLS, + ACME: r.conf.HTTP.ACME, + PublicIP: publicIP, + Cert: crt, + Key: key, }, nil } @@ -354,6 +356,8 @@ func (r *settingRepo) UpdatePanel(req *request.SettingPanel) (bool, error) { conf.App.Locale = req.Locale conf.HTTP.Port = req.Port conf.HTTP.Entrance = req.Entrance + conf.HTTP.EntranceError = req.EntranceError + conf.HTTP.LoginCaptcha = req.LoginCaptcha conf.HTTP.TLS = req.HTTPS conf.HTTP.ACME = req.ACME conf.HTTP.IPHeader = req.IPHeader diff --git a/internal/http/middleware/entrance.go b/internal/http/middleware/entrance.go index 76eb04c3..297ea7a1 100644 --- a/internal/http/middleware/entrance.go +++ b/internal/http/middleware/entrance.go @@ -12,6 +12,7 @@ import ( "github.com/libtnb/sessions" "github.com/acepanel/panel/pkg/config" + "github.com/acepanel/panel/pkg/embed" "github.com/acepanel/panel/pkg/punycode" ) @@ -41,7 +42,7 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager) } } if len(conf.HTTP.BindDomain) > 0 && !slices.Contains(conf.HTTP.BindDomain, host) { - Abort(w, http.StatusTeapot, t.Get("invalid request domain: %s", r.Host)) + abortEntrance(w, r, conf, conf.App.Locale) return } @@ -77,12 +78,12 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager) } } if !allowed { - Abort(w, http.StatusTeapot, t.Get("invalid request ip: %s", ip)) + abortEntrance(w, r, conf, conf.App.Locale) return } } if len(conf.HTTP.BindUA) > 0 && !slices.Contains(conf.HTTP.BindUA, r.UserAgent()) { - Abort(w, http.StatusTeapot, t.Get("invalid request user agent: %s", r.UserAgent())) + abortEntrance(w, r, conf, conf.App.Locale) return } @@ -122,7 +123,7 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager) if !conf.App.Debug && sess.Missing("verify_entrance") && r.URL.Path != "/robots.txt" { - Abort(w, http.StatusTeapot, t.Get("invalid access entrance")) + abortEntrance(w, r, conf, conf.App.Locale) return } @@ -130,3 +131,43 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager) }) } } + +func abortEntrance(w http.ResponseWriter, r *http.Request, conf *config.Config, locale string) { + errorType := conf.HTTP.EntranceError + + switch errorType { + case "close": + hj, ok := w.(http.Hijacker) + if ok { + conn, _, err := hj.Hijack() + if err == nil { + _ = conn.Close() + return + } + } + // 如果无法 hijack,则返回空响应 + w.WriteHeader(http.StatusTeapot) + return + case "nginx": + content, err := embed.ErrorFS.ReadFile("error/nginx_404.html") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Server", "nginx") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write(content) + return + default: + fileName := "error/418.html" + if locale == "zh_CN" { + fileName = "error/418_zh_CN.html" + } + content, _ := embed.ErrorFS.ReadFile(fileName) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write(content) + return + } +} diff --git a/internal/http/middleware/must_login.go b/internal/http/middleware/must_login.go index 39820a83..016d736f 100644 --- a/internal/http/middleware/must_login.go +++ b/internal/http/middleware/must_login.go @@ -23,6 +23,7 @@ func MustLogin(t *gotext.Locale, conf *config.Config, session *sessions.Manager, // 白名单 whiteList := []string{ "/api/user/key", + "/api/user/captcha", "/api/user/login", "/api/user/logout", "/api/user/is_login", diff --git a/internal/http/request/setting.go b/internal/http/request/setting.go index e7821a76..075f1825 100644 --- a/internal/http/request/setting.go +++ b/internal/http/request/setting.go @@ -3,28 +3,30 @@ package request import "net/http" type SettingPanel struct { - Name string `json:"name" validate:"required"` - Channel string `json:"channel" validate:"required|in:stable,beta"` - Locale string `json:"locale" validate:"required"` - Entrance string `json:"entrance" validate:"required"` - OfflineMode bool `json:"offline_mode"` - AutoUpdate bool `json:"auto_update"` - TwoFA bool `json:"two_fa"` - Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分 - IPHeader string `json:"ip_header"` - BindDomain []string `json:"bind_domain"` - BindIP []string `json:"bind_ip"` - BindUA []string `json:"bind_ua"` - WebsitePath string `json:"website_path" validate:"required"` - BackupPath string `json:"backup_path" validate:"required"` - HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项 - CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL - Port uint `json:"port" validate:"required|min:1|max:65535"` - HTTPS bool `json:"https"` - ACME bool `json:"acme"` - PublicIP []string `json:"public_ip"` - Cert string `json:"cert" validate:"required"` - Key string `json:"key" validate:"required"` + Name string `json:"name" validate:"required"` + Channel string `json:"channel" validate:"required|in:stable,beta"` + Locale string `json:"locale" validate:"required"` + Entrance string `json:"entrance" validate:"required"` + EntranceError string `json:"entrance_error" validate:"in:418,nginx,close"` // 安全入口错误页伪装类型 + LoginCaptcha bool `json:"login_captcha"` // 登录验证码 + OfflineMode bool `json:"offline_mode"` + AutoUpdate bool `json:"auto_update"` + TwoFA bool `json:"two_fa"` + Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分 + IPHeader string `json:"ip_header"` + BindDomain []string `json:"bind_domain"` + BindIP []string `json:"bind_ip"` + BindUA []string `json:"bind_ua"` + WebsitePath string `json:"website_path" validate:"required"` + BackupPath string `json:"backup_path" validate:"required"` + HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项 + CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL + Port uint `json:"port" validate:"required|min:1|max:65535"` + HTTPS bool `json:"https"` + ACME bool `json:"acme"` + PublicIP []string `json:"public_ip"` + Cert string `json:"cert" validate:"required"` + Key string `json:"key" validate:"required"` } func (r *SettingPanel) Rules(_ *http.Request) map[string]string { diff --git a/internal/http/request/user.go b/internal/http/request/user.go index 68a0c179..4dec2af6 100644 --- a/internal/http/request/user.go +++ b/internal/http/request/user.go @@ -5,10 +5,11 @@ type UserID struct { } type UserLogin struct { - Username string `json:"username" validate:"required"` // encrypted with RSA-OAEP - Password string `json:"password" validate:"required"` // encrypted with RSA-OAEP - SafeLogin bool `json:"safe_login"` - PassCode string `json:"pass_code"` + Username string `json:"username" validate:"required"` // encrypted with RSA-OAEP + Password string `json:"password" validate:"required"` // encrypted with RSA-OAEP + SafeLogin bool `json:"safe_login"` + PassCode string `json:"pass_code"` // 2FA + CaptchaCode string `json:"captcha_code"` // 验证码 } type UserIsTwoFA struct { diff --git a/internal/route/http.go b/internal/route/http.go index e2d8bd8d..e385f536 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -136,6 +136,7 @@ func (route *Http) Register(r *chi.Mux) { r.Route("/api", func(r chi.Router) { r.Route("/user", func(r chi.Router) { r.Get("/key", route.user.GetKey) + r.Get("/captcha", route.user.GetCaptcha) r.With(middleware.Throttle(route.conf.HTTP.IPHeader, 5, time.Minute)).Post("/login", route.user.Login) r.Post("/logout", route.user.Logout) r.Get("/is_login", route.user.IsLogin) diff --git a/internal/service/user.go b/internal/service/user.go index 57c2ae32..3ec6caf4 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/dchest/captcha" "github.com/leonelquinteros/gotext" "github.com/libtnb/chix" "github.com/libtnb/sessions" @@ -25,6 +26,9 @@ import ( "github.com/acepanel/panel/pkg/rsacrypto" ) +// 登录失败次数阈值,超过此次数需要验证码 +const loginFailThreshold = 3 + type UserService struct { t *gotext.Locale conf *config.Config @@ -65,6 +69,37 @@ func (s *UserService) GetKey(w http.ResponseWriter, r *http.Request) { Success(w, pk) } +// GetCaptcha 获取登录验证码 +func (s *UserService) GetCaptcha(w http.ResponseWriter, r *http.Request) { + sess, err := s.session.GetSession(r) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + failCount := cast.ToInt(sess.Get("login_fail_count")) + if !s.conf.HTTP.LoginCaptcha || failCount < loginFailThreshold { + Success(w, chix.M{ + "required": false, + }) + return + } + + captchaID := captcha.NewLen(4) + sess.Put("captcha_id", captchaID) + + var buf bytes.Buffer + if err := captcha.WriteImage(&buf, captchaID, 150, 50); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, chix.M{ + "required": true, + "image": base64.StdEncoding.EncodeToString(buf.Bytes()), + }) +} + func (s *UserService) Login(w http.ResponseWriter, r *http.Request) { sess, err := s.session.GetSession(r) if err != nil { @@ -78,6 +113,16 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) { return } + failCount := cast.ToInt(sess.Get("login_fail_count")) + if s.conf.HTTP.LoginCaptcha && failCount >= loginFailThreshold { + captchaID, ok := sess.Get("captcha_id").(string) + if !ok || captchaID == "" || !captcha.VerifyString(captchaID, req.CaptchaCode) { + Error(w, http.StatusForbidden, s.t.Get("invalid captcha code")) + return + } + sess.Forget("captcha_id") + } + key, ok := sess.Get("key").(rsa.PrivateKey) if !ok { Error(w, http.StatusForbidden, s.t.Get("invalid key, please refresh the page")) @@ -88,6 +133,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) { decryptedPassword, _ := rsacrypto.DecryptData(&key, req.Password) user, err := s.userRepo.CheckPassword(string(decryptedUsername), string(decryptedPassword)) if err != nil { + sess.Put("login_fail_count", failCount+1) Error(w, http.StatusForbidden, "%v", err) return } @@ -128,6 +174,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) { sess.Put("user_id", user.ID) sess.Put("refresh_at", time.Now().Unix()) sess.Forget("key") + sess.Forget("login_fail_count") Success(w, nil) } diff --git a/pkg/embed/embed.go b/pkg/embed/embed.go index 5a1d309d..e8fb0a38 100644 --- a/pkg/embed/embed.go +++ b/pkg/embed/embed.go @@ -10,3 +10,6 @@ var WebsiteFS embed.FS //go:embed all:locales/* var LocalesFS embed.FS + +//go:embed all:error/* +var ErrorFS embed.FS diff --git a/pkg/embed/error/418.html b/pkg/embed/error/418.html new file mode 100644 index 00000000..d6083f9b --- /dev/null +++ b/pkg/embed/error/418.html @@ -0,0 +1,17 @@ + + +
+ + +You are accessing AcePanel through the wrong entrance.
+If you have forgotten the entrance, please run acepanel entrance off to disable it.
Powered by AcePanel
+您正在使用错误的入口访问 AcePanel。
+如果您忘记了入口,请运行 acepanel entrance off 将其关闭。
由 AcePanel 强力驱动
+