补齐公共密码恢复审计并修正刷新成功提示
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-02 17:37:45 +08:00
parent 93be3257b7
commit a901bd19b4
3 changed files with 79 additions and 4 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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()
}