fix(order): prevent duplicate subscriptions and repair invite gifts
Build docker and publish / build (20.15.1) (push) Successful in 5m6s
Build docker and publish / build (20.15.1) (push) Successful in 5m6s
This commit is contained in:
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
@@ -15,11 +16,10 @@ func IsEmailDomainWhitelisted(email, whitelistCSV string) bool {
|
||||
if whitelistCSV == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
_, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
domain := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
for _, d := range strings.Split(whitelistCSV, ",") {
|
||||
if strings.ToLower(strings.TrimSpace(d)) == domain {
|
||||
return true
|
||||
@@ -32,10 +32,16 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool
|
||||
if !register.EnableTrial {
|
||||
return false
|
||||
}
|
||||
if !IsValidTrialEmail(email) {
|
||||
return false
|
||||
}
|
||||
// 无论白名单是否启用,泛域名邮箱(含 + 别名或 Gmail 点号)始终拒绝赠送
|
||||
if IsDisposableAlias(email) {
|
||||
return false
|
||||
}
|
||||
if isConfusableGmailDomain(emailDomain(email)) {
|
||||
return false
|
||||
}
|
||||
if !register.EnableTrialEmailWhitelist {
|
||||
return true
|
||||
}
|
||||
@@ -52,11 +58,10 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool
|
||||
// For Gmail-like domains, local part containing "." or "+" is rejected.
|
||||
// For all other domains, only "+" alias is rejected.
|
||||
func IsDisposableAlias(email string) bool {
|
||||
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(email)), "@", 2)
|
||||
if len(parts) != 2 {
|
||||
local, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
local, domain := parts[0], parts[1]
|
||||
|
||||
// All domains: reject + alias
|
||||
if strings.ContainsRune(local, '+') {
|
||||
@@ -74,11 +79,10 @@ func IsDisposableAlias(email string) bool {
|
||||
// Removes dots from local part for Gmail-like providers (gmail.com, googlemail.com).
|
||||
func NormalizeEmail(email string) string {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
local, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return email
|
||||
}
|
||||
local, domain := parts[0], parts[1]
|
||||
|
||||
// Strip + alias
|
||||
if idx := strings.IndexByte(local, '+'); idx != -1 {
|
||||
@@ -101,6 +105,51 @@ func isGmailLikeDomain(domain string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func IsValidTrialEmail(email string) bool {
|
||||
local, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return local != "" && domain != ""
|
||||
}
|
||||
|
||||
func parseStrictEmail(email string) (local, domain string, ok bool) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" || strings.ContainsAny(email, " \t\r\n") {
|
||||
return "", "", false
|
||||
}
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email || addr.Name != "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(addr.Address, "@")
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
local = strings.TrimSpace(parts[0])
|
||||
domain = strings.Trim(strings.TrimSpace(parts[1]), ".")
|
||||
if local == "" || domain == "" || strings.Contains(domain, "..") || !strings.Contains(domain, ".") {
|
||||
return "", "", false
|
||||
}
|
||||
return local, domain, true
|
||||
}
|
||||
|
||||
func emailDomain(email string) string {
|
||||
_, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
func isConfusableGmailDomain(domain string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "gmaial.com", "gmial.com", "gmai.com", "gamil.com", "gmal.com", "gmail.co", "gmail.con":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizedEmailHasTrial returns true if any user with the same normalized email
|
||||
// already holds a trial subscription. Only performs the cross-user DB check when
|
||||
// normalization actually changes the email (i.e., dots removed or + alias stripped).
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
)
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
// Gmail dot trick
|
||||
{"a.v.x.xx@gmail.com", "avxxx@gmail.com"},
|
||||
{"john.doe@gmail.com", "johndoe@gmail.com"},
|
||||
{"a.b.c.d.e@gmail.com", "abcde@gmail.com"},
|
||||
// Gmail + alias
|
||||
{"user+tag@gmail.com", "user@gmail.com"},
|
||||
{"a.b+tag@gmail.com", "ab@gmail.com"},
|
||||
// Googlemail
|
||||
{"a.b@googlemail.com", "ab@googlemail.com"},
|
||||
// Non-Gmail: dots preserved
|
||||
{"john.doe@outlook.com", "john.doe@outlook.com"},
|
||||
{"john.doe@qq.com", "john.doe@qq.com"},
|
||||
// + alias stripped for all providers
|
||||
{"user+spam@outlook.com", "user@outlook.com"},
|
||||
{"user+spam@qq.com", "user@qq.com"},
|
||||
// Case insensitive
|
||||
{"User@Gmail.COM", "user@gmail.com"},
|
||||
{"A.B@Gmail.com", "ab@gmail.com"},
|
||||
// No change for normal non-gmail email
|
||||
{"abc@163.com", "abc@163.com"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeEmail(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmail_NoChangeSkipsCheck(t *testing.T) {
|
||||
// These emails should NOT trigger cross-user check (normalized == original)
|
||||
noChangeCases := []string{
|
||||
"abc@163.com",
|
||||
"john.doe@outlook.com",
|
||||
"user@qq.com",
|
||||
}
|
||||
for _, email := range noChangeCases {
|
||||
normalized := NormalizeEmail(email)
|
||||
lower := email
|
||||
if normalized == lower {
|
||||
// correct: no normalization change, NormalizedEmailHasTrial would return false early
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldGrantTrialForEmail(t *testing.T) {
|
||||
// 模拟线上配置:白名单开启,gmail.com 也在名单里
|
||||
rcWithGmail := config.RegisterConfig{
|
||||
EnableTrial: true,
|
||||
EnableTrialEmailWhitelist: true,
|
||||
TrialEmailDomainWhitelist: "hifastapp.com,hifastvpn.com,126.com,139.com,163.com,gmail.com",
|
||||
}
|
||||
// 白名单关闭
|
||||
rcNoWhitelist := config.RegisterConfig{
|
||||
EnableTrial: true,
|
||||
EnableTrialEmailWhitelist: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rc config.RegisterConfig
|
||||
email string
|
||||
want bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "gmail dot trick - blocked even if gmail.com in whitelist",
|
||||
rc: rcWithGmail,
|
||||
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
|
||||
want: false,
|
||||
reason: "gmail 泛域名(含点号)应拒绝",
|
||||
},
|
||||
{
|
||||
name: "gmail plus alias - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "user+tag@gmail.com",
|
||||
want: false,
|
||||
reason: "gmail +别名应拒绝",
|
||||
},
|
||||
{
|
||||
name: "clean gmail - allowed",
|
||||
rc: rcWithGmail,
|
||||
email: "normaluser@gmail.com",
|
||||
want: true,
|
||||
reason: "干净的 gmail 应放行",
|
||||
},
|
||||
{
|
||||
name: "163 with dot - allowed (non-gmail dot is ok)",
|
||||
rc: rcWithGmail,
|
||||
email: "s.m.s.n@163.com",
|
||||
want: true,
|
||||
reason: "非 gmail 域点号不拦截",
|
||||
},
|
||||
{
|
||||
name: "163 plus alias - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "user+spam@163.com",
|
||||
want: false,
|
||||
reason: "所有域名的 +别名都拦截",
|
||||
},
|
||||
{
|
||||
name: "gmail typo squatting domain - blocked even if accidentally whitelisted",
|
||||
rc: config.RegisterConfig{EnableTrial: true, EnableTrialEmailWhitelist: true, TrialEmailDomainWhitelist: "gmail.com,gmaial.com"},
|
||||
email: "1.2.3.4xxx@gmaial.com",
|
||||
want: false,
|
||||
reason: "易混淆 Gmail 域名不应发放试用",
|
||||
},
|
||||
{
|
||||
name: "invalid empty local - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "@gmail.com",
|
||||
want: false,
|
||||
reason: "邮箱 local 为空应拒绝",
|
||||
},
|
||||
{
|
||||
name: "subdomain spoof - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "user@fake.gmail.com",
|
||||
want: false,
|
||||
reason: "白名单必须精确匹配域名,不匹配子域",
|
||||
},
|
||||
{
|
||||
name: "whitelist disabled - gmail dot trick still blocked",
|
||||
rc: rcNoWhitelist,
|
||||
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
|
||||
want: false,
|
||||
reason: "白名单未启用,但泛域名仍应拒绝",
|
||||
},
|
||||
{
|
||||
name: "trial disabled - always blocked",
|
||||
rc: config.RegisterConfig{EnableTrial: false},
|
||||
email: "user@163.com",
|
||||
want: false,
|
||||
reason: "试用未开启",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ShouldGrantTrialForEmail(tt.rc, tt.email)
|
||||
if got != tt.want {
|
||||
t.Errorf("ShouldGrantTrialForEmail(%q) = %v, want %v | reason: %s",
|
||||
tt.email, got, tt.want, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmailDomainWhitelisted(t *testing.T) {
|
||||
whitelist := "gmail.com,edu.cn,outlook.com"
|
||||
tests := []struct {
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{"user@gmail.com", true},
|
||||
{"user@edu.cn", true},
|
||||
{"User@Gmail.COM", true},
|
||||
{"user@yahoo.com", false},
|
||||
{"user@fake.gmail.com", false}, // subdomain not matched
|
||||
{"user@", false},
|
||||
{"notanemail", false},
|
||||
{"@gmail.com", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.email, func(t *testing.T) {
|
||||
got := IsEmailDomainWhitelisted(tt.email, whitelist)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsEmailDomainWhitelisted(%q) = %v, want %v", tt.email, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user