fix: 重置密码踢线失败时回退断开并记 ok_kick_failed
This commit is contained in:
@@ -953,6 +953,15 @@
|
||||
- 备选方案:独立 audit 表;未采用,保持 slog 采集。
|
||||
- 影响:日志采集仍按 `admin_audit` 过滤,新字段只增不改;另需采集 `admin_auth_fail`。
|
||||
|
||||
### 复审修复 H-07
|
||||
|
||||
- 日期:2026-09-30
|
||||
- 原条款:PRD F02 管理员重置登录密码后当前连接被踢下线;审查 #50。
|
||||
- 实际做法:`PasswordResetKick` 出错时回退 `KickEndpoint`,审计 `result=ok_kick_failed` 并写错误日志;HTTP 仍 200(密码已改)。停用/删除踢线钩子出错只写日志,不改审计结果。合入后审计改走 H-02 `auditP`。
|
||||
- 原因:`fatalKick` 清会话失败会直接返回,重置路径原先吞掉错误仍记 ok,设备可能仍在线。
|
||||
- 备选方案:踢线失败改回 500 并回滚密码;未采用,口令已写入且重试会再重置一次。
|
||||
- 影响:致命踢线失败时仍会尽力断开连接;审计可区分踢线未完成。
|
||||
|
||||
## 后台网页 W
|
||||
|
||||
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
|
||||
|
||||
@@ -104,28 +104,49 @@ func (h *Handler) kickEndpoint(ctx context.Context, id string) (bool, error) {
|
||||
|
||||
func (h *Handler) passwordResetKick(ctx context.Context, id string) (bool, error) {
|
||||
if h.resetKick != nil {
|
||||
return h.resetKick(ctx, id)
|
||||
kicked, err := h.resetKick(ctx, id)
|
||||
if err == nil {
|
||||
return kicked, nil
|
||||
}
|
||||
return h.kickEndpoint(ctx, id)
|
||||
h.log.Error("password reset kick failed", "endpoint", id, "err", err)
|
||||
fallbackKicked, fallbackErr := h.kickEndpoint(ctx, id)
|
||||
if fallbackErr != nil {
|
||||
h.log.Error("password reset kick fallback failed", "endpoint", id, "err", fallbackErr)
|
||||
}
|
||||
return fallbackKicked, err
|
||||
}
|
||||
kicked, err := h.kickEndpoint(ctx, id)
|
||||
if err != nil {
|
||||
h.log.Error("password reset kick failed", "endpoint", id, "err", err)
|
||||
}
|
||||
return kicked, err
|
||||
}
|
||||
|
||||
func (h *Handler) afterDisableKick(ctx context.Context, id string) {
|
||||
if h.disableKick != nil {
|
||||
_, _ = h.disableKick(ctx, id)
|
||||
if _, err := h.disableKick(ctx, id); err != nil {
|
||||
h.log.Error("disable kick failed", "endpoint", id, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if h.identity == nil {
|
||||
_, _ = h.kickEndpoint(ctx, id)
|
||||
if _, err := h.kickEndpoint(ctx, id); err != nil {
|
||||
h.log.Error("disable kick failed", "endpoint", id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) afterDeleteKick(ctx context.Context, id string) {
|
||||
if h.deleteKick != nil {
|
||||
_, _ = h.deleteKick(ctx, id)
|
||||
if _, err := h.deleteKick(ctx, id); err != nil {
|
||||
h.log.Error("delete kick failed", "endpoint", id, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if h.identity == nil {
|
||||
_, _ = h.kickEndpoint(ctx, id)
|
||||
if _, err := h.kickEndpoint(ctx, id); err != nil {
|
||||
h.log.Error("delete kick failed", "endpoint", id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,8 +578,12 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||
return
|
||||
}
|
||||
_, _ = h.passwordResetKick(r.Context(), id)
|
||||
h.auditP(p, "endpoint_reset_login_password", id, "ok", ip)
|
||||
_, kickErr := h.passwordResetKick(r.Context(), id)
|
||||
result := "ok"
|
||||
if kickErr != nil {
|
||||
result = "ok_kick_failed"
|
||||
}
|
||||
h.auditP(p, "endpoint_reset_login_password", id, result, ip)
|
||||
httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
@@ -369,6 +371,165 @@ func TestEndpointImportBOMAndCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointResetPasswordKickFailureFallsBack(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)
|
||||
}
|
||||
kick := &kickRecorder{}
|
||||
var logBuf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logBuf, nil))
|
||||
h := admin.New(admin.Deps{
|
||||
DB: db,
|
||||
Hash: hash,
|
||||
Tokens: admin.NewRandomAPITokens(),
|
||||
Locks: admin.NewMemoryLoginLocks(),
|
||||
Logger: logger,
|
||||
KickEndpoint: kick.Kick,
|
||||
PasswordResetKick: func(_ context.Context, _ string) (bool, error) {
|
||||
return false, errors.New("fatal kick failed")
|
||||
},
|
||||
})
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar}
|
||||
res := postJSON(t, client, srv.URL+"/api/admin/login",
|
||||
`{"username":"admin","password":"`+testPassword+`"}`, nil)
|
||||
env := decodeEnv(t, res)
|
||||
if res.StatusCode != http.StatusOK || !env.OK {
|
||||
t.Fatalf("login failed: %d %+v", res.StatusCode, env)
|
||||
}
|
||||
|
||||
res = postJSON(t, client, srv.URL+"/api/admin/endpoints",
|
||||
`{"id":"kick-fail","name":"踢失败","login_password":"oldpass12"}`,
|
||||
csrfHeaders())
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("create: %d %+v", res.StatusCode, env)
|
||||
}
|
||||
|
||||
res = postJSON(t, client, srv.URL+"/api/admin/endpoints/kick-fail/reset-login-password",
|
||||
`{}`, csrfHeaders())
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("reset status=%d body=%s", res.StatusCode, body)
|
||||
}
|
||||
var resetEnv struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
LoginPassword string `json:"login_password"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resetEnv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resetEnv.OK || resetEnv.Data.LoginPassword == "" {
|
||||
t.Fatalf("want one-time password after kick failure, got %s", body)
|
||||
}
|
||||
if kick.count() != 1 {
|
||||
t.Fatalf("want KickEndpoint fallback once, got %d", kick.count())
|
||||
}
|
||||
logs := logBuf.String()
|
||||
if !strings.Contains(logs, "action=endpoint_reset_login_password") {
|
||||
t.Fatalf("want reset password audit, logs=%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "result=ok_kick_failed") {
|
||||
t.Fatalf("audit want ok_kick_failed, logs=%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "password reset kick failed") || !strings.Contains(logs, "fatal kick failed") {
|
||||
t.Fatalf("want error log for reset kick, logs=%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableDeleteKickLogsError(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)
|
||||
}
|
||||
kick := &kickRecorder{}
|
||||
var logBuf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logBuf, nil))
|
||||
kickErr := errors.New("hook kick failed")
|
||||
h := admin.New(admin.Deps{
|
||||
DB: db,
|
||||
Hash: hash,
|
||||
Tokens: admin.NewRandomAPITokens(),
|
||||
Locks: admin.NewMemoryLoginLocks(),
|
||||
Logger: logger,
|
||||
KickEndpoint: kick.Kick,
|
||||
DisableKick: func(_ context.Context, _ string) (bool, error) {
|
||||
return false, kickErr
|
||||
},
|
||||
DeleteKick: func(_ context.Context, _ string) (bool, error) {
|
||||
return false, kickErr
|
||||
},
|
||||
})
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar}
|
||||
res := postJSON(t, client, srv.URL+"/api/admin/login",
|
||||
`{"username":"admin","password":"`+testPassword+`"}`, nil)
|
||||
env := decodeEnv(t, res)
|
||||
if res.StatusCode != http.StatusOK || !env.OK {
|
||||
t.Fatalf("login failed: %d %+v", res.StatusCode, env)
|
||||
}
|
||||
|
||||
for _, id := range []string{"dis-fail", "del-fail"} {
|
||||
res = postJSON(t, client, srv.URL+"/api/admin/endpoints",
|
||||
`{"id":"`+id+`","name":"`+id+`","login_password":"password1"}`,
|
||||
csrfHeaders())
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("create %s: %d %+v", id, res.StatusCode, env)
|
||||
}
|
||||
}
|
||||
|
||||
res = postJSON(t, client, srv.URL+"/api/admin/endpoints/batch",
|
||||
`{"ids":["dis-fail"],"action":"disable"}`, csrfHeaders())
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("disable: %d %+v", res.StatusCode, env)
|
||||
}
|
||||
|
||||
res = doReq(t, client, http.MethodDelete, srv.URL+"/api/admin/endpoints/del-fail", "", csrfHeaders())
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("delete: %d %+v", res.StatusCode, env)
|
||||
}
|
||||
if kick.count() != 0 {
|
||||
t.Fatalf("disable/delete hook errors must not fall back to KickEndpoint, got %d", kick.count())
|
||||
}
|
||||
logs := logBuf.String()
|
||||
if !strings.Contains(logs, "disable kick failed") || !strings.Contains(logs, "delete kick failed") {
|
||||
t.Fatalf("want disable/delete kick error logs, logs=%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfHeaders() map[string]string {
|
||||
return map[string]string{"X-Nixmsg-Request": "1"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user