This commit is contained in:
parent
b5a3e601c7
commit
8899e74a66
@ -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 表示收件人的帐户名称。
|
||||
|
||||
49
backend/internal/gopop/revocation_test.go
Normal file
49
backend/internal/gopop/revocation_test.go
Normal 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("撤销后仍提交延迟删除")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -25,4 +25,5 @@ type Session struct {
|
||||
Conn net.Conn
|
||||
InTls bool
|
||||
AliveTime time.Time
|
||||
Authorize func() error
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
155
backend/listen/imap_server/session_authorization.go
Normal file
155
backend/listen/imap_server/session_authorization.go
Normal 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)
|
||||
}
|
||||
67
backend/listen/imap_server/session_authorization_test.go
Normal file
67
backend/listen/imap_server/session_authorization_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
81
backend/listen/smtp_server/protocol_revocation_test.go
Normal file
81
backend/listen/smtp_server/protocol_revocation_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
50
backend/services/auth/protocol_session.go
Normal file
50
backend/services/auth/protocol_session.go
Normal 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
|
||||
}
|
||||
118
backend/services/auth/protocol_session_test.go
Normal file
118
backend/services/auth/protocol_session_test.go
Normal 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("被拒绝的连接不应恢复授权")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
33
backend/services/credentials/protocol_grant.go
Normal file
33
backend/services/credentials/protocol_grant.go
Normal 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
|
||||
}
|
||||
91
backend/services/credentials/protocol_grant_test.go
Normal file
91
backend/services/credentials/protocol_grant_test.go
Normal 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("客户端撤销范围不正确")
|
||||
}
|
||||
}
|
||||
66
test/deployment/test_runtime_revocation.py
Normal file
66
test/deployment/test_runtime_revocation.py
Normal 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()
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user