feat(admin): 登录改用 bcrypt 并对失败登录限速;bump 0.6.4

- 管理台密码改为 bcrypt 校验(已配置的 bcrypt 值直接使用,明文先 SHA-256 再哈希,避开 72 字节上限并保持常数时间比较)
- 同一客户端 5 次失败登录锁定 5 分钟,429 响应带 Retry-After;成功登录清零
- 版本号提升到 0.6.4(Makefile / internal/admin.Version)
This commit is contained in:
nyoungo committed 2026-10-09 00:50:36 +08:00
1 parent c877c1bf8c
commit fed260559c
3 files changed
+195 -7

No files matched your search

+1 -1
View File
@@ -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
+70
View File
@@ -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")
}
}
+124 -6
View File
@@ -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 {