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 @@
+
+
+
+ {{ $gettext('Save') }}
+
+
-
+
-
-
+
+
diff --git a/web/src/views/setting/SettingBase.vue b/web/src/views/setting/SettingBase.vue
index 8195322a..c681652c 100644
--- a/web/src/views/setting/SettingBase.vue
+++ b/web/src/views/setting/SettingBase.vue
@@ -1,29 +1,10 @@
@@ -64,6 +43,9 @@ const handleSave = () => {
+
+
+
@@ -74,16 +56,7 @@ const handleSave = () => {
-
-
-
-
-
-
-
-
-
-
+
@@ -93,9 +66,6 @@ const handleSave = () => {
-
- {{ $gettext('Save') }}
-
diff --git a/web/src/views/setting/SettingHttps.vue b/web/src/views/setting/SettingHttps.vue
deleted file mode 100644
index 863e7018..00000000
--- a/web/src/views/setting/SettingHttps.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
-
-
- {{
- $gettext(
- 'Incorrect certificates may cause the panel to be inaccessible. Please proceed with caution!'
- )
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $gettext('Save') }}
-
-
-
-
diff --git a/web/src/views/setting/SettingSafe.vue b/web/src/views/setting/SettingSafe.vue
new file mode 100644
index 00000000..eedd0eb5
--- /dev/null
+++ b/web/src/views/setting/SettingSafe.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+ {{ $gettext('minutes') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+