diff --git a/backend/services/recovery/public_flow_test.go b/backend/services/recovery/public_flow_test.go index 5e631f4..09abd00 100644 --- a/backend/services/recovery/public_flow_test.go +++ b/backend/services/recovery/public_flow_test.go @@ -1,12 +1,17 @@ package recovery import ( + "context" + "encoding/json" + "strconv" "strings" "testing" "time" + "git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/services/verification" "git.pcux.cn/xuans/Smail/utils/password" ) @@ -15,6 +20,46 @@ func TestPublicFlowEmailResetAndRoleBoundary(t *testing.T) { for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} { t.Run(string(role), func(t *testing.T) { s, verify, engine, user := newRecoveryFixture(t) + var migrations []migration.Migration + for _, item := range migration.DefaultMigrations() { + if item.Name == "create_managed_logs" || item.Name == "expand_operation_log_actor_role" { + migrations = append(migrations, item) + } + } + runner := migration.Runner{DB: engine.DB().DB, Dialect: migration.DialectSQLite, Migrations: migrations} + if _, err := runner.Run(context.Background()); err != nil { + t.Fatal(err) + } + logs, err := logstore.New(engine.DB().DB, migration.DialectSQLite) + if err != nil { + t.Fatal(err) + } + runtime := logstore.Start(logs) + defer runtime.Stop() + assertAudit := func(want int, secrets ...string) { + t.Helper() + entries, err := logs.Query(context.Background(), logstore.Query{Kind: "operation"}) + if err != nil || len(entries) != want { + t.Fatalf("恢复审计记录数量不符:数量=%d,预期=%d,错误=%v", len(entries), want, err) + } + for _, entry := range entries { + if entry.ActorID != strconv.Itoa(user.ID) || entry.ActorRole != string(role) || entry.Action != "公共密码恢复" || entry.Result != "success" { + t.Fatal("恢复审计的可信身份或结果不符") + } + var payload map[string]string + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload["method"] != "email" || payload["actorName"] != user.Account || len(payload) != 5 { + t.Fatal("恢复审计未使用预期的白名单字段") + } + for _, secret := range secrets { + if secret != "" && strings.Contains(string(entry.Payload), secret) { + t.Fatal("恢复审计包含敏感信息") + } + } + } + } user.Role = string(role) if _, err := engine.ID(user.ID).Cols("role").Update(user); err != nil { t.Fatal(err) @@ -29,6 +74,7 @@ func TestPublicFlowEmailResetAndRoleBoundary(t *testing.T) { if flow.Token != "" || len(flow.Methods) != 0 || flow.Email != "" { t.Fatal("super administrator received public recovery access") } + assertAudit(0) return } if flow.Email != "a***@example.com" || flow.Role != role { @@ -62,12 +108,14 @@ func TestPublicFlowEmailResetAndRoleBoundary(t *testing.T) { if err := s.CompletePublicFlow(ticket, "short"); err == nil { t.Fatal("invalid password accepted") } + assertAudit(0) if err := s.CompletePublicFlow(ticket, "NewPass123"); err != nil { t.Fatal(err) } if err := s.CompletePublicFlow(ticket, "OtherPass12"); err == nil { t.Fatal("ticket replay accepted") } + assertAudit(1, flow.Token, ticket, code, "NewPass123", "OtherPass12", "alice@example.com") var updated models.User if _, err := engine.ID(user.ID).Get(&updated); err != nil { t.Fatal(err) diff --git a/backend/services/recovery/public_link.go b/backend/services/recovery/public_link.go index 946aa1d..484b64a 100644 --- a/backend/services/recovery/public_link.go +++ b/backend/services/recovery/public_link.go @@ -1,14 +1,18 @@ package recovery import ( + "context" + "encoding/json" "errors" "fmt" + "strconv" "strings" "time" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/utils/password" ) @@ -253,6 +257,24 @@ func (s *Service) completePublicLink(token, newPassword, recoveryCode string, fl _ = s.verification.Store.InvalidateAccount(user.Account) } s.invalidateSecurityState(item.UserID, user.Account) + method := item.Method + if !flow { + method = "email_link" + if user.EffectiveRole() == access.RoleAdmin { + method = "email_link_and_security_code" + } + } + payload, _ := json.Marshal(struct { + ActorName string `json:"actorName"` + ActorRole string `json:"actorRole"` + Method string `json:"method"` + Object string `json:"object"` + Outcome string `json:"outcome"` + }{user.Account, string(user.EffectiveRole()), method, "账号安全", "密码已重置,旧凭据已撤销"}) + logstore.Record(context.Background(), logstore.Entry{ + Kind: "operation", CreatedAt: now, ActorID: strconv.Itoa(user.ID), ActorRole: string(user.EffectiveRole()), + Action: "公共密码恢复", Section: "账号安全", Result: "success", Payload: payload, + }) return nil } diff --git a/frontend/src/views/List/List.js b/frontend/src/views/List/List.js index 62f6eef..75e5010 100644 --- a/frontend/src/views/List/List.js +++ b/frontend/src/views/List/List.js @@ -67,7 +67,7 @@ export function usePage() { const totalPage = ref(0) const updateList = async function (silent = false) { - if (silent && selectedRows.value.length > 0) return + if (silent && selectedRows.value.length > 0) return false const version = ++requestVersion if (!silent) { isRefreshing.value = true @@ -76,7 +76,7 @@ export function usePage() { const res = await http.post("/api/v1/email/list", { tag, keyword: groupStore.keyword, current_page: currentPage.value, page_size: 15 }) - if (version !== requestVersion) return + if (version !== requestVersion) return false if (res.errorNo !== 0) throw new Error(res.errorMsg) const available = new Set((res.data.list || []).map(row => row.id)) selectedRows.value.filter(row => !available.has(row.id)).forEach(row => { @@ -86,12 +86,14 @@ export function usePage() { totalPage.value = res.data.total_page || 0 if (currentPage.value > Math.max(1, totalPage.value)) { currentPage.value = Math.max(1, totalPage.value) - await updateList(silent) + return await updateList(silent) } + return true } catch (err) { if (!silent && version === requestVersion) { ElMessage.error(err?.message || '获取邮件列表失败') } + return false } finally { if (!silent && version === requestVersion) { isRefreshing.value = false @@ -106,9 +108,11 @@ export function usePage() { throw new Error(res.errorMsg || "分组列表加载失败") } groupList.value = res.data || [] + return true } catch (error) { groupList.value = [] ElMessage.error(error?.message || "分组列表加载失败,请稍后重试") + return false } } @@ -139,10 +143,11 @@ export function usePage() { } const manualRefresh = async () => { - await Promise.all([ + const results = await Promise.all([ updateList(false), updateGroupList() ]) + if (!globalStatus.isLogin || !results.every(result => result === true)) return ElMessage.success('已刷新') startAutoRefreshTimer() }