保存恢复并发修复与协议回归作为前端整改检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 12:14:59 +08:00
parent b4f5f51c0e
commit ce8285f7cc
7 changed files with 343 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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