补充本机重置安全回归并修复取消后的密码提交

This commit is contained in:
xuans 2026-09-21 00:48:35 +08:00
parent 5c327e2b48
commit 651d7ba917
3 changed files with 551 additions and 1 deletions

View File

@ -164,7 +164,7 @@ func (r *localResetSession) Reset(ctx context.Context, password string) error {
r.audit("failed", "本机密码重置同步账户失败")
return localreset.ErrUnavailable
}
if err := persisted.Save(s.configPath); err != nil {
if err := r.save(ctx, persisted); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
rollbackErr := s.syncPrimarySuperAdmin(rollbackCtx, previous)
cancel()
@ -203,3 +203,10 @@ func (r *localResetSession) audit(status, message string) {
r.server.logger.Warn("本机密码重置审计写入失败")
}
}
func (r *localResetSession) save(ctx context.Context, cfg config.Config) error {
if !r.current(ctx) {
return localreset.ErrProtocol
}
return cfg.Save(r.server.configPath)
}

View File

@ -0,0 +1,397 @@
package httpapi
import (
"context"
"crypto/sha256"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/localreset"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"golang.org/x/crypto/bcrypt"
)
func newLocalResetTestServer(t *testing.T) *Server {
t.Helper()
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
cfg.Admin.Email = "bound@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "OldPassword123")
cfg.Web.Hostname = "scert.example.test"
cfg.DNS.VerificationState = "verified"
path := filepath.Join(cfg.Paths.Config, "scert.json")
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatal(err)
}
users := repository.NewSQLUserRepository(db.DB, "sqlite")
if err := users.EnsureSuperAdmin(t.Context(), repository.User{
ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash,
}); err != nil {
t.Fatal(err)
}
return New(Dependencies{Config: cfg, ConfigPath: path, Repository: &stubDomainRepository{}, Users: users})
}
func preparedLocalReset(t *testing.T, server *Server) *localResetSession {
t.Helper()
var code string
factory := server.localResetFactory(func(_ context.Context, recipient, value string) error {
if recipient != "bound@example.test" {
t.Fatal("验证码收件人不是绑定邮箱")
}
code = value
return nil
})
session, err := factory(t.Context(), localreset.Peer{Identity: "test-local-admin"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(session.Close)
if err := session.Select(t.Context(), "email"); err != nil {
t.Fatal(err)
}
if len(code) != 8 {
t.Fatal("验证码长度不正确")
}
if err := session.Verify(t.Context(), code); err != nil {
t.Fatal(err)
}
return session.(*localResetSession)
}
func assertLocalResetUnchanged(t *testing.T, server *Server, hash string) {
t.Helper()
if server.config.Admin.PasswordHash != hash {
t.Fatal("失败流程修改了内存密码")
}
user, err := server.users.GetUserByID(t.Context(), "primary-super-admin")
if err != nil {
t.Fatal(err)
}
if user.PasswordHash != hash {
t.Fatal("失败流程修改了数据库密码")
}
}
func TestLocalResetPersistsAndRevokesOnlyAdminSessions(t *testing.T) {
server := newLocalResetTestServer(t)
oldHash := server.config.Admin.PasswordHash
server.authSessions["old-admin"] = authSession{UserID: "primary-super-admin"}
server.authSessions["other-user"] = authSession{UserID: "ordinary-user"}
session := preparedLocalReset(t, server)
if err := session.Reset(t.Context(), "NewPass123!"); err != nil {
t.Fatal(err)
}
persisted, err := config.Load(server.configPath)
if err != nil {
t.Fatal(err)
}
user, err := server.users.GetUserByID(t.Context(), "primary-super-admin")
if err != nil {
t.Fatal(err)
}
hash := server.config.Admin.PasswordHash
if hash == oldHash || hash != persisted.Admin.PasswordHash || hash != user.PasswordHash {
t.Fatal("密码没有一致写入配置、数据库和内存")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("NewPass123!")) != nil {
t.Fatal("新密码校验失败")
}
if _, ok := server.authSessions["old-admin"]; ok {
t.Fatal("旧管理员会话仍然有效")
}
if _, ok := server.authSessions["other-user"]; !ok {
t.Fatal("误注销其他用户")
}
if err := session.Reset(t.Context(), "NextPass123!"); err == nil {
t.Fatal("允许重放密码重置")
}
if session.codeHash != [32]byte{} {
t.Fatal("验证码摘要未清除")
}
}
func TestLocalResetRejectsInvalidState(t *testing.T) {
for _, scenario := range []string{"wrong-code", "expired", "changed-admin", "cancelled", "closed", "skip-select"} {
t.Run(scenario, func(t *testing.T) {
server := newLocalResetTestServer(t)
oldHash := server.config.Admin.PasswordHash
now := time.Now()
server.now = func() time.Time { return now }
var code string
factory := server.localResetFactory(func(_ context.Context, _, value string) error { code = value; return nil })
session, err := factory(t.Context(), localreset.Peer{Identity: "test-local-admin"})
if err != nil {
t.Fatal(err)
}
defer session.Close()
if scenario != "skip-select" {
if err := session.Select(t.Context(), "email"); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
switch scenario {
case "wrong-code":
code = "invalid-code"
case "expired":
now = now.Add(localreset.SessionTimeout)
case "changed-admin":
server.config.Admin.Email = "changed@example.test"
case "cancelled":
cancel()
case "closed":
session.Close()
}
if err := session.Verify(ctx, code); err == nil {
t.Fatal("无效会话通过验证")
}
if err := session.Reset(t.Context(), "NewPass123!"); err == nil {
t.Fatal("无效会话允许重置密码")
}
assertLocalResetUnchanged(t, server, oldHash)
})
}
}
func TestLocalResetRejectsUnavailableSetupAndChannel(t *testing.T) {
for _, scenario := range []string{"bootstrap", "missing-email", "missing-hash", "missing-path", "nil-sender", "cancelled"} {
t.Run(scenario, func(t *testing.T) {
server := newLocalResetTestServer(t)
sender := resetCodeSender(func(context.Context, string, string) error { return nil })
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
switch scenario {
case "bootstrap":
server.config.SetupReady = false
case "missing-email":
server.config.Admin.Email = ""
case "missing-hash":
server.config.Admin.PasswordHash = ""
case "missing-path":
server.configPath = ""
case "nil-sender":
sender = nil
case "cancelled":
cancel()
}
if session, err := server.localResetFactory(sender)(ctx, localreset.Peer{}); err == nil || session != nil {
t.Fatal("未就绪的服务创建了重置会话")
}
})
}
}
func TestLocalResetMailCooldownIsSharedAndConcurrent(t *testing.T) {
server := newLocalResetTestServer(t)
now := time.Now()
server.now = func() time.Time { return now }
var sends atomic.Int32
factory := server.localResetFactory(func(context.Context, string, string) error { sends.Add(1); return nil })
var wait sync.WaitGroup
for range 8 {
wait.Add(1)
go func() {
defer wait.Done()
session, err := factory(t.Context(), localreset.Peer{Identity: "test-local-admin"})
if err != nil {
t.Error(err)
return
}
defer session.Close()
_ = session.Select(t.Context(), "email")
}()
}
wait.Wait()
if sends.Load() != 1 {
t.Fatal("并发会话绕过了发送冷却")
}
now = now.Add(time.Minute)
session, err := factory(t.Context(), localreset.Peer{})
if err != nil {
t.Fatal(err)
}
defer session.Close()
if err := session.Select(t.Context(), "email"); err != nil {
t.Fatal(err)
}
if sends.Load() != 2 {
t.Fatal("冷却到期后仍无法发送")
}
}
type localResetFaultUsers struct {
repository.UserRepository
ensure func(context.Context, repository.User) error
}
func (r *localResetFaultUsers) EnsureSuperAdmin(ctx context.Context, user repository.User) error {
return r.ensure(ctx, user)
}
func TestLocalResetPersistenceFailureBoundaries(t *testing.T) {
for _, scenario := range []string{"database", "config-save", "cancel-after-database", "rollback"} {
t.Run(scenario, func(t *testing.T) {
server := newLocalResetTestServer(t)
oldHash := server.config.Admin.PasswordHash
session := preparedLocalReset(t, server)
base := server.users
calls := 0
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
server.users = &localResetFaultUsers{UserRepository: base, ensure: func(callCtx context.Context, user repository.User) error {
calls++
if calls == 1 && scenario == "database" {
return errors.New("injected-database-failure")
}
if calls == 2 {
if callCtx.Err() != nil {
t.Fatal("补偿回滚继承了取消状态")
}
deadline, ok := callCtx.Deadline()
if !ok || time.Until(deadline) > 6*time.Second {
t.Fatal("补偿回滚没有超时上限")
}
if scenario == "rollback" {
return errors.New("injected-rollback-failure")
}
}
if err := base.EnsureSuperAdmin(callCtx, user); err != nil {
return err
}
if calls == 1 {
if scenario == "cancel-after-database" {
cancel()
}
if scenario == "config-save" || scenario == "rollback" {
if err := os.Rename(server.configPath, server.configPath+".preserved"); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(server.configPath, 0700); err != nil {
t.Fatal(err)
}
}
}
return nil
}}
if err := session.Reset(ctx, "NewPass123!"); !errors.Is(err, localreset.ErrUnavailable) {
t.Fatalf("失败流程报告了成功或错误类型不正确:%v", err)
}
if scenario == "database" {
if calls != 1 {
t.Fatal("数据库失败后不应继续提交")
}
} else if calls != 2 {
t.Fatal("持久化失败没有尝试回滚数据库")
}
if server.config.Admin.PasswordHash != oldHash {
t.Fatal("失败时更新了内存密码")
}
if scenario != "rollback" {
assertLocalResetUnchanged(t, server, oldHash)
}
if scenario == "database" || scenario == "cancel-after-database" {
persisted, err := config.Load(server.configPath)
if err != nil || persisted.Admin.PasswordHash != oldHash {
t.Fatal("失败时更改了配置密码")
}
}
})
}
}
func TestLocalResetConcurrentVerifiedSessionsOnlyOneCommits(t *testing.T) {
server := newLocalResetTestServer(t)
first := preparedLocalReset(t, server)
second := preparedLocalReset(t, server)
var successes atomic.Int32
var wait sync.WaitGroup
for _, session := range []*localResetSession{first, second} {
wait.Add(1)
go func(session *localResetSession) {
defer wait.Done()
if err := session.Reset(t.Context(), "NewPass123!"); err == nil {
successes.Add(1)
}
}(session)
}
wait.Wait()
if successes.Load() != 1 {
t.Fatal("并发重置没有保证单次提交")
}
}
func TestLocalResetFailureConsumesVerification(t *testing.T) {
for _, scenario := range []string{"weak-password", "config-conflict", "locked"} {
t.Run(scenario, func(t *testing.T) {
server := newLocalResetTestServer(t)
oldHash := server.config.Admin.PasswordHash
session := preparedLocalReset(t, server)
password := "NewPass123!"
switch scenario {
case "weak-password":
password = "short"
case "config-conflict":
cfg, err := config.Load(server.configPath)
if err != nil {
t.Fatal(err)
}
cfg.Admin.Email = "changed@example.test"
if err := cfg.Save(server.configPath); err != nil {
t.Fatal(err)
}
case "locked":
server.tlsMu.Lock()
defer server.tlsMu.Unlock()
}
if err := session.Reset(t.Context(), password); err == nil {
t.Fatal("失败条件被忽略")
}
if err := session.Reset(t.Context(), "NextPass123!"); err == nil {
t.Fatal("失败后允许重用验证码")
}
assertLocalResetUnchanged(t, server, oldHash)
})
}
}
func TestLocalResetSendFailureConsumesSession(t *testing.T) {
server := newLocalResetTestServer(t)
var code string
session, err := server.localResetFactory(func(_ context.Context, _, value string) error {
code = value
return errors.New("injected-send-failure")
})(t.Context(), localreset.Peer{})
if err != nil {
t.Fatal(err)
}
defer session.Close()
if err := session.Select(t.Context(), "email"); err == nil {
t.Fatal("发送失败被忽略")
}
if err := session.Verify(t.Context(), code); err == nil {
t.Fatal("发送失败仍可验证")
}
if err := session.Select(t.Context(), "email"); err == nil {
t.Fatal("失败会话被重新使用")
}
if session.(*localResetSession).codeHash == sha256.Sum256([]byte(code)) {
t.Fatal("失败验证没有清理摘要")
}
}

View File

@ -0,0 +1,146 @@
package notification
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestSecurityCodeHTTPRecipientIsolation(t *testing.T) {
received := make(chan mailPayload, 1)
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload mailPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
received <- payload
w.WriteHeader(http.StatusNoContent)
}))
defer endpoint.Close()
cfg := newTestConfig(endpoint.URL)
cfg.Recipients = []string{"unrelated@example.test"}
service := New(cfg, nil)
if err := service.SendSecurityCode(t.Context(), "bound@example.test", "01234567"); err != nil {
t.Fatal(err)
}
payload := <-received
if len(payload.To) != 1 || payload.To[0].Email != "bound@example.test" || len(payload.CC) != 0 || len(payload.BCC) != 0 {
t.Fatal("验证码只能发送到绑定邮箱")
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "unrelated@example.test") || !strings.Contains(payload.Text, "01234567") || !strings.Contains(payload.HTML, "01234567") {
t.Fatal("验证码内容或收件人隔离错误")
}
}
func TestSecurityCodeRejectsInvalidInputWithoutSending(t *testing.T) {
var calls atomic.Int32
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer endpoint.Close()
for _, tc := range []struct{ name, recipient, code string }{
{"short", "bound@example.test", "1234567"},
{"long", "bound@example.test", "123456789"},
{"letters", "bound@example.test", "1234567a"},
{"space", "bound@example.test", "1234567 "},
{"empty", "", "12345678"},
{"multiple", "a@example.test,b@example.test", "12345678"},
{"display-name", "Admin <a@example.test>", "12345678"},
{"header-injection", "a@example.test\r\nBcc: b@example.test", "12345678"},
} {
t.Run(tc.name, func(t *testing.T) {
if err := New(newTestConfig(endpoint.URL), nil).SendSecurityCode(t.Context(), tc.recipient, tc.code); err == nil {
t.Fatal("无效验证码或收件人被接受")
}
})
}
if calls.Load() != 0 {
t.Fatal("校验失败仍然发送了邮件")
}
}
func TestSecurityCodeRequiresConfiguredChannel(t *testing.T) {
cfg := newTestConfig("")
cfg.HTTPEnabled = false
for _, service := range []*Service{nil, New(cfg, nil)} {
if err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678"); err == nil {
t.Fatal("未配置邮件通道不能报告成功")
}
}
}
func TestSecurityCodeCancellationAndFailureDoNotLeak(t *testing.T) {
var calls atomic.Int32
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("12345678 sensitive-upstream-detail"))
}))
defer endpoint.Close()
service := New(newTestConfig(endpoint.URL), nil)
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := service.SendSecurityCode(ctx, "bound@example.test", "12345678"); !errors.Is(err, context.Canceled) {
t.Fatal("已取消的发送必须立即失败")
}
if calls.Load() != 0 {
t.Fatal("取消后仍发起网络请求")
}
err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678")
if err == nil || strings.Contains(err.Error(), "12345678") || strings.Contains(err.Error(), "sensitive-upstream-detail") {
t.Fatal("发送失败泄露了验证码或上游响应")
}
}
func TestSecurityCodeSMTPAndHTTPFallback(t *testing.T) {
for _, fallback := range []bool{false, true} {
name := "smtp-only"
if fallback {
name = "http-fallback"
}
t.Run(name, func(t *testing.T) {
smtp := newSMTPTestServer(t, true, false)
host, port := smtp.address()
cfg := smtpTestConfig(host, port, "implicit_tls")
var calls atomic.Int32
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer endpoint.Close()
cfg.HTTPEnabled = fallback
cfg.Endpoint = endpoint.URL
cfg.HTTPPath = "/api/email/send"
cfg.HTTPAuthMode = "none"
service := New(cfg, credentialReaderStub{values: map[string]string{"username": "user", "password": "test-password"}})
service.smtpTLSConfig = smtp.clientTLSConfig
if err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678"); err != nil {
t.Fatal(err)
}
smtp.mu.Lock()
defer smtp.mu.Unlock()
if len(smtp.messages) != 1 {
t.Fatal("验证码邮件发送数量错误")
}
message := string(smtp.messages[0])
if !strings.Contains(message, "bound@example.test") || strings.Contains(message, "receiver@example.com") {
t.Fatal("备用通道未保持绑定邮箱隔离")
}
if fallback && calls.Load() != 1 {
t.Fatal("未先尝试配置的 HTTP 通道")
}
})
}
}