diff --git a/README_CN.md b/README_CN.md index ff7e064..28d2e44 100644 --- a/README_CN.md +++ b/README_CN.md @@ -203,3 +203,78 @@ PMail 原版的协议登录流程与 Smail 不兼容,不能直接作为 Smail [go to wiki](https://git.pcux.cn/xuans/Smail/wiki/%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91%E8%AF%B4%E6%98%8E) + +## 第三方协议验证方法 + +完成用户邮箱验证、协议开关和授权码生成后,可以在测试环境使用真实邮件客户端或命令行验证。以下命令中的 `alice@example.com`、服务器地址和授权码均为示例,请替换为实际值;授权码不要写入 Git、脚本仓库或公共日志。 + +### SMTP 验证 + +推荐使用 `swaks`,它可以同时验证 TLS、认证和投递流程。 + +```bash +# SMTP 465:连接即建立 SSL/TLS +swaks --server smtp.example.com --port 465 --tls-on-connect \ + --auth PLAIN \ + --auth-user 'alice@example.com' \ + --auth-password 'SMTP授权码' \ + --from 'alice@example.com' \ + --to '收件人@example.net' + +# SMTP 587:先建立普通连接,再执行 STARTTLS +swaks --server smtp.example.com --port 587 --tls \ + --auth PLAIN \ + --auth-user 'alice@example.com' \ + --auth-password 'SMTP授权码' \ + --from 'alice@example.com' \ + --to '收件人@example.net' +``` + +认证成功后,服务端应接受认证并继续处理 `MAIL FROM`、`RCPT TO` 和邮件正文;如果 587 端口在 STARTTLS 前返回认证成功,说明服务端配置不符合安全要求。 + +### IMAP 验证 + +使用支持 IMAPS 的 `curl` 验证 993 端口: + +```bash +curl --fail --silent --show-error \ + --url 'imaps://imap.example.com/INBOX' \ + --user 'alice@example.com:IMAP授权码' +``` + +能够建立 TLS 连接并返回收件箱信息,说明 IMAP 登录成功。将用户名改为 `alice`、密码改为网页登录密码,或先关闭 IMAP 开关后重新执行,均应认证失败。 + +### POP3 验证 + +使用支持 POP3S 的 `curl` 验证 995 端口: + +```bash +curl --fail --silent --show-error \ + --url 'pop3s://pop.example.com/' \ + --user 'alice@example.com:POP3授权码' \ + --list-only +``` + +能够建立 TLS 连接并返回邮件列表,说明 POP3 登录成功。普通 POP3 端口必须先完成 STLS,不能在明文连接阶段直接提交密码。 + +### 验证结果判定 + +| 测试项 | 预期结果 | +| --- | --- | +| 已开启协议 + 对应授权码 + 完整邮箱地址 | 登录成功 | +| 协议未开启 | 登录失败 | +| 已开启但尚未生成授权码 | 登录失败 | +| 授权码已撤销或重新生成 | 旧授权码登录失败,新授权码登录成功 | +| 使用网页登录密码 | 登录失败 | +| 使用短账号名(例如 `alice`) | 登录失败 | +| 使用未验证或未绑定邮箱地址 | 登录失败 | +| SMTP 587 在 STARTTLS 前认证 | 服务端拒绝认证 | +| POP3 未完成 TLS 或 STLS 就认证 | 服务端拒绝认证 | + +### 当前自动化验证范围 + +项目自动化测试已经覆盖统一协议认证服务、控制器接口、SMTP 会话认证、POP3 会话认证、IMAP 原始 `LOGIN`、完整邮箱地址、协议开关、授权码撤销、重新生成和网页登录密码拒绝。自动化测试不能替代生产环境中的 DNS、证书、防火墙、Nginx、真实邮件客户端和实际投递验收,因此部署后仍需按上面的命令执行一次真实验证。 + +### PMail 原版无法登录的结论 + +PMail 原版不能作为当前 Smail 的第三方协议登录测试客户端。它的旧登录流程会截断包含 `@` 的登录名,没有 Smail 的协议开关和授权码模型,并且按网页登录密码校验;Smail 则要求“完整邮箱地址 + 对应协议授权码”。因此,把 Smail 的授权码填入 PMail 原版后登录失败属于兼容性限制,不应通过接受短账号名或网页登录密码来规避。 diff --git a/server/listen/pop3_server/protocol_auth_test.go b/server/listen/pop3_server/protocol_auth_test.go new file mode 100644 index 0000000..0ef2f8a --- /dev/null +++ b/server/listen/pop3_server/protocol_auth_test.go @@ -0,0 +1,116 @@ +package pop3_server + +import ( + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/internal/gopop" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/utils/context" + "xorm.io/xorm" +) + +func TestActionPassUsesFullEmailAndProtocolCode(t *testing.T) { + engine, user, code := newPOP3ProtocolAuthFixture(t) + oldDB := db.Instance + oldConfig := config.Instance + db.Instance = engine + config.Instance = &config.Config{ + Domain: "example.com", + Domains: []string{"example.com"}, + } + t.Cleanup(func() { + db.Instance = oldDB + config.Instance = oldConfig + }) + + session := &gopop.Session{ + User: " Alice@Example.com ", + InTls: true, + Ctx: &context.Context{}, + } + if err := (action{}).Pass(session, code); err != nil { + t.Fatalf("POP3 full email authorization failed: %v", err) + } + if session.Status != gopop.TRANSACTION || session.Ctx.(*context.Context).UserAccount != user.Account { + t.Fatalf("POP3 session state = %v, account = %q", session.Status, session.Ctx.(*context.Context).UserAccount) + } + + shortAccountSession := &gopop.Session{ + User: user.Account, + InTls: true, + Ctx: &context.Context{}, + } + if err := (action{}).Pass(shortAccountSession, code); err == nil { + t.Fatal("POP3 short account identifier must be rejected") + } + + webPasswordSession := &gopop.Session{ + User: "alice@example.com", + InTls: true, + Ctx: &context.Context{}, + } + if err := (action{}).Pass(webPasswordSession, "WebPass1234"); err == nil { + t.Fatal("POP3 web password must be rejected") + } +} + +func TestActionPassRejectsAuthenticationBeforeTLS(t *testing.T) { + session := &gopop.Session{ + User: "alice@example.com", + InTls: false, + Ctx: &context.Context{}, + } + if err := (action{}).Pass(session, "any-code"); err == nil { + t.Fatal("POP3 authentication before TLS must be rejected") + } +} + +func newPOP3ProtocolAuthFixture(t *testing.T) (*xorm.Engine, *models.User, string) { + t.Helper() + engine, err := xorm.NewEngine("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatalf("create POP3 test database: %v", err) + } + engine.SetMaxOpenConns(1) + for _, table := range []any{&models.User{}, &models.Email{}, &models.UserEmail{}, &models.ProtocolCredential{}} { + if err := engine.Sync2(table); err != nil { + t.Fatalf("initialize POP3 test database: %v", err) + } + } + t.Cleanup(func() { _ = engine.Close() }) + + user := &models.User{Account: "alice", Name: "Alice"} + if err := auth.SetPassword(user, "WebPass1234"); err != nil { + t.Fatalf("hash POP3 test password: %v", err) + } + if _, err := engine.Insert(user); err != nil { + t.Fatalf("insert POP3 test user: %v", err) + } + canonicalAddress := "alice@example.com" + verifiedAt := time.Now().UTC() + if _, err := engine.Insert(&models.UserEmail{ + UserID: user.ID, + Address: canonicalAddress, + AddressCanonical: &canonicalAddress, + VerifiedAt: &verifiedAt, + }); err != nil { + t.Fatalf("insert POP3 test email binding: %v", err) + } + service, err := credentials.NewService(engine) + if err != nil { + t.Fatalf("create POP3 credential service: %v", err) + } + if err := service.SetProtocolEnabled(user.ID, credentials.ProtocolPOP3, true); err != nil { + t.Fatalf("enable POP3 protocol: %v", err) + } + code, err := service.GenerateProtocolCode(user.ID, credentials.ProtocolPOP3) + if err != nil { + t.Fatalf("generate POP3 protocol code: %v", err) + } + return engine, user, code +} diff --git a/server/listen/smtp_server/protocol_auth_test.go b/server/listen/smtp_server/protocol_auth_test.go new file mode 100644 index 0000000..257d9c9 --- /dev/null +++ b/server/listen/smtp_server/protocol_auth_test.go @@ -0,0 +1,102 @@ +package smtp_server + +import ( + "net" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/utils/context" + "xorm.io/xorm" +) + +func TestSessionAuthPlainUsesFullEmailAndProtocolCode(t *testing.T) { + engine, user, code := newSMTPProtocolAuthFixture(t) + oldDB := db.Instance + oldConfig := config.Instance + db.Instance = engine + config.Instance = &config.Config{ + Domain: "example.com", + Domains: []string{"example.com"}, + } + t.Cleanup(func() { + db.Instance = oldDB + config.Instance = oldConfig + }) + + session := &Session{ + RemoteAddress: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 465}, + Ctx: &context.Context{}, + } + if err := session.AuthPlain(" Alice@Example.com ", code); err != nil { + t.Fatalf("SMTP full email authorization failed: %v", err) + } + if session.User != user.Account { + t.Fatalf("SMTP session account = %q, want %q", session.User, user.Account) + } + + shortAccountSession := &Session{ + RemoteAddress: &net.TCPAddr{IP: net.ParseIP("192.0.2.21"), Port: 465}, + Ctx: &context.Context{}, + } + if err := shortAccountSession.AuthPlain(user.Account, code); err == nil { + t.Fatal("SMTP short account identifier must be rejected") + } + + webPasswordSession := &Session{ + RemoteAddress: &net.TCPAddr{IP: net.ParseIP("192.0.2.22"), Port: 465}, + Ctx: &context.Context{}, + } + if err := webPasswordSession.AuthPlain("alice@example.com", "WebPass1234"); err == nil { + t.Fatal("SMTP web password must be rejected") + } +} + +func newSMTPProtocolAuthFixture(t *testing.T) (*xorm.Engine, *models.User, string) { + t.Helper() + engine, err := xorm.NewEngine("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatalf("create SMTP test database: %v", err) + } + engine.SetMaxOpenConns(1) + for _, table := range []any{&models.User{}, &models.Email{}, &models.UserEmail{}, &models.ProtocolCredential{}} { + if err := engine.Sync2(table); err != nil { + t.Fatalf("initialize SMTP test database: %v", err) + } + } + t.Cleanup(func() { _ = engine.Close() }) + + user := &models.User{Account: "alice", Name: "Alice"} + if err := auth.SetPassword(user, "WebPass1234"); err != nil { + t.Fatalf("hash SMTP test password: %v", err) + } + if _, err := engine.Insert(user); err != nil { + t.Fatalf("insert SMTP test user: %v", err) + } + canonicalAddress := "alice@example.com" + verifiedAt := time.Now().UTC() + if _, err := engine.Insert(&models.UserEmail{ + UserID: user.ID, + Address: canonicalAddress, + AddressCanonical: &canonicalAddress, + VerifiedAt: &verifiedAt, + }); err != nil { + t.Fatalf("insert SMTP test email binding: %v", err) + } + service, err := credentials.NewService(engine) + if err != nil { + t.Fatalf("create SMTP credential service: %v", err) + } + if err := service.SetProtocolEnabled(user.ID, credentials.ProtocolSMTP, true); err != nil { + t.Fatalf("enable SMTP protocol: %v", err) + } + code, err := service.GenerateProtocolCode(user.ID, credentials.ProtocolSMTP) + if err != nil { + t.Fatalf("generate SMTP protocol code: %v", err) + } + return engine, user, code +}