diff --git a/backend/internal/gopop/pop3.go b/backend/internal/gopop/pop3.go index 829145c..0f1b5f2 100644 --- a/backend/internal/gopop/pop3.go +++ b/backend/internal/gopop/pop3.go @@ -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 + } /* userusername user 命令是POP3客户端程序与POP3邮件服务器建立连接后通常发送的第一条命令,参数 username 表示收件人的帐户名称。 diff --git a/backend/internal/gopop/revocation_test.go b/backend/internal/gopop/revocation_test.go new file mode 100644 index 0000000..b1ebdbf --- /dev/null +++ b/backend/internal/gopop/revocation_test.go @@ -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("撤销后仍提交延迟删除") + } + }) + } +} diff --git a/backend/internal/gopop/session.go b/backend/internal/gopop/session.go index 139f13b..b47049b 100644 --- a/backend/internal/gopop/session.go +++ b/backend/internal/gopop/session.go @@ -25,4 +25,5 @@ type Session struct { Conn net.Conn InTls bool AliveTime time.Time + Authorize func() error } diff --git a/backend/listen/imap_server/imap_server_test.go b/backend/listen/imap_server/imap_server_test.go index f730f16..f37fe7a 100644 --- a/backend/listen/imap_server/imap_server_test.go +++ b/backend/listen/imap_server/imap_server_test.go @@ -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) } diff --git a/backend/listen/imap_server/server.go b/backend/listen/imap_server/server.go index b222e1b..76c3e52 100644 --- a/backend/listen/imap_server/server.go +++ b/backend/listen/imap_server/server.go @@ -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 { diff --git a/backend/listen/imap_server/session_authorization.go b/backend/listen/imap_server/session_authorization.go new file mode 100644 index 0000000..b0067d2 --- /dev/null +++ b/backend/listen/imap_server/session_authorization.go @@ -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) +} diff --git a/backend/listen/imap_server/session_authorization_test.go b/backend/listen/imap_server/session_authorization_test.go new file mode 100644 index 0000000..1c39851 --- /dev/null +++ b/backend/listen/imap_server/session_authorization_test.go @@ -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) + } + }) + } +} diff --git a/backend/listen/imap_server/session_idle.go b/backend/listen/imap_server/session_idle.go index 32df608..5b7b136 100644 --- a/backend/listen/imap_server/session_idle.go +++ b/backend/listen/imap_server/session_idle.go @@ -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 diff --git a/backend/listen/imap_server/session_login.go b/backend/listen/imap_server/session_login.go index c65f5c0..87c6f94 100644 --- a/backend/listen/imap_server/session_login.go +++ b/backend/listen/imap_server/session_login.go @@ -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 diff --git a/backend/listen/imap_server/session_poll.go b/backend/listen/imap_server/session_poll.go index 1adf830..86e106c 100644 --- a/backend/listen/imap_server/session_poll.go +++ b/backend/listen/imap_server/session_poll.go @@ -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 == "" { diff --git a/backend/listen/pop3_server/action.go b/backend/listen/pop3_server/action.go index 3cb827a..59b82dd 100644 --- a/backend/listen/pop3_server/action.go +++ b/backend/listen/pop3_server/action.go @@ -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 diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go index 36a091b..d5b350a 100644 --- a/backend/listen/smtp_server/action.go +++ b/backend/listen/smtp_server/action.go @@ -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 +} diff --git a/backend/listen/smtp_server/protocol_revocation_test.go b/backend/listen/smtp_server/protocol_revocation_test.go new file mode 100644 index 0000000..89ae53e --- /dev/null +++ b/backend/listen/smtp_server/protocol_revocation_test.go @@ -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)) + }) + } +} diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go index 9d7c5c9..f14d766 100644 --- a/backend/listen/smtp_server/read_content.go +++ b/backend/listen/smtp_server/read_content.go @@ -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)) diff --git a/backend/services/auth/protocol_limiter.go b/backend/services/auth/protocol_limiter.go index 220f163..e26bb4b 100644 --- a/backend/services/auth/protocol_limiter.go +++ b/backend/services/auth/protocol_limiter.go @@ -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 } diff --git a/backend/services/auth/protocol_session.go b/backend/services/auth/protocol_session.go new file mode 100644 index 0000000..4c93a8c --- /dev/null +++ b/backend/services/auth/protocol_session.go @@ -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 +} diff --git a/backend/services/auth/protocol_session_test.go b/backend/services/auth/protocol_session_test.go new file mode 100644 index 0000000..2dba872 --- /dev/null +++ b/backend/services/auth/protocol_session_test.go @@ -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("被拒绝的连接不应恢复授权") + } + } + }) + } + } +} diff --git a/backend/services/credentials/client.go b/backend/services/credentials/client.go index 609291a..784f87d 100644 --- a/backend/services/credentials/client.go +++ b/backend/services/credentials/client.go @@ -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 } diff --git a/backend/services/credentials/protocol.go b/backend/services/credentials/protocol.go index 84d413b..cf91358 100644 --- a/backend/services/credentials/protocol.go +++ b/backend/services/credentials/protocol.go @@ -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 diff --git a/backend/services/credentials/protocol_grant.go b/backend/services/credentials/protocol_grant.go new file mode 100644 index 0000000..28d9c68 --- /dev/null +++ b/backend/services/credentials/protocol_grant.go @@ -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 +} diff --git a/backend/services/credentials/protocol_grant_test.go b/backend/services/credentials/protocol_grant_test.go new file mode 100644 index 0000000..c18b83b --- /dev/null +++ b/backend/services/credentials/protocol_grant_test.go @@ -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("客户端撤销范围不正确") + } +} diff --git a/test/deployment/test_runtime_revocation.py b/test/deployment/test_runtime_revocation.py new file mode 100644 index 0000000..195f4c8 --- /dev/null +++ b/test/deployment/test_runtime_revocation.py @@ -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() diff --git a/test/tools/runtime_probe.py b/test/tools/runtime_probe.py index d349a4a..d2880d0 100644 --- a/test/tools/runtime_probe.py +++ b/test/tools/runtime_probe.py @@ -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: