fix: 改密计入锁定并清理过期管理员会话
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/admin"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||
)
|
||||
|
||||
func TestPasswordWrongOldLocksAfterTen(t *testing.T) {
|
||||
_, srv, client, _ := setup(t)
|
||||
login(t, client, srv.URL)
|
||||
base := srv.URL
|
||||
var last *http.Response
|
||||
var env envelope
|
||||
for i := 0; i < 10; i++ {
|
||||
last = postJSON(t, client, base+"/api/admin/password",
|
||||
`{"old_password":"not-the-password","new_password":"new-password-12"}`,
|
||||
map[string]string{"X-Nixmsg-Request": "1"})
|
||||
env = decodeEnv(t, last)
|
||||
}
|
||||
if last.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("want 429 after 10 wrong old passwords, got %d %+v", last.StatusCode, env)
|
||||
}
|
||||
if env.Error == nil || env.Error.Code != "rate_limited" {
|
||||
t.Fatalf("want rate_limited got %+v", env.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginDeletesExpiredAdminSessions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := store.Open(filepath.Join(dir, "data"), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
hash := auth.NewStubHashPool()
|
||||
if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil {
|
||||
t.Fatal(seedErr)
|
||||
}
|
||||
h := admin.New(admin.Deps{
|
||||
DB: db,
|
||||
Hash: hash,
|
||||
Tokens: admin.NewRandomAPITokens(),
|
||||
Locks: admin.NewMemoryLoginLocks(),
|
||||
})
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
if err := db.Queue.Do(context.Background(), func(tx *sql.Tx) error {
|
||||
_, e := tx.Exec(`INSERT INTO admin_sessions(token_hash, created_at, expires_at) VALUES ('expired-hash', 1, 1)`)
|
||||
return e
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar}
|
||||
login(t, client, srv.URL)
|
||||
var n int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM admin_sessions WHERE token_hash = 'expired-hash'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("expired session still present, count=%d", n)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -111,6 +111,13 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
|
||||
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||
h.audit(actorString(p), "password_change", "", "rate_limited", ip)
|
||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
@@ -134,10 +141,18 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ok, err := h.hash.Verify(r.Context(), auth.PasswordAdmin, req.OldPassword, phc)
|
||||
if err != nil || !ok {
|
||||
locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
||||
if locked {
|
||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||
h.audit(actorString(p), "password_change", "", "rate_limited", ip)
|
||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), "password_change", "", "unauthorized", ip)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "旧密码错误")
|
||||
return
|
||||
}
|
||||
h.locks.Clear(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
||||
newPHC, err := h.hash.Hash(r.Context(), auth.PasswordAdmin, req.NewPassword)
|
||||
if err != nil {
|
||||
h.audit(actorString(p), "password_change", "", "error", ip)
|
||||
@@ -149,9 +164,13 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
// 保留当前会话,作废其它会话
|
||||
// 保留当前会话,作废其它会话;失败则返回 500,避免其它会话继续有效。
|
||||
if p.Session != "" {
|
||||
_ = h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session))
|
||||
if err := h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session)); err != nil {
|
||||
h.audit(actorString(p), "password_change", "", "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
h.audit(actorString(p), "password_change", "", "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{})
|
||||
|
||||
@@ -42,6 +42,9 @@ func (h *Handler) setAdminPasswordHash(ctx context.Context, phc string) error {
|
||||
func (h *Handler) createSession(ctx context.Context, hashHex string, ttl time.Duration) error {
|
||||
now := time.Now()
|
||||
return h.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`DELETE FROM admin_sessions WHERE expires_at <= ?`, now.UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
`INSERT INTO admin_sessions(token_hash, created_at, expires_at) VALUES(?, ?, ?)`,
|
||||
hashHex, now.UnixMilli(), now.Add(ttl).UnixMilli(),
|
||||
|
||||
Reference in New Issue
Block a user