This commit is contained in:
parent
b4f5f51c0e
commit
ce8285f7cc
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
42
backend/listen/imap_server/capacity_test.go
Normal file
42
backend/listen/imap_server/capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
backend/listen/pop3_server/capacity_test.go
Normal file
32
backend/listen/pop3_server/capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
33
backend/listen/smtp_server/capacity_test.go
Normal file
33
backend/listen/smtp_server/capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
backend/services/credentials/client_race_test.go
Normal file
52
backend/services/credentials/client_race_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user