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:
1 parent
c877c1bf8c
commit
fed260559c
3 files changed
+195
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user