fix: 重置密码踢线失败时回退断开并记 ok_kick_failed
This commit is contained in:
@@ -953,6 +953,15 @@
|
|||||||
- 备选方案:独立 audit 表;未采用,保持 slog 采集。
|
- 备选方案:独立 audit 表;未采用,保持 slog 采集。
|
||||||
- 影响:日志采集仍按 `admin_audit` 过滤,新字段只增不改;另需采集 `admin_auth_fail`。
|
- 影响:日志采集仍按 `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
|
## 后台网页 W
|
||||||
|
|
||||||
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
|
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) {
|
func (h *Handler) passwordResetKick(ctx context.Context, id string) (bool, error) {
|
||||||
if h.resetKick != nil {
|
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) {
|
func (h *Handler) afterDisableKick(ctx context.Context, id string) {
|
||||||
if h.disableKick != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if h.identity == nil {
|
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) {
|
func (h *Handler) afterDeleteKick(ctx context.Context, id string) {
|
||||||
if h.deleteKick != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if h.identity == nil {
|
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", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = h.passwordResetKick(r.Context(), id)
|
_, kickErr := h.passwordResetKick(r.Context(), id)
|
||||||
h.auditP(p, "endpoint_reset_login_password", id, "ok", ip)
|
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})
|
httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"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 {
|
func csrfHeaders() map[string]string {
|
||||||
return map[string]string{"X-Nixmsg-Request": "1"}
|
return map[string]string{"X-Nixmsg-Request": "1"}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user