From fed260559c72e0b78df6fd080b564b3da4f2334f Mon Sep 17 00:00:00 2001 From: nyoungo Date: Fri, 9 Oct 2026 00:50:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=99=BB=E5=BD=95=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20bcrypt=20=E5=B9=B6=E5=AF=B9=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=99=90=E9=80=9F=EF=BC=9Bbump=200.6.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理台密码改为 bcrypt 校验(已配置的 bcrypt 值直接使用,明文先 SHA-256 再哈希,避开 72 字节上限并保持常数时间比较) - 同一客户端 5 次失败登录锁定 5 分钟,429 响应带 Retry-After;成功登录清零 - 版本号提升到 0.6.4(Makefile / internal/admin.Version) --- Makefile | 2 +- internal/admin/login_test.go | 70 +++++++++++++++++++ internal/admin/server.go | 130 +++++++++++++++++++++++++++++++++-- 3 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 internal/admin/login_test.go diff --git a/Makefile b/Makefile index 06e4b62..26a35c4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # dsfree2api — build / test helpers GO ?= go -VERSION ?= 0.6.3 +VERSION ?= 0.6.4 LDFLAGS := -s -w -X github.com/nyoungo/dsfree2api/internal/admin.Version=$(VERSION) BIN := bin/dsfree2api IMAGE ?= dsfree2api:latest diff --git a/internal/admin/login_test.go b/internal/admin/login_test.go new file mode 100644 index 0000000..436b7dc --- /dev/null +++ b/internal/admin/login_test.go @@ -0,0 +1,70 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func loginOnce(t *testing.T, s *Server, password string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"password":"`+password+`"}`)) + req.RemoteAddr = "192.0.2.10:12345" + w := httptest.NewRecorder() + s.handleLogin(w, req) + return w +} + +func TestLoginAcceptsBcryptPassword(t *testing.T) { + cfg := testConfig(t) + cfg.Admin.Password = "correct-horse" + s := newActionServer(t, cfg) + if s.passwordMatches("wrong") { + t.Fatal("wrong password must not match") + } + if !s.passwordMatches("correct-horse") { + t.Fatal("configured password must match") + } + if w := loginOnce(t, s, "correct-horse"); w.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", w.Code, w.Body.String()) + } +} + +func TestLoginAcceptsPreHashedPassword(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("plaintext-secret"), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash: %v", err) + } + cfg := testConfig(t) + cfg.Admin.Password = string(hash) + s := newActionServer(t, cfg) + if !s.passwordPreHashed { + t.Fatal("a bcrypt hash in config must be used verbatim") + } + if w := loginOnce(t, s, "plaintext-secret"); w.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", w.Code, w.Body.String()) + } +} + +func TestLoginLocksOutAfterFiveFailures(t *testing.T) { + cfg := testConfig(t) + cfg.Admin.Password = "secret" + s := newActionServer(t, cfg) + + for i := 0; i < adminMaxFails; i++ { + if w := loginOnce(t, s, "nope"); w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want 401", i+1, w.Code) + } + } + // The next attempt — even with the right password — is locked out. + w := loginOnce(t, s, "secret") + if w.Code != http.StatusTooManyRequests { + t.Fatalf("locked status = %d, want 429", w.Code) + } + if w.Header().Get("Retry-After") == "" { + t.Fatal("locked response should carry Retry-After") + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 26aafd2..23d0f20 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -1,17 +1,22 @@ package admin import ( + "crypto/sha256" "embed" "encoding/json" "io/fs" "log/slog" + "net" "net/http" "os" "runtime" + "strconv" "strings" "sync" "time" + "golang.org/x/crypto/bcrypt" + "github.com/nyoungo/dsfree2api/internal/config" "github.com/nyoungo/dsfree2api/internal/logbuf" "github.com/nyoungo/dsfree2api/internal/metrics" @@ -21,8 +26,15 @@ import ( "github.com/nyoungo/dsfree2api/internal/upstream" ) +// Admin login throttling: five failed attempts within the window lock the +// client out for the same window. +const ( + adminMaxFails = 5 + adminLockWindow = 5 * time.Minute +) + // Version is reported by the console and /api/overview. -var Version = "0.6.3" +var Version = "0.6.4" //go:embed web/* var webFS embed.FS @@ -38,9 +50,20 @@ type Server struct { log *slog.Logger start time.Time - mu sync.Mutex - tokens map[string]time.Time - password string + mu sync.Mutex + tokens map[string]time.Time + passwordHash []byte + passwordPreHashed bool + + loginMu sync.Mutex + loginFails map[string]*loginFail +} + +// loginFail tracks one client's failed login attempts for throttling. +type loginFail struct { + count int + first time.Time + until time.Time } func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *logbuf.Buffer, ts *turnstile.Solver, log *slog.Logger, pool *proxypool.Manager, qw *quota.Watcher) *Server { @@ -53,9 +76,12 @@ func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *l pw = randomToken(12) generated = true } + hash, preHashed := derivePasswordHash(pw) s := &Server{ cfg: cfg, up: up, met: met, logs: logs, ts: ts, pool: pool, quota: qw, log: log, - start: time.Now(), tokens: map[string]time.Time{}, password: pw, + start: time.Now(), tokens: map[string]time.Time{}, + passwordHash: hash, passwordPreHashed: preHashed, + loginFails: map[string]*loginFail{}, } if generated { s.log.Warn("admin password was not configured — generated a one-time password", @@ -64,6 +90,88 @@ func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *l return s } +// derivePasswordHash turns the configured admin password into a bcrypt hash. +// An already-bcrypt value is used verbatim; a plaintext value is pre-hashed +// with SHA-256 first, which both removes bcrypt's 72-byte input limit and +// keeps login comparisons constant-time. +func derivePasswordHash(pw string) ([]byte, bool) { + if isBcryptHash(pw) { + return []byte(pw), true + } + sum := sha256.Sum256([]byte(pw)) + hash, err := bcrypt.GenerateFromPassword(sum[:], bcrypt.DefaultCost) + if err != nil { + return nil, false + } + return hash, false +} + +func isBcryptHash(s string) bool { + if len(s) != 60 { + return false + } + return strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$") +} + +func (s *Server) passwordMatches(input string) bool { + if len(s.passwordHash) == 0 { + return false + } + candidate := []byte(input) + if !s.passwordPreHashed { + sum := sha256.Sum256([]byte(input)) + candidate = sum[:] + } + return bcrypt.CompareHashAndPassword(s.passwordHash, candidate) == nil +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// loginLockRemaining returns the time left in a lockout, or 0 when free. +func (s *Server) loginLockRemaining(key string) time.Duration { + s.loginMu.Lock() + defer s.loginMu.Unlock() + f := s.loginFails[key] + if f == nil { + return 0 + } + now := time.Now() + if now.Before(f.until) { + return f.until.Sub(now) + } + if f.until.IsZero() && now.Sub(f.first) > adminLockWindow { + delete(s.loginFails, key) + } + return 0 +} + +func (s *Server) recordLoginFailure(key string) { + s.loginMu.Lock() + defer s.loginMu.Unlock() + now := time.Now() + f := s.loginFails[key] + if f == nil || now.Sub(f.first) > adminLockWindow { + f = &loginFail{first: now} + s.loginFails[key] = f + } + f.count++ + if f.count >= adminMaxFails { + f.until = now.Add(adminLockWindow) + } +} + +func (s *Server) clearLoginFailures(key string) { + s.loginMu.Lock() + defer s.loginMu.Unlock() + delete(s.loginFails, key) +} + func (s *Server) Handler() http.Handler { sub, _ := fs.Sub(webFS, "web") static := http.FileServer(http.FS(sub)) @@ -124,15 +232,25 @@ func (s *Server) validToken(tok string) bool { } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + client := clientIP(r) + if wait := s.loginLockRemaining(client); wait > 0 { + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) + writeJSON(w, http.StatusTooManyRequests, map[string]any{ + "error": "too many failed login attempts; try again later", + }) + return + } var body struct { Password string `json:"password"` } _ = json.NewDecoder(r.Body).Decode(&body) - if body.Password != s.password { + if !s.passwordMatches(body.Password) { + s.recordLoginFailure(client) time.Sleep(300 * time.Millisecond) writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid password"}) return } + s.clearLoginFailures(client) tok := randomToken(24) s.mu.Lock() for k, exp := range s.tokens {