From 038f425f2dc81146816d5c9e82976f0d2591f786 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 15 Sep 2026 23:18:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E9=82=AE=E4=BB=B6=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E8=AE=A4=E8=AF=81=E5=B9=B6=E5=8A=A0=E5=9B=BA=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/smail-admin/systemd_policy_test.go | 10 ++++++++++ backend/services/auth/auth_test.go | 6 ++++-- backend/services/auth/identifier.go | 14 ++++++++++++++ backend/services/auth/identifier_test.go | 11 +++++++++-- backend/services/auth/protocol_limiter.go | 18 +++++++----------- deploy/install-systemd.sh | 4 ++-- deploy/systemd/smail.service | 1 + 7 files changed, 47 insertions(+), 17 deletions(-) diff --git a/backend/cmd/smail-admin/systemd_policy_test.go b/backend/cmd/smail-admin/systemd_policy_test.go index 5669fd5..06c4e74 100644 --- a/backend/cmd/smail-admin/systemd_policy_test.go +++ b/backend/cmd/smail-admin/systemd_policy_test.go @@ -113,6 +113,16 @@ func TestSmailServiceUsesOnlyBindServiceCapability(t *testing.T) { t.Fatalf("smail unit is missing %q", required) } } + usesEnvironmentFile := false + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == "EnvironmentFile=/etc/smail/smail.env" { + usesEnvironmentFile = true + break + } + } + if !usesEnvironmentFile { + t.Fatal("smail unit must define EnvironmentFile as a standalone directive") + } if strings.Contains(content, "User=root") { t.Fatal("smail unit must not run as root") } diff --git a/backend/services/auth/auth_test.go b/backend/services/auth/auth_test.go index b67f07f..9d9268c 100644 --- a/backend/services/auth/auth_test.go +++ b/backend/services/auth/auth_test.go @@ -150,6 +150,7 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T if err != nil { t.Fatal(err) } + protocolCodes := make(map[string]string) for _, protocol := range []string{"smtp", "imap", "pop3"} { if err := service.SetProtocolEnabled(user.ID, protocol, true); err != nil { t.Fatalf("enable %s: %v", protocol, err) @@ -158,6 +159,7 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T if err != nil { t.Fatalf("generate %s code: %v", protocol, err) } + protocolCodes[protocol] = code got, err := AuthenticateProtocol(protocol, "192.0.2.10:12345", " Alice@Example.com ", code) if err != nil { t.Fatalf("%s full email authentication failed: %v", protocol, err) @@ -170,10 +172,10 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T } } - if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice", "CorrectP12"); err == nil { + if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice", protocolCodes["imap"]); err == nil { t.Fatal("account-only protocol login should be rejected") } - if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice@other.example", "CorrectP12"); err == nil { + if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice@other.example", protocolCodes["imap"]); err == nil { t.Fatal("unconfigured email domain should be rejected") } } diff --git a/backend/services/auth/identifier.go b/backend/services/auth/identifier.go index b6a8547..2227483 100644 --- a/backend/services/auth/identifier.go +++ b/backend/services/auth/identifier.go @@ -52,6 +52,20 @@ func NormalizeIdentifierKey(identifier string) (string, error) { return normalized, err } +// NormalizeProtocolIdentifier 仅接受属于已配置邮箱域名的完整地址,供 SMTP、IMAP、POP3 客户端认证使用。 +// Web 登录仍可使用短账号或已验证的外部邮箱,不能复用到邮件协议认证。 +func NormalizeProtocolIdentifier(identifier string) (string, error) { + normalized, kind, err := parseLoginIdentifier(identifier) + if err != nil || kind != IdentifierEmail { + return "", errors.New("invalid identifier") + } + parts := strings.SplitN(normalized, "@", 2) + if len(parts) != 2 || !isConfiguredLoginDomain(parts[1]) { + return "", errors.New("invalid identifier") + } + return normalized, nil +} + // ResolveIdentifier 将账号或已验证邮箱绑定解析为唯一用户身份。 func ResolveIdentifier(identifier string) (ResolvedIdentity, error) { normalized, kind, err := parseLoginIdentifier(identifier) diff --git a/backend/services/auth/identifier_test.go b/backend/services/auth/identifier_test.go index f7ef0d4..9398e33 100644 --- a/backend/services/auth/identifier_test.go +++ b/backend/services/auth/identifier_test.go @@ -93,7 +93,6 @@ func TestNormalizeIdentifierKeepsLegacyAccountCompatibility(t *testing.T) { } } - func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) { oldConfig := config.Instance oldDB := db.Instance @@ -106,6 +105,14 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) { if err := db.Init(""); err != nil { t.Fatal(err) } + if got, err := NormalizeProtocolIdentifier(" Bob@Solit.cn "); err != nil || got != "bob@solit.cn" { + t.Fatalf("normalized protocol identifier = %q, err = %v", got, err) + } + for _, identifier := range []string{"bob", "bob@other.com"} { + if _, err := NormalizeProtocolIdentifier(identifier); err == nil { + t.Fatalf("protocol identifier %q was accepted", identifier) + } + } t.Cleanup(func() { if db.Instance != nil && db.Instance != oldDB { _ = db.Instance.Close() @@ -141,4 +148,4 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) { if _, err := ResolveIdentifier("bob@other.com"); err == nil { t.Fatal("unconfigured domain must not be resolved") } -} \ No newline at end of file +} diff --git a/backend/services/auth/protocol_limiter.go b/backend/services/auth/protocol_limiter.go index 2630846..24f9f9c 100644 --- a/backend/services/auth/protocol_limiter.go +++ b/backend/services/auth/protocol_limiter.go @@ -1,4 +1,4 @@ -package auth +package auth import ( "crypto/sha256" @@ -46,7 +46,7 @@ var ( func AuthenticateProtocol(protocol string, remoteAddress string, account string, plain string) (*models.User, error) { normalizedProtocol, protocolErr := credentials.NormalizeProtocol(protocol) normalizedAccount := strings.ToLower(strings.TrimSpace(account)) - if normalizedIdentifier, err := NormalizeIdentifierKey(account); err == nil { + if normalizedIdentifier, err := NormalizeProtocolIdentifier(account); err == nil { normalizedAccount = normalizedIdentifier } normalizedRemote := protocolRemoteHost(remoteAddress) @@ -67,19 +67,15 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string, return fail() } - identity, err := ResolveIdentifier(account) + protocolIdentifier, err := NormalizeProtocolIdentifier(account) + if err != nil { + return fail() + } + identity, err := ResolveIdentifier(protocolIdentifier) if err != nil { return fail() } userID := identity.UserID - if identity.Kind == IdentifierAccount { - var u models.User - has, err := db.Instance.Where("account = ? AND disabled = 0", identity.Account).Get(&u) - if err != nil || !has || u.ID <= 0 { - return fail() - } - userID = u.ID - } if userID <= 0 { return fail() } diff --git a/deploy/install-systemd.sh b/deploy/install-systemd.sh index d788225..90d88dd 100644 --- a/deploy/install-systemd.sh +++ b/deploy/install-systemd.sh @@ -84,9 +84,9 @@ if [ ! -e /etc/smail/smail.env ]; then # 只创建默认根目录;已存在的文件始终保留部署人员的自定义环境配置。 umask 0077 printf '%s\n' 'SMAIL_ROOT=/opt/smail' > /etc/smail/smail.env - chown root:root /etc/smail/smail.env - chmod 0600 /etc/smail/smail.env fi +chown root:root /etc/smail/smail.env +chmod 0600 /etc/smail/smail.env for directory in config data ssl log plugins; do target="$runtime_dir/$directory" # 内置插件在临时沙箱中展开,只兼容已存在的历史插件目录。 diff --git a/deploy/systemd/smail.service b/deploy/systemd/smail.service index 04e5eff..032edba 100644 --- a/deploy/systemd/smail.service +++ b/deploy/systemd/smail.service @@ -7,6 +7,7 @@ Wants=network-online.target Type=simple User=smail Group=smail +EnvironmentFile=/etc/smail/smail.env # 运行环境仅由 root 可读写的独立文件提供,避免在 unit 中混入部署环境配置。 EnvironmentFile=/etc/smail/smail.env WorkingDirectory=/opt/smail