diff --git a/backend/controllers/password_capacity_test.go b/backend/controllers/password_capacity_test.go index e7b7354..a9a9826 100644 --- a/backend/controllers/password_capacity_test.go +++ b/backend/controllers/password_capacity_test.go @@ -1,6 +1,7 @@ package controllers import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -84,3 +85,46 @@ func TestPasswordBusyResponsePreservesOtherErrors(t *testing.T) { t.Fatal("包装后的繁忙错误未被识别") } } + +func TestPublicRecoveryBusyHTTPAllowsRetry(t *testing.T) { + enablePublicRecoveryForTest(t) + recoveryService, _, _, user, _ := newPublicSecurityFixture(t) + var token string + recoveryService.SetPublicLinkDelivery(func(_ string, issued string) error { token = issued; return nil }) + if err := recoveryService.BeginPublicLink(user.Account); err != nil { + t.Fatal(err) + } + previous := currentSecurityRuntime() + ConfigureSecurityRuntime(previous.verification, previous.external, recoveryService, previous.credentials) + t.Cleanup(func() { + ConfigureSecurityRuntime(previous.verification, previous.external, previous.recovery, previous.credentials) + }) + body, err := json.Marshal(map[string]string{"token": token, "new_password": "NewPass123"}) + if err != nil { + t.Fatal(err) + } + invoke := func() *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPost, "https://example.com/rest/api/recovery/complete", strings.NewReader(string(body))) + recorder := httptest.NewRecorder() + CompletePublicLinkPasswordRecovery(recorder, request) + return recorder + } + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + recorder := invoke() + var result map[string]bool + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil || len(result) != 1 || result["reset"] { + t.Fatal("匿名恢复繁忙响应泄露额外状态或错误报告成功") + } + if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" || recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("匿名恢复繁忙响应不可重试:%d", recorder.Code) + } + } + release() + if recorder := invoke(); recorder.Code != http.StatusOK { + t.Fatalf("繁忙结束后链接无法重试:%d", recorder.Code) + } + if recorder := invoke(); recorder.Code != http.StatusBadRequest { + t.Fatalf("恢复链接可被重复使用:%d", recorder.Code) + } +} diff --git a/backend/listen/imap_server/capacity_test.go b/backend/listen/imap_server/capacity_test.go new file mode 100644 index 0000000..141f84c --- /dev/null +++ b/backend/listen/imap_server/capacity_test.go @@ -0,0 +1,42 @@ +package imap_server + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "os" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +func TestIMAPBusyReturnsUnavailableAndAllowsRetry(t *testing.T) { + certificate, err := os.ReadFile(config.Instance.SSLPublicKeyPath) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(certificate) { + t.Fatal("测试证书不可解析") + } + client, err := imapclient.DialTLS(imapTestAddr, &imapclient.Options{TLSConfig: &tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12}}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + var temporary *imap.Error + err := client.Login("testCase@test.domain", imapTestProtocolCode).Wait() + if !errors.As(err, &temporary) || temporary.Type != imap.StatusResponseTypeNo || temporary.Code != imap.ResponseCodeUnavailable { + t.Fatalf("IMAP 繁忙响应错误:%v", err) + } + } + release() + if err := client.Login("testCase@test.domain", imapTestProtocolCode).Wait(); err != nil { + t.Fatalf("IMAP 繁忙恢复后无法认证:%v", err) + } +} diff --git a/backend/listen/pop3_server/capacity_test.go b/backend/listen/pop3_server/capacity_test.go new file mode 100644 index 0000000..d417a73 --- /dev/null +++ b/backend/listen/pop3_server/capacity_test.go @@ -0,0 +1,32 @@ +package pop3_server + +import ( + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/internal/gopop" + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/utils/context" +) + +func TestPOP3BusyReturnsTemporaryAuthenticationFailure(t *testing.T) { + engine, user, code := newPOP3ProtocolAuthFixture(t) + oldDB, oldConfig := db.Instance, config.Instance + db.Instance, config.Instance = engine, &config.Config{Domain: "example.com"} + t.Cleanup(func() { db.Instance, config.Instance = oldDB, oldConfig }) + ctx := &context.Context{} + session := &gopop.Session{User: "alice@example.com", InTls: true, Ctx: ctx} + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + err := (action{}).Pass(session, code) + if err == nil || !strings.HasPrefix(err.Error(), "[SYS/TEMP]") || session.Status == gopop.TRANSACTION || ctx.UserID != 0 { + t.Fatalf("POP3 繁忙响应或会话状态错误:%v", err) + } + } + release() + if err := (action{}).Pass(session, code); err != nil || ctx.UserID != user.ID { + t.Fatalf("POP3 繁忙恢复后无法认证:%v", err) + } +} diff --git a/backend/listen/smtp_server/capacity_test.go b/backend/listen/smtp_server/capacity_test.go new file mode 100644 index 0000000..5d0ac2a --- /dev/null +++ b/backend/listen/smtp_server/capacity_test.go @@ -0,0 +1,33 @@ +package smtp_server + +import ( + "errors" + "net" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/utils/context" + "github.com/emersion/go-smtp" +) + +func TestSMTPBusyReturnsTemporaryAuthenticationFailure(t *testing.T) { + engine, user, code := newSMTPProtocolAuthFixture(t) + oldDB, oldConfig := db.Instance, config.Instance + db.Instance, config.Instance = engine, &config.Config{Domain: "example.com"} + t.Cleanup(func() { db.Instance, config.Instance = oldDB, oldConfig }) + session := &Session{RemoteAddress: &net.TCPAddr{IP: net.ParseIP("192.0.2.81"), Port: 465}, Ctx: &context.Context{}} + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + var temporary *smtp.SMTPError + err := session.AuthPlain("alice@example.com", code) + if !errors.As(err, &temporary) || temporary.Code != 454 || temporary.EnhancedCode != (smtp.EnhancedCode{4, 7, 0}) || session.User != "" { + t.Fatalf("SMTP 繁忙响应或会话状态错误:%v", err) + } + } + release() + if err := session.AuthPlain("alice@example.com", code); err != nil || session.User != user.Account { + t.Fatalf("SMTP 繁忙恢复后无法认证:%v", err) + } +} diff --git a/backend/services/credentials/client_race_test.go b/backend/services/credentials/client_race_test.go new file mode 100644 index 0000000..542e2e3 --- /dev/null +++ b/backend/services/credentials/client_race_test.go @@ -0,0 +1,52 @@ +package credentials + +import ( + "testing" + "time" + + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/password" +) + +func TestClientCodeRejectsStateChangedDuringVerification(t *testing.T) { + for _, change := range []string{"revoke", "rotate"} { + t.Run(change, func(t *testing.T) { + service := newFixture(t) + if err := service.engine.Sync2(&models.ClientCredential{}); err != nil { + t.Fatal(err) + } + code, item, err := service.CreateClientCredential(1, "撤销竞态验收") + if err != nil { + t.Fatal(err) + } + const replacement = "ABCD1234EFGH5678" + hash, err := password.Hash(replacement) + if err != nil { + t.Fatal(err) + } + now := service.now + changed := false + // 在哈希匹配之后、更新最后使用时间之前注入已完成的撤销或换码。 + service.now = func() time.Time { + service.now = now + changed = true + if change == "revoke" { + if err := service.RevokeClientCredential(1, item.ID); err != nil { + t.Fatal(err) + } + } else if _, err := service.engine.ID(item.ID).Cols("code_hash", "code_version").Update(&models.ClientCredential{CodeHash: hash, CodeVersion: item.CodeVersion + 1}); err != nil { + t.Fatal(err) + } + return now() + } + if matched, err := service.CheckClientOrProtocolCode(1, ProtocolSMTP, code); matched || err != nil || !changed { + t.Fatalf("已失效授权码通过认证:匹配=%v,状态已变=%v,错误=%v", matched, changed, err) + } + if change == "rotate" { + if matched, err := service.CheckClientOrProtocolCode(1, ProtocolSMTP, replacement); !matched || err != nil { + t.Fatalf("新授权码不可用:%v", err) + } + } + }) + } +} diff --git a/backend/services/recovery/capacity_test.go b/backend/services/recovery/capacity_test.go index cce8897..84d6fec 100644 --- a/backend/services/recovery/capacity_test.go +++ b/backend/services/recovery/capacity_test.go @@ -3,8 +3,12 @@ package recovery import ( "errors" "testing" + "time" "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/verification" "git.pcux.cn/xuans/Smail/utils/password" ) @@ -45,3 +49,138 @@ func TestRecoveryBusyDoesNotRestoreRevokedTicket(t *testing.T) { t.Fatalf("撤销的票据被恢复:%v", err) } } + +func TestEmailContinuationBusyPreservesAttemptsAndCode(t *testing.T) { + service, verify, engine, user := newRecoveryFixture(t) + user.ApplyRole(access.RoleAdmin) + if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil { + t.Fatal(err) + } + codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5) + if err != nil { + t.Fatal(err) + } + var code string + verify.Send = func(_ verification.Challenge, issued string) error { code = issued; return nil } + challenge, err := service.Begin(BeginRequest{Identifier: user.Account, Email: "alice@example.com", BrowserContext: "capacity-browser"}, "192.0.2.80") + if err != nil { + t.Fatal(err) + } + result, err := service.VerifyEmailStepWithContext(challenge, user.Account, "alice@example.com", code, "192.0.2.80", "capacity-browser") + if err != nil || !result.RequiresRecoveryCode || result.Continuation == "" { + t.Fatalf("管理员邮箱验证未生成中间凭证:%v", err) + } + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + if _, err := service.ContinueEmailWithContext(result.Continuation, user.Account, "alice@example.com", codes[0], "capacity-browser"); !errors.Is(err, password.ErrBusy) { + t.Fatalf("繁忙时中间凭证不可重试:%v", err) + } + } + pending := service.emailPending[ticketKey(result.Continuation)] + if pending == nil || pending.Busy || pending.Attempts != 0 { + t.Fatal("繁忙误计失败次数或未释放中间凭证") + } + release() + ticketID, err := service.ContinueEmailWithContext(result.Continuation, user.Account, "alice@example.com", codes[0], "capacity-browser") + if err != nil || ticketID == "" { + t.Fatalf("繁忙结束后无法继续恢复:%v", err) + } + if _, err := service.ContinueEmailWithContext(result.Continuation, user.Account, "alice@example.com", codes[0], "capacity-browser"); !errors.Is(err, ErrRecoveryExpired) { + t.Fatalf("中间凭证被重复使用:%v", err) + } + if err := service.CompleteWithContext(ticketID, "NewPass123", "capacity-browser"); err != nil { + t.Fatal(err) + } +} + +func TestPublicLinkBusyPreservesAttemptsAndSingleUse(t *testing.T) { + for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} { + t.Run(string(role), func(t *testing.T) { + service, _, engine, user := newRecoveryFixture(t) + user.ApplyRole(role) + if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil { + t.Fatal(err) + } + var recoveryCode string + if role == access.RoleAdmin { + codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5) + if err != nil { + t.Fatal(err) + } + recoveryCode = codes[0] + } + var token string + service.SetPublicLinkDelivery(func(_ string, issued string) error { token = issued; return nil }) + if err := service.BeginPublicLink(user.Account); err != nil || token == "" { + t.Fatalf("未生成恢复链接:%v", err) + } + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + if err := service.CompletePublicLink(token, "NewPass123", recoveryCode); !errors.Is(err, password.ErrBusy) { + t.Fatalf("繁忙时链接不可重试:%v", err) + } + } + pending := service.publicTickets[ticketKey(token)] + if pending == nil || pending.Busy || pending.Attempts != 0 { + t.Fatal("繁忙误计失败次数或未释放链接") + } + release() + if err := service.CompletePublicLink(token, "NewPass123", recoveryCode); err != nil { + t.Fatal(err) + } + if err := service.CompletePublicLink(token, "NextPass12", recoveryCode); !errors.Is(err, ErrRecoveryExpired) { + t.Fatalf("恢复链接被重复使用:%v", err) + } + var stored models.User + if found, err := engine.ID(user.ID).Get(&stored); err != nil || !found || stored.SessionVersion != user.SessionVersion+1 { + t.Fatalf("重置未撤销旧会话版本:%v", err) + } + if matched, err := password.Check(stored.Password, "NewPass123"); !matched || err != nil { + t.Fatalf("新密码不可用:%v", err) + } + }) + } +} + +func TestPublicLinkBusyDuringCodeCheckRollsBack(t *testing.T) { + service, _, engine, user := newRecoveryFixture(t) + user.ApplyRole(access.RoleAdmin) + if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil { + t.Fatal(err) + } + codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5) + if err != nil { + t.Fatal(err) + } + var token string + service.SetPublicLinkDelivery(func(_ string, issued string) error { token = issued; return nil }) + if err := service.BeginPublicLink(user.Account); err != nil { + t.Fatal(err) + } + now, calls := service.now, 0 + var release func() + // 第二次读取时钟位于新密码哈希之后、恢复码事务之前。 + service.now = func() time.Time { + calls++ + if calls == 2 { + release = passwordtest.Hold(t) + } + return now() + } + if err := service.CompletePublicLink(token, "NewPass123", codes[0]); !errors.Is(err, password.ErrBusy) || release == nil { + t.Fatalf("未覆盖事务内恢复码繁忙分支:%v", err) + } + service.now = now + release() + pending := service.publicTickets[ticketKey(token)] + if pending == nil || pending.Busy || pending.Attempts != 0 { + t.Fatal("恢复码繁忙错误地消耗了链接尝试次数") + } + var stored models.User + if found, err := engine.ID(user.ID).Get(&stored); err != nil || !found || stored.Password != user.Password || stored.SessionVersion != user.SessionVersion { + t.Fatalf("繁忙事务未保持原密码和会话版本:%v", err) + } + if err := service.CompletePublicLink(token, "NewPass123", codes[0]); err != nil { + t.Fatalf("事务繁忙后同一链接及恢复码不可用:%v", err) + } +} diff --git a/backend/services/recovery/service.go b/backend/services/recovery/service.go index 08f2645..9706e92 100644 --- a/backend/services/recovery/service.go +++ b/backend/services/recovery/service.go @@ -360,7 +360,7 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri } if item.Busy { s.mu.Unlock() - return ErrRecoveryDenied + return ErrRecoveryExpired } if item.HasBrowserContextDigest { providedDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))