diff --git a/cmd/web/wire_gen.go b/cmd/web/wire_gen.go index f9230cfa..0945f54e 100644 --- a/cmd/web/wire_gen.go +++ b/cmd/web/wire_gen.go @@ -53,6 +53,10 @@ func initWeb() (*app.Web, error) { if err != nil { return nil, err } + locale, err := bootstrap.NewT(koanf) + if err != nil { + return nil, err + } logger := bootstrap.NewLog(koanf) db, err := bootstrap.NewDB(koanf, logger) if err != nil { @@ -62,10 +66,6 @@ func initWeb() (*app.Web, error) { if err != nil { return nil, err } - locale, err := bootstrap.NewT(koanf) - if err != nil { - return nil, err - } cacheRepo := data.NewCacheRepo(db) queue := bootstrap.NewQueue() taskRepo := data.NewTaskRepo(locale, db, logger, queue) @@ -145,7 +145,7 @@ func initWeb() (*app.Web, error) { http := route.NewHttp(userService, dashboardService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, loader) wsService := service.NewWsService(locale, koanf, sshRepo) ws := route.NewWs(wsService) - mux, err := bootstrap.NewRouter(middlewares, http, ws) + mux, err := bootstrap.NewRouter(locale, middlewares, http, ws) if err != nil { return nil, err } diff --git a/go.sum b/go.sum index 417720c0..bb5a64a4 100644 --- a/go.sum +++ b/go.sum @@ -304,8 +304,6 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sethvargo/go-limiter v1.0.0 h1:JqW13eWEMn0VFv86OKn8wiYJY/m250WoXdrjRV0kLe4= -github.com/sethvargo/go-limiter v1.0.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II= github.com/sethvargo/go-limiter v1.0.1-0.20250412144437-fa26982c7e1a h1:CdCoDHVynJVAQWN7ZQrAUOp0SV5TmRwNOSkF5KedDko= github.com/sethvargo/go-limiter v1.0.1-0.20250412144437-fa26982c7e1a/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= diff --git a/internal/biz/setting.go b/internal/biz/setting.go index b098c1c1..fa38c8f6 100644 --- a/internal/biz/setting.go +++ b/internal/biz/setting.go @@ -4,8 +4,6 @@ import ( "context" "time" - "github.com/pquerna/otp" - "github.com/tnb-labs/panel/internal/http/request" ) @@ -22,15 +20,6 @@ const ( SettingKeyMySQLRootPassword SettingKey = "mysql_root_password" SettingKeyOfflineMode SettingKey = "offline_mode" SettingKeyAutoUpdate SettingKey = "auto_update" - SettingKeyTwoFA SettingKey = "two_fa" - SettingKeyTwoFASecret SettingKey = "two_fa_secret" - SettingKeyLoginTimeout SettingKey = "login_timeout" - SettingKeyBindDomain SettingKey = "bind_domain" - SettingKeyBindIP SettingKey = "bind_ip" - SettingKeyBindUA SettingKey = "bind_ua" - SettingKeyAPI SettingKey = "api" - SettingKeyAPIKey SettingKey = "api_key" - SettingKeyAPIWhiteList SettingKey = "api_white_list" ) type Setting struct { @@ -51,6 +40,4 @@ type SettingRepo interface { Delete(key SettingKey) error GetPanelSetting(ctx context.Context) (*request.PanelSetting, error) UpdatePanelSetting(ctx context.Context, setting *request.PanelSetting) (bool, error) - GenerateTwoFAKey() (*otp.Key, error) - GenerateAPIKey() (string, error) } diff --git a/internal/biz/user.go b/internal/biz/user.go index 0233f620..ff0c2b28 100644 --- a/internal/biz/user.go +++ b/internal/biz/user.go @@ -11,6 +11,7 @@ type User struct { Username string `gorm:"not null;default:'';unique" json:"username"` Password string `gorm:"not null;default:''" json:"password"` Email string `gorm:"not null;default:''" json:"email"` + TwoFA string `gorm:"not null;default:''" json:"two_fa"` // 2FA secret,为空表示未开启 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` diff --git a/internal/biz/user_token.go b/internal/biz/user_token.go new file mode 100644 index 00000000..12776146 --- /dev/null +++ b/internal/biz/user_token.go @@ -0,0 +1,21 @@ +package biz + +import ( + "time" +) + +type UserToken struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"user_id"` + Token string `gorm:"not null;default:'';unique" json:"token"` + IPs []string `gorm:"not null;default:'[]';serializer:json" json:"ips"` + ExpiredAt time.Time `json:"expired_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserTokenRepo interface { + Create(userID uint, ips []string) (*UserToken, error) + Get(id uint) (*UserToken, error) + Save(user *UserToken) error +} diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go index 4d370a38..e4bbd2ec 100644 --- a/internal/bootstrap/http.go +++ b/internal/bootstrap/http.go @@ -8,16 +8,17 @@ import ( "github.com/bddjr/hlfhr" "github.com/go-chi/chi/v5" "github.com/knadh/koanf/v2" + "github.com/leonelquinteros/gotext" "github.com/tnb-labs/panel/internal/http/middleware" "github.com/tnb-labs/panel/internal/route" ) -func NewRouter(middlewares *middleware.Middlewares, http *route.Http, ws *route.Ws) (*chi.Mux, error) { +func NewRouter(t *gotext.Locale, middlewares *middleware.Middlewares, http *route.Http, ws *route.Ws) (*chi.Mux, error) { r := chi.NewRouter() // add middleware - r.Use(middlewares.Globals(r)...) + r.Use(middlewares.Globals(t, r)...) // add http route http.Register(r) // add ws route diff --git a/internal/bootstrap/session.go b/internal/bootstrap/session.go index 457e5a1a..0450042b 100644 --- a/internal/bootstrap/session.go +++ b/internal/bootstrap/session.go @@ -9,9 +9,14 @@ import ( func NewSession(conf *koanf.Koanf, db *gorm.DB) (*sessions.Manager, error) { // initialize session manager + lifetime := conf.Int("session.lifetime") + // TODO: will remove this fallback in v3 + if lifetime == 0 { + lifetime = 120 + } manager, err := sessions.NewManager(&sessions.ManagerOptions{ Key: conf.MustString("app.key"), - Lifetime: conf.MustInt("session.lifetime"), + Lifetime: lifetime, GcInterval: 5, DisableDefaultDriver: true, }) diff --git a/internal/bootstrap/validator.go b/internal/bootstrap/validator.go index df454b13..04dc274b 100644 --- a/internal/bootstrap/validator.go +++ b/internal/bootstrap/validator.go @@ -23,7 +23,6 @@ func NewValidator(conf *koanf.Koanf, db *gorm.DB) *validate.Validation { validate.Config(func(opt *validate.GlobalOption) { opt.StopOnError = false opt.SkipOnEmpty = true - opt.FieldTag = "form" }) // register global rules diff --git a/internal/data/setting.go b/internal/data/setting.go index ab60ffaa..45bc6cfa 100644 --- a/internal/data/setting.go +++ b/internal/data/setting.go @@ -8,7 +8,6 @@ import ( "sync" "github.com/go-rat/utils/hash" - "github.com/go-rat/utils/str" "github.com/knadh/koanf/v2" "github.com/leonelquinteros/gotext" "github.com/pquerna/otp" @@ -219,22 +218,6 @@ func (r *settingRepo) GetPanelSetting(ctx context.Context) (*request.PanelSettin if err != nil { return nil, err } - twoFA, err := r.GetBool(biz.SettingKeyTwoFA) - if err != nil { - return nil, err - } - bindDomain, err := r.GetSlice(biz.SettingKeyBindDomain) - if err != nil { - return nil, err - } - bindIP, err := r.GetSlice(biz.SettingKeyBindIP) - if err != nil { - return nil, err - } - bindUA, err := r.GetSlice(biz.SettingKeyBindUA) - if err != nil { - return nil, err - } websitePath, err := r.Get(biz.SettingKeyWebsitePath) if err != nil { return nil, err @@ -243,14 +226,6 @@ func (r *settingRepo) GetPanelSetting(ctx context.Context) (*request.PanelSettin if err != nil { return nil, err } - api, err := r.GetBool(biz.SettingKeyAPI) - if err != nil { - return nil, err - } - apiWhiteList, err := r.GetSlice(biz.SettingKeyAPIWhiteList) - if err != nil { - return nil, err - } userID := cast.ToUint(ctx.Value("user_id")) user := new(biz.User) @@ -268,27 +243,24 @@ func (r *settingRepo) GetPanelSetting(ctx context.Context) (*request.PanelSettin } return &request.PanelSetting{ - Name: name, - Channel: channel, - Locale: r.conf.String("app.locale"), - Entrance: r.conf.String("http.entrance"), - OfflineMode: offlineMode, - AutoUpdate: autoUpdate, - TwoFA: twoFA, - Lifetime: uint(r.conf.Int("session.lifetime")), - BindDomain: bindDomain, - BindIP: bindIP, - BindUA: bindUA, - WebsitePath: websitePath, - BackupPath: backupPath, - Username: user.Username, - Email: user.Email, - Port: uint(r.conf.Int("http.port")), - API: api, - APIWhiteList: apiWhiteList, - HTTPS: r.conf.Bool("http.tls"), - Cert: crt, - Key: key, + Name: name, + Channel: channel, + Locale: r.conf.String("app.locale"), + Entrance: r.conf.String("http.entrance"), + OfflineMode: offlineMode, + AutoUpdate: autoUpdate, + Lifetime: uint(r.conf.Int("session.lifetime")), + BindDomain: r.conf.Strings("http.bind_domain"), + BindIP: r.conf.Strings("http.bind_ip"), + BindUA: r.conf.Strings("http.bind_ua"), + WebsitePath: websitePath, + BackupPath: backupPath, + Username: user.Username, + Email: user.Email, + Port: uint(r.conf.Int("http.port")), + HTTPS: r.conf.Bool("http.tls"), + Cert: crt, + Key: key, }, nil } @@ -305,18 +277,6 @@ func (r *settingRepo) UpdatePanelSetting(ctx context.Context, setting *request.P if err := r.Set(biz.SettingKeyAutoUpdate, cast.ToString(setting.AutoUpdate)); err != nil { return false, err } - if err := r.Set(biz.SettingKeyTwoFA, cast.ToString(setting.TwoFA)); err != nil { - return false, err - } - if err := r.SetSlice(biz.SettingKeyBindDomain, setting.BindDomain); err != nil { - return false, err - } - if err := r.SetSlice(biz.SettingKeyBindIP, setting.BindIP); err != nil { - return false, err - } - if err := r.SetSlice(biz.SettingKeyBindUA, setting.BindUA); err != nil { - return false, err - } if err := r.Set(biz.SettingKeyWebsitePath, setting.WebsitePath); err != nil { return false, err } @@ -388,6 +348,9 @@ func (r *settingRepo) UpdatePanelSetting(ctx context.Context, setting *request.P config.HTTP.Port = setting.Port config.HTTP.Entrance = setting.Entrance config.HTTP.TLS = setting.HTTPS + config.HTTP.BindDomain = setting.BindDomain + config.HTTP.BindIP = setting.BindIP + config.HTTP.BindUA = setting.BindUA config.Session.Lifetime = setting.Lifetime // 放行端口 @@ -420,8 +383,9 @@ func (r *settingRepo) UpdatePanelSetting(ctx context.Context, setting *request.P return restartFlag, nil } -// GenerateTwoFAKey 生成两步验证密钥 -func (r *settingRepo) GenerateTwoFAKey() (*otp.Key, error) { +// GetTwoFA 生成两步验证密钥 +// TODO: 即将废弃 +func (r *settingRepo) GetTwoFA() (*otp.Key, error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: "RatPanel", AccountName: "admin", @@ -432,24 +396,5 @@ func (r *settingRepo) GenerateTwoFAKey() (*otp.Key, error) { return nil, err } - if err = r.Set(biz.SettingKeyTwoFASecret, key.Secret()); err != nil { - return nil, err - } - - return key, nil -} - -// GenerateAPIKey 生成API密钥 -func (r *settingRepo) GenerateAPIKey() (string, error) { - key := str.Random(32) - hashed, err := hash.NewArgon2id().Make(key) - if err != nil { - return "", err - } - - if err = r.Set(biz.SettingKeyAPIKey, hashed); err != nil { - return "", err - } - return key, nil } diff --git a/internal/http/middleware/entrance.go b/internal/http/middleware/entrance.go index 13447194..f54b669a 100644 --- a/internal/http/middleware/entrance.go +++ b/internal/http/middleware/entrance.go @@ -1,17 +1,22 @@ package middleware import ( + "net" "net/http" + "slices" "strings" "github.com/go-rat/chix" "github.com/go-rat/sessions" "github.com/knadh/koanf/v2" + "github.com/leonelquinteros/gotext" "github.com/spf13/cast" + + "github.com/tnb-labs/panel/pkg/punycode" ) // Entrance 确保通过正确的入口访问 -func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handler) http.Handler { +func Entrance(t *gotext.Locale, conf *koanf.Koanf, session *sessions.Manager) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, err := session.GetSession(r) @@ -26,6 +31,50 @@ func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handl } entrance := conf.String("http.entrance") + + // 情况一:设置了绑定域名、IP、UA,且请求不符合要求,返回错误 + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host + } + if strings.Contains(host, "xn--") { + if decoded, err := punycode.DecodeDomain(host); err == nil { + host = decoded + } + } + if len(conf.Strings("http.bind_domain")) > 0 && !slices.Contains(conf.Strings("http.bind_domain"), host) { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusTeapot) + render.JSON(chix.M{ + "message": t.Get("invalid request domain: %s", r.Host), + }) + return + } + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + if len(conf.Strings("http.bind_ip")) > 0 && !slices.Contains(conf.Strings("http.bind_ip"), ip) { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusTeapot) + render.JSON(chix.M{ + "message": t.Get("invalid request ip: %s", r.RemoteAddr), + }) + return + } + if len(conf.Strings("http.bind_ua")) > 0 && !slices.Contains(conf.Strings("http.bind_ua"), r.UserAgent()) { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusTeapot) + render.JSON(chix.M{ + "message": t.Get("invalid request user agent: %s", r.UserAgent()), + }) + return + } + + // 情况二:请求路径与入口路径相同,标记通过验证并重定向到登录页面 if strings.TrimSuffix(r.URL.Path, "/") == strings.TrimSuffix(entrance, "/") { sess.Put("verify_entrance", true) render := chix.NewRender(w, r) @@ -34,6 +83,14 @@ func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handl return } + // 情况三:通过APIKey+入口路径访问,重写请求路径并跳过验证 + if strings.HasPrefix(r.URL.Path, entrance) && r.Header.Get("Authorization") != "" { + r.URL.Path = strings.TrimPrefix(r.URL.Path, entrance) + next.ServeHTTP(w, r) + return + } + + // 情况三:非调试模式且未通过验证的请求,返回错误 if !conf.Bool("app.debug") && !cast.ToBool(sess.Get("verify_entrance", false)) && r.URL.Path != "/robots.txt" { @@ -41,7 +98,7 @@ func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handl defer render.Release() render.Status(http.StatusTeapot) render.JSON(chix.M{ - "message": "请通过正确的入口访问", + "message": t.Get("invalid access entrance"), }) return } diff --git a/internal/http/middleware/middleware.go b/internal/http/middleware/middleware.go index 7c535eac..241c0960 100644 --- a/internal/http/middleware/middleware.go +++ b/internal/http/middleware/middleware.go @@ -11,6 +11,7 @@ import ( "github.com/golang-cz/httplog" "github.com/google/wire" "github.com/knadh/koanf/v2" + "github.com/leonelquinteros/gotext" "github.com/tnb-labs/panel/internal/biz" ) @@ -34,7 +35,7 @@ func NewMiddlewares(conf *koanf.Koanf, log *slog.Logger, session *sessions.Manag } // Globals is a collection of global middleware that will be applied to every request. -func (r *Middlewares) Globals(mux *chi.Mux) []func(http.Handler) http.Handler { +func (r *Middlewares) Globals(t *gotext.Locale, mux *chi.Mux) []func(http.Handler) http.Handler { return []func(http.Handler) http.Handler{ sessionmiddleware.StartSession(r.session), //middleware.SupressNotFound(mux),// bug https://github.com/go-chi/chi/pull/940 @@ -46,9 +47,9 @@ func (r *Middlewares) Globals(mux *chi.Mux) []func(http.Handler) http.Handler { LogRequestHeaders: []string{"User-Agent"}, }), middleware.Recoverer, - Status, - Entrance(r.conf, r.session), - MustLogin(r.session), - MustInstall(r.app), + Status(t), + Entrance(t, r.conf, r.session), + MustLogin(t, r.session), + MustInstall(t, r.app), } } diff --git a/internal/http/middleware/must_install.go b/internal/http/middleware/must_install.go index 80f7a8b6..20cf7b7e 100644 --- a/internal/http/middleware/must_install.go +++ b/internal/http/middleware/must_install.go @@ -1,17 +1,17 @@ package middleware import ( - "fmt" "net/http" "strings" "github.com/go-rat/chix" + "github.com/leonelquinteros/gotext" "github.com/tnb-labs/panel/internal/biz" ) // MustInstall 确保已安装应用 -func MustInstall(app biz.AppRepo) func(next http.Handler) http.Handler { +func MustInstall(t *gotext.Locale, app biz.AppRepo) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var slugs []string @@ -26,7 +26,7 @@ func MustInstall(app biz.AppRepo) func(next http.Handler) http.Handler { defer render.Release() render.Status(http.StatusForbidden) render.JSON(chix.M{ - "message": "应用不存在", + "message": t.Get("app not found"), }) return } @@ -45,7 +45,7 @@ func MustInstall(app biz.AppRepo) func(next http.Handler) http.Handler { defer render.Release() render.Status(http.StatusForbidden) render.JSON(chix.M{ - "message": fmt.Sprintf("应用 %s 未安装", slugs), + "message": t.Get("app %s not installed", slugs), }) return } diff --git a/internal/http/middleware/must_login.go b/internal/http/middleware/must_login.go index e279e85a..3cdb3994 100644 --- a/internal/http/middleware/must_login.go +++ b/internal/http/middleware/must_login.go @@ -1,21 +1,29 @@ package middleware import ( + "bytes" "context" + "crypto/hmac" "crypto/sha256" + "crypto/subtle" + "encoding/hex" "fmt" + "io" "net" "net/http" "slices" "strings" + "time" "github.com/go-rat/chix" "github.com/go-rat/sessions" + "github.com/go-rat/utils/str" + "github.com/leonelquinteros/gotext" "github.com/spf13/cast" ) // MustLogin 确保已登录 -func MustLogin(session *sessions.Manager) func(next http.Handler) http.Handler { +func MustLogin(t *gotext.Locale, session *sessions.Manager) func(next http.Handler) http.Handler { // 白名单 whiteList := []string{ "/api/user/key", @@ -43,45 +51,101 @@ func MustLogin(session *sessions.Manager) func(next http.Handler) http.Handler { return } - if sess.Missing("user_id") { - render := chix.NewRender(w) - defer render.Release() - render.Status(http.StatusUnauthorized) - render.JSON(chix.M{ - "message": "会话已过期,请重新登录", - }) - return + userID := uint(0) + if r.Header.Get("Authorization") != "" { + signature := strings.TrimPrefix(r.Header.Get("Authorization"), "HMAC-SHA256 ") + + // 步骤一:构造规范化请求 + body, err := io.ReadAll(r.Body) + if err != nil { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusInternalServerError) + render.JSON(chix.M{ + "message": err.Error(), + }) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s", r.Method, r.URL.Path, r.URL.Query().Encode(), str.SHA256(string(body))) + + // 步骤二:构造待签名字符串 + stringToSign := fmt.Sprintf("%s\n%d\n%s", "HMAC-SHA256", cast.ToInt64(r.Header.Get("X-Timestamp")), str.SHA256(canonicalRequest)) + + // 步骤三:计算签名 + validSignature := hmacsha256(stringToSign, cast.ToString(sess.Get("api_secret"))) + + // 步骤四:验证签名 + if subtle.ConstantTimeCompare([]byte(signature), []byte(validSignature)) != 1 { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusUnauthorized) + render.JSON(chix.M{ + "message": t.Get("invalid api signature"), + }) + return + } + timestamp := cast.ToInt64(r.Header.Get("X-Timestamp")) + if timestamp == 0 || timestamp < (time.Now().Unix()-60) { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusUnauthorized) + render.JSON(chix.M{ + "message": t.Get("api signature expired"), + }) + return + } + + // 步骤五:验证通过 + userID = 1 + } else { + if sess.Missing("user_id") { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusUnauthorized) + render.JSON(chix.M{ + "message": t.Get("session expired, please login again"), + }) + return + } + + safeLogin := cast.ToBool(sess.Get("safe_login")) + if safeLogin { + safeClientHash := cast.ToString(sess.Get("safe_client")) + ip, _, _ := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + clientHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip))) + if safeClientHash != clientHash || safeClientHash == "" { + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusUnauthorized) + render.JSON(chix.M{ + "message": t.Get("client ip/ua changed, please login again"), + }) + return + } + } + + userID = cast.ToUint(sess.Get("user_id")) } - userID := cast.ToUint(sess.Get("user_id")) if userID == 0 { render := chix.NewRender(w) defer render.Release() render.Status(http.StatusUnauthorized) render.JSON(chix.M{ - "message": "会话无效,请重新登录", + "message": t.Get("invalid user id, please login again"), }) return } - safeLogin := cast.ToBool(sess.Get("safe_login")) - if safeLogin { - safeClientHash := cast.ToString(sess.Get("safe_client")) - ip, _, _ := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) - clientHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip))) - if safeClientHash != clientHash || safeClientHash == "" { - render := chix.NewRender(w) - defer render.Release() - render.Status(http.StatusUnauthorized) - render.JSON(chix.M{ - "message": "客户端IP/UA变化,请重新登录", - }) - return - } - } - r = r.WithContext(context.WithValue(r.Context(), "user_id", userID)) // nolint:staticcheck next.ServeHTTP(w, r) }) } } + +func hmacsha256(data string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/http/middleware/status.go b/internal/http/middleware/status.go index 910523f2..d2653156 100644 --- a/internal/http/middleware/status.go +++ b/internal/http/middleware/status.go @@ -4,48 +4,51 @@ import ( "net/http" "github.com/go-rat/chix" + "github.com/leonelquinteros/gotext" "github.com/tnb-labs/panel/internal/app" ) // Status 检查程序状态 -func Status(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch app.Status { - case app.StatusUpgrade: - render := chix.NewRender(w) - defer render.Release() - render.Status(http.StatusServiceUnavailable) - render.JSON(chix.M{ - "message": "面板更新中,请稍后刷新", - }) - return - case app.StatusMaintain: - render := chix.NewRender(w) - defer render.Release() - render.Status(http.StatusServiceUnavailable) - render.JSON(chix.M{ - "message": "面板正在运行维护任务,请稍后刷新", - }) - return - case app.StatusClosed: - render := chix.NewRender(w) - defer render.Release() - render.Status(http.StatusForbidden) - render.JSON(chix.M{ - "message": "面板已关闭", - }) - return - case app.StatusFailed: - render := chix.NewRender(w) - defer render.Release() - render.Status(http.StatusInternalServerError) - render.JSON(chix.M{ - "message": "面板运行出错,请检查排除或联系支持", - }) - return - default: - next.ServeHTTP(w, r) - } - }) +func Status(t *gotext.Locale) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch app.Status { + case app.StatusUpgrade: + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusServiceUnavailable) + render.JSON(chix.M{ + "message": t.Get("panel is upgrading, please refresh later"), + }) + return + case app.StatusMaintain: + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusServiceUnavailable) + render.JSON(chix.M{ + "message": t.Get("panel is maintaining, please refresh later"), + }) + return + case app.StatusClosed: + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusForbidden) + render.JSON(chix.M{ + "message": t.Get("panel is closed"), + }) + return + case app.StatusFailed: + render := chix.NewRender(w) + defer render.Release() + render.Status(http.StatusInternalServerError) + render.JSON(chix.M{ + "message": t.Get("panel run error, please check or contact support"), + }) + return + default: + next.ServeHTTP(w, r) + } + }) + } } diff --git a/internal/http/request/setting.go b/internal/http/request/setting.go index cc1990ef..2aabb513 100644 --- a/internal/http/request/setting.go +++ b/internal/http/request/setting.go @@ -1,26 +1,34 @@ package request +import "net/http" + type PanelSetting 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"` // 登录超时,单位:分 - 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"` - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"password"` - Email string `json:"email" validate:"required"` - Port uint `json:"port" validate:"required|min:1|max:65535"` - API bool `json:"api"` - APIWhiteList []string `json:"api_white_list"` - HTTPS bool `json:"https"` - 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"` + 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"` // 登录超时,单位:分 + 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"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"password"` + Email string `json:"email" validate:"required"` + Port uint `json:"port" validate:"required|min:1|max:65535"` + HTTPS bool `json:"https"` + Cert string `json:"cert" validate:"required"` + Key string `json:"key" validate:"required"` +} + +func (r *PanelSetting) Rules(_ *http.Request) map[string]string { + return map[string]string{ + "BindDomain.*": "required", + "BindIP.*": "required|ip", + "BindUA.*": "required", + } } diff --git a/internal/migration/v1.go b/internal/migration/v1.go index 60e87079..aad7ec7f 100644 --- a/internal/migration/v1.go +++ b/internal/migration/v1.go @@ -93,4 +93,26 @@ func init() { return tx.Migrator().DropColumn(&biz.Cert{}, "script") }, }) + Migrations = append(Migrations, &gormigrate.Migration{ + ID: "20250514-user-two-fa", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &biz.User{}, + ) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Migrator().DropColumn(&biz.User{}, "two_fa") + }, + }) + Migrations = append(Migrations, &gormigrate.Migration{ + ID: "20250514-user-token", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &biz.UserToken{}, + ) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Migrator().DropTable(&biz.UserToken{}) + }, + }) } diff --git a/internal/service/cli.go b/internal/service/cli.go index 76b76cf6..e08fed2b 100644 --- a/internal/service/cli.go +++ b/internal/service/cli.go @@ -826,15 +826,6 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error { {Key: biz.SettingKeyWebsitePath, Value: filepath.Join(app.Root, "wwwroot")}, {Key: biz.SettingKeyOfflineMode, Value: "false"}, {Key: biz.SettingKeyAutoUpdate, Value: "true"}, - {Key: biz.SettingKeyTwoFA, Value: "false"}, - {Key: biz.SettingKeyTwoFASecret, Value: ""}, - {Key: biz.SettingKeyLoginTimeout, Value: "720"}, - {Key: biz.SettingKeyBindDomain, Value: "[]"}, - {Key: biz.SettingKeyBindIP, Value: "[]"}, - {Key: biz.SettingKeyBindUA, Value: "[]"}, - {Key: biz.SettingKeyAPI, Value: "false"}, - {Key: biz.SettingKeyAPIKey, Value: ""}, - {Key: biz.SettingKeyAPIWhiteList, Value: "[]"}, } if err := s.db.Create(&settings).Error; err != nil { return errors.New(s.t.Get("Initialization failed: %v", err)) diff --git a/pkg/types/config.go b/pkg/types/config.go index f3c6b348..eba9e61f 100644 --- a/pkg/types/config.go +++ b/pkg/types/config.go @@ -17,10 +17,13 @@ type PanelAppConfig struct { } type PanelHTTPConfig struct { - Debug bool `yaml:"debug"` - Port uint `yaml:"port"` - Entrance string `yaml:"entrance"` - TLS bool `yaml:"tls"` + Debug bool `yaml:"debug"` + Port uint `yaml:"port"` + Entrance string `yaml:"entrance"` + TLS bool `yaml:"tls"` + BindDomain []string `yaml:"bind_domain"` + BindIP []string `yaml:"bind_ip"` + BindUA []string `yaml:"bind_ua"` } type PanelDatabaseConfig struct { diff --git a/web/src/views/setting/IndexView.vue b/web/src/views/setting/IndexView.vue index e28cf44f..5b080840 100644 --- a/web/src/views/setting/IndexView.vue +++ b/web/src/views/setting/IndexView.vue @@ -1,24 +1,74 @@