修复邮件授权码撤销后旧协议连接仍可操作并补充回归验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 01:57:23 +08:00
parent b5a3e601c7
commit 8899e74a66
23 changed files with 918 additions and 21 deletions

View File

@ -176,6 +176,11 @@ func (s *Server) handleClient(conn net.Conn) {
cmd, args := getCommand(rawLine)
slog.Debug("pop3 command", "cmd", cmd)
var cmdError error
// 撤销后的事务连接立即终止,不执行 QUIT 的延迟删除或任何后续命令。
if session.Status == TRANSACTION && session.Authorize != nil && session.Authorize() != nil {
fmt.Fprint(conn, "-ERR authorization revoked"+eol)
return
}
/*
user<SP>username<CRLF> user 命令是POP3客户端程序与POP3邮件服务器建立连接后通常发送的第一条命令,参数 username 表示收件人的帐户名称。

View File

@ -0,0 +1,49 @@
package gopop
import (
"bufio"
"errors"
"net"
"testing"
"time"
)
type revokedAction struct{ securityTestAction }
func (a *revokedAction) Pass(session *Session, password string) error {
session.Status = TRANSACTION
session.Authorize = func() error { return errors.New("revoked") }
return nil
}
func TestRevokedSessionRejectsCommandsWithoutQuitCommit(t *testing.T) {
for _, command := range []string{"STAT", "LIST", "UIDL", "TOP 1 1", "RETR 1", "DELE 1", "RSET", "QUIT", "NOOP"} {
t.Run(command, func(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
if err := clientConn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
action := &revokedAction{}
server := &Server{Domain: "test", Action: action}
done := make(chan struct{})
go func() { server.handleClient(serverConn); close(done) }()
reader := bufio.NewReader(clientConn)
readResponseLine(t, reader, "+OK")
writeCommand(t, clientConn, "USER user")
readResponseLine(t, reader, "+OK")
writeCommand(t, clientConn, "PASS test-password")
readResponseLine(t, reader, "+OK")
writeCommand(t, clientConn, command)
readResponseLine(t, reader, "-ERR authorization revoked")
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("已撤销的连接未关闭")
}
if action.quitCalls != 0 {
t.Fatal("撤销后仍提交延迟删除")
}
})
}
}

View File

@ -25,4 +25,5 @@ type Session struct {
Conn net.Conn
InTls bool
AliveTime time.Time
Authorize func() error
}

View File

@ -1059,6 +1059,17 @@ func TestIdleNotice(t *testing.T) {
}
ctx := &pcontext.Context{UserID: user.ID}
// IDLE 的继续响应先于应用回调注册,必须等待注册完成再验证主动通知。
deadline := time.Now().Add(2 * time.Second)
for {
if _, registered := userConnects.Load(user.ID); registered {
break
}
if time.Now().After(deadline) {
t.Fatal("IDLE 连接未完成通知注册")
}
time.Sleep(time.Millisecond)
}
if err = IdleNotice(ctx, user.ID, &models.Email{Id: 1}); err != nil {
t.Fatal(err)
}

View File

@ -15,6 +15,7 @@ import (
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/group"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/id"
@ -58,6 +59,7 @@ type serverSession struct {
remoteAddress string
mailboxMu sync.Mutex
mailboxUIDs []int
authorization *auth.ProtocolSession
}
// NewSession creates a new IMAP session.
@ -69,12 +71,12 @@ func (s *Server) NewSession(remoteAddresses ...net.Addr) imapserver.Session {
remoteAddress = remoteAddresses[0].String()
}
return &serverSession{
return &authorizedSession{serverSession: &serverSession{
server: s,
ctx: tc,
connectTime: time.Now(),
remoteAddress: remoteAddress,
}
}}
}
func (s *serverSession) Close() error {

View File

@ -0,0 +1,155 @@
package imap_server
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
// authorizedSession 是网络会话的统一授权边界;业务实现不持有明文授权码。
type authorizedSession struct {
*serverSession
}
var _ imapserver.Session = (*authorizedSession)(nil)
func protocolAuthorizationError() error {
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeAuthenticationFailed, Text: "Authentication credentials revoked"}
}
func (s *authorizedSession) check() error {
if s.authorization == nil || s.authorization.Check() != nil {
return protocolAuthorizationError()
}
return nil
}
func (s *authorizedSession) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) {
if err := s.check(); err != nil {
return nil, err
}
return s.serverSession.Select(mailbox, options)
}
func (s *authorizedSession) Create(mailbox string, options *imap.CreateOptions) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Create(mailbox, options)
}
func (s *authorizedSession) Delete(mailbox string) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Delete(mailbox)
}
func (s *authorizedSession) Rename(mailbox, newName string) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Rename(mailbox, newName)
}
func (s *authorizedSession) Subscribe(mailbox string) error {
return s.check()
}
func (s *authorizedSession) Unsubscribe(mailbox string) error {
return s.check()
}
func (s *authorizedSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.List(w, ref, patterns, options)
}
func (s *authorizedSession) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) {
if err := s.check(); err != nil {
return nil, err
}
return s.serverSession.Status(mailbox, options)
}
func (s *authorizedSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
if err := s.check(); err != nil {
return nil, err
}
return s.serverSession.Append(mailbox, r, options)
}
func (s *authorizedSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Expunge(w, uids)
}
func (s *authorizedSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
if err := s.check(); err != nil {
return nil, err
}
return s.serverSession.Search(kind, criteria, options)
}
func (s *authorizedSession) Fetch(w *imapserver.FetchWriter, numbers imap.NumSet, options *imap.FetchOptions) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Fetch(w, numbers, options)
}
func (s *authorizedSession) Store(w *imapserver.FetchWriter, numbers imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Store(w, numbers, flags, options)
}
func (s *authorizedSession) Copy(numbers imap.NumSet, dest string) (*imap.CopyData, error) {
if err := s.check(); err != nil {
return nil, err
}
return s.serverSession.Copy(numbers, dest)
}
func (s *authorizedSession) Move(w *imapserver.MoveWriter, numbers imap.NumSet, dest string) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Move(w, numbers, dest)
}
func (s *authorizedSession) Namespace() (*imap.NamespaceData, error) {
if err := s.check(); err != nil {
return nil, err
}
return s.serverSession.Namespace()
}
func (s *authorizedSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Idle(w, stop)
}
func (s *authorizedSession) Unselect() error {
if err := s.check(); err != nil {
return err
}
return s.serverSession.Unselect()
}
func (s *authorizedSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
// 库在未登录状态也调用 Poll,此时不得触及邮箱数据。
if s.authorization == nil {
return nil
}
if err := s.check(); err != nil {
return err
}
return s.serverSession.Poll(w, allowExpunge)
}

View File

@ -0,0 +1,67 @@
package imap_server
import (
"errors"
"testing"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
func TestIMAPSessionGuardsAllDataCommands(t *testing.T) {
var user models.User
if has, err := db.Instance.Where("account = ?", "testCase").Get(&user); err != nil || !has {
t.Fatalf("读取测试用户失败:%v", err)
}
service, err := credentials.NewService(db.Instance)
if err != nil {
t.Fatal(err)
}
code, item, err := service.CreateClientCredential(user.ID, "IMAP 撤销验收")
if err != nil {
t.Fatal(err)
}
s := NewServer().NewSession().(*authorizedSession)
if err := s.Login("testCase@test.domain", code); err != nil {
t.Fatal(err)
}
if _, err := s.Select("INBOX", &imap.SelectOptions{}); err != nil {
t.Fatal(err)
}
if err := service.RevokeClientCredential(user.ID, item.ID); err != nil {
t.Fatal(err)
}
checks := map[string]func() error{
"select": func() error { _, err := s.Select("INBOX", nil); return err },
"create": func() error { return s.Create("blocked", nil) },
"delete": func() error { return s.Delete("INBOX") },
"rename": func() error { return s.Rename("INBOX", "blocked") },
"subscribe": func() error { return s.Subscribe("INBOX") },
"unsubscribe": func() error { return s.Unsubscribe("INBOX") },
"list": func() error { return s.List(nil, "", nil, nil) },
"status": func() error { _, err := s.Status("INBOX", nil); return err },
"append": func() error { _, err := s.Append("INBOX", nil, nil); return err },
"expunge": func() error { return s.Expunge(nil, nil) },
"search": func() error { _, err := s.Search(imapserver.NumKindUID, nil, nil); return err },
"fetch": func() error { return s.Fetch(nil, nil, nil) },
"store": func() error { return s.Store(nil, nil, nil, nil) },
"copy": func() error { _, err := s.Copy(nil, "INBOX"); return err },
"move": func() error { return s.Move(nil, nil, "INBOX") },
"namespace": func() error { _, err := s.Namespace(); return err },
"idle": func() error { return s.Idle(nil, nil) },
"unselect": s.Unselect,
"poll": func() error { return s.Poll(nil, true) },
"idle_poll": func() error { return s.serverSession.poll(nil, true, true) },
}
for name, check := range checks {
t.Run(name, func(t *testing.T) {
var denied *imap.Error
if err := check(); !errors.As(err, &denied) || denied.Code != imap.ResponseCodeAuthenticationFailed {
t.Fatalf("撤销后仍执行数据命令:%v", err)
}
})
}
}

View File

@ -88,6 +88,10 @@ func IdleNotice(ctx *context.Context, userId int, email *models.Email) error {
for _, connect := range connects.writers {
if connect.session != nil {
// 已撤销的监听连接不再收到通知,也不能阻断其他收件连接的投递。
if connect.session.authorization != nil && connect.session.authorization.Check() != nil {
continue
}
// 同步变化和显式总数在同一锁内只写一次,避免投递时重复通知。
if err := connect.session.poll(connect.writer, true, true); err != nil {
return err

View File

@ -11,7 +11,7 @@ import (
)
func (s *serverSession) Login(username, pwd string) error {
user, err := auth.AuthenticateProtocol("imap", s.remoteAddress, username, pwd)
user, authorization, err := auth.AuthenticateProtocolSession("imap", s.remoteAddress, username, pwd)
if err != nil {
if errors.Is(err, password.ErrBusy) {
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeUnavailable, Text: "Authentication temporarily unavailable"}
@ -29,6 +29,7 @@ func (s *serverSession) Login(username, pwd string) error {
}
s.status = AUTHORIZED
s.authorization = authorization
s.ctx.UserID = user.ID
s.ctx.UserName = user.Name
s.ctx.UserAccount = user.Account

View File

@ -12,6 +12,9 @@ func (s *serverSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) erro
}
func (s *serverSession) poll(w *imapserver.UpdateWriter, allowExpunge, forceExists bool) error {
if s.authorization != nil && s.authorization.Check() != nil {
return protocolAuthorizationError()
}
s.mailboxMu.Lock()
defer s.mailboxMu.Unlock()
if s.currentMailbox == "" {

View File

@ -104,7 +104,7 @@ func (a action) Pass(session *gopop.Session, pwd string) error {
if session.Conn != nil && session.Conn.RemoteAddr() != nil {
remoteAddress = session.Conn.RemoteAddr().String()
}
user, err := auth.AuthenticateProtocol("pop3", remoteAddress, session.User, pwd)
user, authorization, err := auth.AuthenticateProtocolSession("pop3", remoteAddress, session.User, pwd)
if err != nil {
if errors2.Is(err, password.ErrBusy) {
return gopop.ErrAuthenticationTemporary
@ -118,6 +118,7 @@ func (a action) Pass(session *gopop.Session, pwd string) error {
}
ctx := session.Ctx.(*context.Context)
session.Authorize = authorization.Check
ctx.UserID = user.ID
ctx.UserName = user.Name
ctx.UserAccount = user.Account
@ -335,6 +336,11 @@ func parseMessageNumber(value string) (int, error) {
}
func (a action) ensureMessageMap(session *gopop.Session) ([]*response.EmailResponseData, error) {
if session.Authorize != nil {
if err := session.Authorize(); err != nil {
return nil, err
}
}
if session.MessageMap != nil {
return nil, nil
}
@ -404,6 +410,11 @@ func (a action) Noop(session *gopop.Session) error {
}
func (a action) Quit(session *gopop.Session) error {
if session.Authorize != nil {
if err := session.Authorize(); err != nil {
return err
}
}
log.WithContext(session.Ctx).Debugf("POP3 CMD: QUIT ")
var DelIds []int

View File

@ -36,6 +36,7 @@ type Session struct {
From string
To []string
Ctx *context.Context
authorization *auth.ProtocolSession
}
// AuthMechanisms returns a slice of available auth mechanisms
@ -63,7 +64,7 @@ func (s *Session) Auth(mech string) (sasl.Server, error) {
}
func (s *Session) AuthPlain(username, pwd string) error {
user, err := auth.AuthenticateProtocol("smtp", s.RemoteAddress.String(), username, pwd)
user, authorization, err := auth.AuthenticateProtocolSession("smtp", s.RemoteAddress.String(), username, pwd)
if err != nil {
if errors.Is(err, password.ErrBusy) {
return &smtp.SMTPError{Code: 454, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: "Temporary authentication failure"}
@ -77,6 +78,7 @@ func (s *Session) AuthPlain(username, pwd string) error {
}
s.User = user.Account
s.authorization = authorization
s.Ctx.UserAccount = user.Account
s.Ctx.UserID = user.ID
s.Ctx.UserName = user.Name
@ -86,12 +88,18 @@ func (s *Session) AuthPlain(username, pwd string) error {
return nil
}
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
if err := s.checkAuthorization(); err != nil {
return err
}
log.WithContext(s.Ctx).Debug("SMTP MAIL accepted")
s.From = from
return nil
}
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
if err := s.checkAuthorization(); err != nil {
return err
}
log.WithContext(s.Ctx).Debug("SMTP RCPT accepted")
s.To = append(s.To, to)
@ -106,3 +114,11 @@ func (s *Session) Reset() {
func (s *Session) Logout() error {
return nil
}
// 未认证的外部收信保留原流程,已认证连接不得在撤销后降级成匿名连接。
func (s *Session) checkAuthorization() error {
if s.authorization != nil && s.authorization.Check() != nil {
return &smtp.SMTPError{Code: 535, EnhancedCode: smtp.EnhancedCode{5, 7, 8}, Message: "Authentication credentials revoked"}
}
return nil
}

View File

@ -0,0 +1,81 @@
package smtp_server
import (
"errors"
"io"
"net"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/context"
"github.com/emersion/go-smtp"
)
type revocationReader struct{ revoke func() }
func (r revocationReader) Read(p []byte) (int, error) { r.revoke(); return 0, io.EOF }
func TestSMTPExistingSessionRejectsRevokedCredential(t *testing.T) {
for _, kind := range []string{"protocol", "client", "during_data"} {
t.Run(kind, func(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 })
if err := engine.Sync2(&models.ClientCredential{}); err != nil {
t.Fatal(err)
}
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
revoke := func() {
if err := service.RevokeProtocolCode(user.ID, "smtp"); err != nil {
t.Fatal(err)
}
}
if kind == "client" {
var item *credentials.ClientCredentialItem
code, item, err = service.CreateClientCredential(user.ID, "SMTP 撤销验收")
if err != nil {
t.Fatal(err)
}
revoke = func() {
if err := service.RevokeClientCredential(user.ID, item.ID); err != nil {
t.Fatal(err)
}
}
}
s := &Session{RemoteAddress: &net.TCPAddr{IP: net.ParseIP("192.0.2.85"), Port: 465}, Ctx: &context.Context{}}
if err := s.AuthPlain("alice@example.com", code); err != nil {
t.Fatal(err)
}
if err := s.Mail("alice@example.com", nil); err != nil {
t.Fatal(err)
}
if err := s.Rcpt("alice@example.com", nil); err != nil {
t.Fatal(err)
}
assertDenied := func(err error) {
t.Helper()
var denied *smtp.SMTPError
if !errors.As(err, &denied) || denied.Code != 535 {
t.Fatalf("未明确拒绝已撤销连接:%v", err)
}
}
if kind == "during_data" {
assertDenied(s.Data(revocationReader{revoke: revoke}))
} else {
revoke()
}
assertDenied(s.Mail("alice@example.com", nil))
assertDenied(s.Rcpt("alice@example.com", nil))
assertDenied(s.Data(nil))
s.Reset()
assertDenied(s.Mail("alice@example.com", nil))
})
}
}

View File

@ -41,6 +41,9 @@ import (
const DropUnknownRecipientEmails = true
func (s *Session) Data(r io.Reader) (resultErr error) {
if err := s.checkAuthorization(); err != nil {
return err
}
messageBytes := 0
var recipientIDs []int
result := "unknown"
@ -88,6 +91,10 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
if len(emailData) > maxSMTPMessageBytes {
return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "Message size exceeds fixed maximum message size"}
}
// DATA 上传期间也可能撤销授权,读取完成后必须再次复核。
if err := s.checkAuthorization(); err != nil {
return err
}
log.WithContext(ctx).Debugf("message_bytes=%d", len(emailData))

View File

@ -45,6 +45,11 @@ var (
)
func AuthenticateProtocol(protocol string, remoteAddress string, account string, plain string) (*models.User, error) {
var grant *credentials.ProtocolGrant
return authenticateProtocol(protocol, remoteAddress, account, plain, &grant)
}
func authenticateProtocol(protocol string, remoteAddress string, account string, plain string, grant **credentials.ProtocolGrant) (*models.User, error) {
normalizedProtocol, protocolErr := credentials.NormalizeProtocol(protocol)
normalizedAccount := strings.ToLower(strings.TrimSpace(account))
if normalizedIdentifier, err := NormalizeProtocolIdentifier(account); err == nil {
@ -85,11 +90,11 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string,
if err != nil {
return fail()
}
matched, err := service.CheckClientOrProtocolCode(userID, normalizedProtocol, plain)
matched, err := service.CheckClientOrProtocolGrant(userID, normalizedProtocol, plain)
if errors.Is(err, password.ErrBusy) {
return nil, err
}
if err != nil || !matched {
if err != nil || matched == nil {
return fail()
}
var user models.User
@ -99,6 +104,7 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string,
}
protocolPairAttempts.clear(pairKey)
protocolAccountAttempts.clear(accountKey)
*grant = matched
return &user, nil
}

View File

@ -0,0 +1,50 @@
package auth
import (
"errors"
"sync/atomic"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
)
// ProtocolSession 保存不可变身份与凭据快照,拒绝后的连接不能通过重新启用恢复权限。
type ProtocolSession struct {
user models.User
grant *credentials.ProtocolGrant
invalid atomic.Bool
}
func AuthenticateProtocolSession(protocol, remoteAddress, account, plain string) (*models.User, *ProtocolSession, error) {
var grant *credentials.ProtocolGrant
user, err := authenticateProtocol(protocol, remoteAddress, account, plain, &grant)
if err != nil {
return nil, nil, err
}
session := &ProtocolSession{user: *user, grant: grant}
if err := session.Check(); err != nil {
return nil, nil, err
}
return user, session, nil
}
// Check 在每条数据命令前复核授权;数据库故障或切换时也不沿用旧权限。
func (s *ProtocolSession) Check() error {
denied := errors.New("protocol authorization revoked")
if s == nil || s.invalid.Load() {
return denied
}
engine := db.Instance
if engine != nil && s.grant.Valid(engine) {
var user models.User
has, err := engine.ID(s.user.ID).Where("disabled = 0").Get(&user)
if err == nil && has && user.Account == s.user.Account && user.Password == s.user.Password &&
user.SessionVersion == s.user.SessionVersion && user.SecurityVersion == s.user.SecurityVersion &&
user.EffectiveRole() == s.user.EffectiveRole() && user.MustChangePassword == s.user.MustChangePassword && !s.invalid.Load() {
return nil
}
}
s.invalid.Store(true)
return denied
}

View File

@ -0,0 +1,118 @@
package auth
import (
"path/filepath"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"xorm.io/xorm"
)
func TestProtocolSessionRechecksUserAndCredential(t *testing.T) {
for _, kind := range []string{"protocol", "client"} {
for _, change := range []string{"revoke", "disabled", "password", "session_version", "security_version", "role", "deleted", "database_failure"} {
t.Run(kind+"/"+change, func(t *testing.T) {
oldDB, oldConfig := db.Instance, config.Instance
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "session.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close(); db.Instance, config.Instance = oldDB, oldConfig })
db.Instance = engine
config.Instance = &config.Config{Domain: "example.com"}
if err := engine.Sync2(&models.User{}, &models.UserEmail{}, &models.ClientCredential{}, &models.ProtocolCredential{}); err != nil {
t.Fatal(err)
}
user := models.User{Account: "session-user", Password: "test-hash", Role: "user", SessionVersion: 1, SecurityVersion: 1}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
address, now := "session-user@example.com", time.Now().UTC()
if _, err := engine.Insert(&models.UserEmail{UserID: user.ID, Address: address, AddressCanonical: &address, VerifiedAt: &now}); err != nil {
t.Fatal(err)
}
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
var code string
var clientID int
if kind == "protocol" {
if err := service.SetProtocolEnabled(user.ID, "smtp", true); err != nil {
t.Fatal(err)
}
code, err = service.GenerateProtocolCode(user.ID, "smtp")
} else {
var item *credentials.ClientCredentialItem
code, item, err = service.CreateClientCredential(user.ID, "会话验收")
if item != nil {
clientID = item.ID
}
}
if err != nil {
t.Fatal(err)
}
_, session, err := AuthenticateProtocolSession("smtp", "192.0.2.91:1234", address, code)
if err != nil {
t.Fatal(err)
}
// 命令复核不占用密码哈希容量,也不累加登录失败计数。
release := passwordtest.Hold(t)
if err := session.Check(); err != nil {
t.Fatal(err)
}
release()
switch change {
case "revoke":
if kind == "protocol" {
err = service.RevokeProtocolCode(user.ID, "smtp")
} else {
err = service.RevokeClientCredential(user.ID, clientID)
}
case "disabled":
_, err = engine.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1})
case "password":
_, err = engine.ID(user.ID).Cols("password").Update(&models.User{Password: "new-hash"})
case "session_version":
_, err = engine.ID(user.ID).Incr("session_version", 1).Update(&models.User{})
case "security_version":
_, err = engine.ID(user.ID).Incr("security_version", 1).Update(&models.User{})
case "role":
_, err = engine.ID(user.ID).Cols("role").Update(&models.User{Role: "admin"})
case "deleted":
_, err = engine.ID(user.ID).Delete(&models.User{})
case "database_failure":
err = engine.Close()
}
if err != nil {
t.Fatal(err)
}
var wait sync.WaitGroup
for range 4 {
wait.Add(1)
go func() {
defer wait.Done()
if session.Check() == nil {
t.Error("旧会话仍可访问数据")
}
}()
}
wait.Wait()
if change == "disabled" {
if _, err := engine.ID(user.ID).Cols("disabled").Update(&models.User{}); err != nil {
t.Fatal(err)
}
if session.Check() == nil {
t.Fatal("被拒绝的连接不应恢复授权")
}
}
})
}
}
}

View File

@ -292,36 +292,53 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla
// CheckClientOrProtocolCode 将计算过载向上返回,避免被当成密码错误累计封禁。
func (s *Service) CheckClientOrProtocolCode(userID int, protocol string, rawPlain string) (bool, error) {
grant, err := s.CheckClientOrProtocolGrant(userID, protocol, rawPlain)
return grant != nil, err
}
// CheckClientOrProtocolGrant 返回本次实际匹配的凭据快照,不保存明文授权码。
func (s *Service) CheckClientOrProtocolGrant(userID int, protocol string, rawPlain string) (*ProtocolGrant, error) {
if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 {
return false, password.CheckDummy(rawPlain)
return nil, password.CheckDummy(rawPlain)
}
cleanedPlain := CleanAuthCode(rawPlain)
if cleanedPlain == "" {
return false, password.CheckDummy(rawPlain)
return nil, password.CheckDummy(rawPlain)
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return nil, password.CheckDummy(rawPlain)
}
// 1. 优先校验客户端专属授权码
var clients []models.ClientCredential
err := s.engine.Where("user_id = ? AND revoked_at IS NULL AND code_hash != ''", userID).Find(&clients)
err = s.engine.Where("user_id = ? AND revoked_at IS NULL AND code_hash != ''", userID).Find(&clients)
if err == nil && len(clients) > 0 {
for _, client := range clients {
// 比对清洗后的统一大写无横线授权码
matched, err := password.Check(client.CodeHash, cleanedPlain)
if err != nil {
return false, err
return nil, err
}
if matched {
grant := &ProtocolGrant{engine: s.engine, userID: userID, protocol: protocol, client: true, id: client.ID, version: client.CodeVersion, hash: client.CodeHash}
now := s.now()
affected, err := s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ClientCredential{LastUsedAt: &now})
if err != nil || affected == 1 {
return err == nil, err
if err != nil {
return nil, err
}
return grant, nil
}
valid, err := s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Exist(&models.ClientCredential{})
return valid, err
if err != nil || !valid {
return nil, err
}
return grant, nil
}
}
}
@ -329,40 +346,44 @@ func (s *Service) CheckClientOrProtocolCode(userID int, protocol string, rawPlai
// 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain)
protocol, err = NormalizeProtocol(protocol)
if err != nil {
return false, password.CheckDummy(rawPlain)
return nil, password.CheckDummy(rawPlain)
}
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
if err != nil || !has || item.CodeHash == "" {
return false, password.CheckDummy(rawPlain)
return nil, password.CheckDummy(rawPlain)
}
// 兼容旧哈希直接比对原始输入,或者比对清洗后的输入
trimmedPlain := strings.TrimSpace(rawPlain)
matched, err := password.Check(item.CodeHash, trimmedPlain)
if err != nil {
return false, err
return nil, err
}
if !matched && trimmedPlain != cleanedPlain {
matched, err = password.Check(item.CodeHash, cleanedPlain)
}
if err != nil || !matched {
return false, err
return nil, err
}
grant := &ProtocolGrant{engine: s.engine, userID: userID, protocol: protocol, id: item.ID, version: item.CodeVersion, hash: item.CodeHash}
now := s.now()
affected, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
if err == nil && affected == 1 {
return true, nil
return grant, nil
}
if err != nil {
return false, err
return nil, err
}
valid, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Exist(&models.ProtocolCredential{})
return valid, err
if err != nil || !valid {
return nil, err
}
return grant, nil
}

View File

@ -113,7 +113,12 @@ func (s *Service) SetProtocolEnabled(userID int, protocol string, enabled bool)
return nil
}
item.Enabled = enabled
if _, err := s.engine.ID(item.ID).Cols("enabled", "updated_at").Update(&item); err != nil {
update := s.engine.ID(item.ID).Cols("enabled", "updated_at")
if !enabled {
// 即使关闭后立即开启且旧连接尚未发命令,也不能恢复旧会话的授权。
update = update.Incr("code_version", 1)
}
if _, err := update.Update(&item); err != nil {
return fmt.Errorf("update protocol credential: %w", err)
}
return nil

View File

@ -0,0 +1,33 @@
package credentials
import (
"git.pcux.cn/xuans/Smail/models"
"xorm.io/xorm"
)
// ProtocolGrant 绑定认证时匹配的单个凭据;命令复核不重复执行密码哈希。
// 字段不导出,避免授权码哈希进入日志、接口响应或持久化会话。
type ProtocolGrant struct {
engine *xorm.Engine
userID int
protocol string
client bool
id int
version int
hash string
}
func (g *ProtocolGrant) Valid(engine *xorm.Engine) bool {
if g == nil || engine == nil || engine != g.engine || g.id <= 0 {
return false
}
query := engine.ID(g.id).Where("user_id = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", g.userID, g.version, g.hash)
var valid bool
var err error
if g.client {
valid, err = query.Exist(&models.ClientCredential{})
} else {
valid, err = query.Where("protocol = ? AND enabled = ?", g.protocol, true).Exist(&models.ProtocolCredential{})
}
return err == nil && valid
}

View File

@ -0,0 +1,91 @@
package credentials
import (
"encoding/json"
"testing"
"git.pcux.cn/xuans/Smail/models"
)
func TestProtocolGrantTracksCredentialChanges(t *testing.T) {
for _, change := range []string{"revoke", "rotate", "disable_enable", "delete", "hash", "version", "database"} {
t.Run(change, func(t *testing.T) {
s := newFixture(t)
if err := s.SetProtocolEnabled(1, ProtocolSMTP, true); err != nil {
t.Fatal(err)
}
code, err := s.GenerateProtocolCode(1, ProtocolSMTP)
if err != nil {
t.Fatal(err)
}
grant, err := s.CheckClientOrProtocolGrant(1, ProtocolSMTP, code)
if err != nil || !grant.Valid(s.engine) {
t.Fatalf("授权快照无效:%v", err)
}
data, err := json.Marshal(grant)
if err != nil || string(data) != "{}" {
t.Fatal("授权快照泄漏内部字段")
}
switch change {
case "revoke":
err = s.RevokeProtocolCode(1, ProtocolSMTP)
case "rotate":
_, err = s.GenerateProtocolCode(1, ProtocolSMTP)
case "disable_enable":
err = s.SetProtocolEnabled(1, ProtocolSMTP, false)
if err == nil {
err = s.SetProtocolEnabled(1, ProtocolSMTP, true)
}
case "delete":
_, err = s.engine.Where("user_id = ?", 1).Delete(&models.ProtocolCredential{})
case "hash":
_, err = s.engine.Where("user_id = ?", 1).Cols("code_hash").Update(&models.ProtocolCredential{CodeHash: "replacement"})
case "version":
_, err = s.engine.Where("user_id = ?", 1).Incr("code_version", 1).Update(&models.ProtocolCredential{})
case "database":
if grant.Valid(newFixture(t).engine) {
t.Fatal("数据库切换后沿用旧授权")
}
return
}
if err != nil {
t.Fatal(err)
}
if grant.Valid(s.engine) {
t.Fatal("失效的凭据快照仍然有效")
}
})
}
}
func TestClientGrantRevocationPreservesOtherClients(t *testing.T) {
s := newFixture(t)
first, item, err := s.CreateClientCredential(1, "被撤销客户端")
if err != nil {
t.Fatal(err)
}
second, _, err := s.CreateClientCredential(1, "保留客户端")
if err != nil {
t.Fatal(err)
}
for _, protocol := range SupportedProtocols() {
grant, err := s.CheckClientOrProtocolGrant(1, protocol, first)
if err != nil || !grant.Valid(s.engine) {
t.Fatalf("客户端授权失败:%v", err)
}
}
firstGrant, err := s.CheckClientOrProtocolGrant(1, ProtocolIMAP, first)
if err != nil {
t.Fatal(err)
}
secondGrant, err := s.CheckClientOrProtocolGrant(1, ProtocolIMAP, second)
if err != nil {
t.Fatal(err)
}
if err := s.RevokeClientCredential(1, item.ID); err != nil {
t.Fatal(err)
}
if firstGrant.Valid(s.engine) || !secondGrant.Valid(s.engine) {
t.Fatal("客户端撤销范围不正确")
}
}

View File

@ -0,0 +1,66 @@
"""旧连接撤销探针必须区分新登录拒绝与存量连接失效。"""
import imaplib
import poplib
import smtplib
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
import runtime_probe
class RevocationProbeTests(unittest.TestCase):
def run_probe(self, revoked, custom=False):
smtp, new_smtp = MagicMock(), MagicMock()
imap, new_imap = MagicMock(), MagicMock()
pop, new_pop = MagicMock(), MagicMock()
client = MagicMock()
client.success.return_value = {'account': runtime_probe.ACCOUNT}
smtp.auth.return_value = (235, b'OK')
smtp.mail.side_effect = [(250, b'OK'), (535 if revoked else 250, b'result')]
smtp.rcpt.return_value = smtp.rset.return_value = (250, b'OK')
imap.login.return_value = imap.select.return_value = ('OK', [])
imap.uid.side_effect = [('OK', [(b'1', b'mail')]), ('NO' if revoked else 'OK', [])]
pop.list.return_value = (b'+OK', [b'1 123'], 0)
pop.retr.side_effect = [(b'+OK', [], 0), poplib.error_proto(b'-ERR') if revoked else (b'+OK', [], 0)]
new_smtp.auth.side_effect = smtplib.SMTPAuthenticationError(535, b'denied')
new_imap.login.side_effect = imaplib.IMAP4.error('AUTHENTICATIONFAILED')
new_pop.pass_.side_effect = poplib.error_proto(b'-ERR')
revoke = MagicMock() if custom else None
with patch.object(runtime_probe, 'smtp_connect', side_effect=[smtp, new_smtp]), \
patch.object(runtime_probe, 'LocalIMAP', side_effect=[imap, new_imap]), \
patch.object(runtime_probe, 'LocalPOP', side_effect=[pop, new_pop]):
result = runtime_probe.verify_protocol_revocation(client, None, dict.fromkeys(('smtp', 'imap', 'pop3'), 'test-code'), revoke)
if custom:
revoke.assert_called_once()
self.assertEqual(client.success.call_count, 1)
for protocol in ('smtp', 'imap', 'pop3'):
self.assertTrue(result[protocol + '_new_login_denied'])
self.assertEqual(result[protocol + '_existing_denied'], revoked)
smtp.close.assert_called_once()
imap.shutdown.assert_called_once()
pop.close.assert_called_once()
def test_new_login_rejection_does_not_hide_old_connection_access(self):
self.run_probe(False)
def test_old_and_new_connections_both_rejected(self):
self.run_probe(True)
def test_client_revocation_uses_supplied_action(self):
self.run_probe(True, custom=True)
def test_partial_connect_failure_closes_prior_connections(self):
smtp = MagicMock()
with patch.object(runtime_probe, 'smtp_connect', return_value=smtp), \
patch.object(runtime_probe, 'LocalIMAP', side_effect=OSError('connection failed')):
with self.assertRaises(OSError):
runtime_probe.verify_protocol_revocation(MagicMock(), None, {})
smtp.close.assert_called_once()
if __name__ == '__main__':
unittest.main()

View File

@ -3,6 +3,7 @@
import argparse
import base64
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from email.message import EmailMessage
from email.parser import BytesParser
from email.policy import default as email_policy
@ -274,6 +275,90 @@ def verify_restored(root, context, password, message_id, port):
print(json.dumps(result, ensure_ascii=False, indent=2))
def verify_protocol_revocation(client, context, codes, revoke=None):
# 必须保持已认证连接跨越撤销操作,重新连接失败不能代替旧连接失效验收。
results = {}
with ExitStack() as cleanup:
smtp = smtp_connect(context)
cleanup.callback(smtp.close)
imap = LocalIMAP(HOST, 993, ssl_context=context, timeout=30)
cleanup.callback(imap.shutdown)
pop = LocalPOP(HOST, 995, context=context, timeout=30)
cleanup.callback(pop.close)
assert smtp.auth('PLAIN', lambda challenge=None: '\0' + ADDRESS + '\0' + codes['smtp'])[0] == 235
assert imap.login(ADDRESS, codes['imap'])[0] == 'OK'
assert imap.select('INBOX', readonly=True)[0] == 'OK'
status, contents = imap.uid('FETCH', '1:*', '(BODY.PEEK[])')
assert status == 'OK' and any(isinstance(item, tuple) and item[1] for item in contents)
pop.user(ADDRESS)
pop.pass_(codes['pop3'])
messages = pop.list()[1]
assert messages
first = int(messages[0].split()[0])
assert pop.retr(first)[0].startswith(b'+OK')
# 不依赖外部 MX 或外发投递,直接验证旧认证连接能否开启新的邮件事务。
assert smtp.mail(ADDRESS)[0] == 250
assert smtp.rcpt(ADDRESS)[0] == 250
assert smtp.rset()[0] == 250
if revoke is None:
for protocol in codes:
client.success('/api/v1/security/credentials/protocols/revoke', {'protocol': protocol})
else:
revoke()
try:
code, _ = smtp.mail(ADDRESS)
assert code in (250, 530, 535), 'SMTP 不是成功或明确的认证拒绝'
results['smtp_existing_denied'] = code in (530, 535)
except smtplib.SMTPServerDisconnected:
results['smtp_existing_denied'] = True
try:
status, _ = imap.uid('FETCH', '1:*', '(BODY.PEEK[])')
results['imap_existing_denied'] = status in ('NO', 'BAD')
except imaplib.IMAP4.abort:
results['imap_existing_denied'] = True
try:
pop.retr(first)
results['pop3_existing_denied'] = False
except poplib.error_proto as error:
assert b'-ERR' in str(error).encode() or 'EOF' in str(error)
results['pop3_existing_denied'] = True
fresh = smtp_connect(context)
try:
try:
fresh.auth('PLAIN', lambda challenge=None: '\0' + ADDRESS + '\0' + codes['smtp'])
results['smtp_new_login_denied'] = False
except smtplib.SMTPAuthenticationError as error:
assert error.smtp_code == 535
results['smtp_new_login_denied'] = True
finally:
fresh.close()
fresh = LocalIMAP(HOST, 993, ssl_context=context, timeout=30)
try:
try:
fresh.login(ADDRESS, codes['imap'])
results['imap_new_login_denied'] = False
except imaplib.IMAP4.error as error:
assert 'AUTHENTICATIONFAILED' in str(error)
results['imap_new_login_denied'] = True
finally:
fresh.shutdown()
fresh = LocalPOP(HOST, 995, context=context, timeout=30)
try:
fresh.user(ADDRESS)
try:
fresh.pass_(codes['pop3'])
results['pop3_new_login_denied'] = False
except poplib.error_proto as error:
assert '-ERR' in str(error)
results['pop3_new_login_denied'] = True
finally:
fresh.close()
# 拒绝不能由整个服务崩溃导致。
assert client.success('/api/v1/user/info').get('account') == ACCOUNT
return results
def main():
parser = argparse.ArgumentParser()
parser.add_argument('root', type=Path)
@ -436,6 +521,15 @@ def main():
if args.proxy:
from runtime_proxy import verify_proxy_sources
result['proxy']['sources'] = verify_proxy_sources(context, login, Client)
result['stage'] = 'protocol_revocation'
result['protocol_revocation'] = verify_protocol_revocation(client, context, codes)
assert all(result['protocol_revocation'].values()), '撤销后旧协议连接仍可访问业务数据'
result['stage'] = 'client_revocation'
credential = client.success('/api/v1/security/credentials/clients/create', {'client_name': '隔离旧连接撤销验收'})
client_codes = dict.fromkeys(('smtp', 'imap', 'pop3'), credential['code'])
revoke = lambda: client.success('/api/v1/security/credentials/clients/revoke', {'client_id': credential['client']['id']})
result['client_revocation'] = verify_protocol_revocation(client, context, client_codes, revoke)
assert all(result['client_revocation'].values()), '撤销客户端授权码后旧协议连接仍可访问业务数据'
result['stage'] = 'complete'
result['passed'] = True
finally: