hi-server/internal/logic/auth/trialEmailWhitelist.go
shanshanzhong 4b73cd4d3c
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 5m38s
fix: 泛域名邮箱(+别名/Gmail点号)拦截提前,不受白名单开关影响
2026-04-21 09:44:00 -07:00

145 lines
3.9 KiB
Go

package auth
import (
"context"
"strings"
"github.com/perfect-panel/server/internal/config"
usermodel "github.com/perfect-panel/server/internal/model/user"
"gorm.io/gorm"
)
// IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
// Returns false if the email format is invalid.
func IsEmailDomainWhitelisted(email, whitelistCSV string) bool {
if whitelistCSV == "" {
return false
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return false
}
domain := strings.ToLower(strings.TrimSpace(parts[1]))
for _, d := range strings.Split(whitelistCSV, ",") {
if strings.ToLower(strings.TrimSpace(d)) == domain {
return true
}
}
return false
}
func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool {
if !register.EnableTrial {
return false
}
// 无论白名单是否启用,泛域名邮箱(含 + 别名或 Gmail 点号)始终拒绝赠送
if IsDisposableAlias(email) {
return false
}
if !register.EnableTrialEmailWhitelist {
return true
}
if register.TrialEmailDomainWhitelist == "" {
return false
}
if !IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist) {
return false
}
return true
}
// IsDisposableAlias detects Gmail dot trick and + alias abuse.
// 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 {
return false
}
local, domain := parts[0], parts[1]
// All domains: reject + alias
if strings.ContainsRune(local, '+') {
return true
}
// Gmail-like domains: reject dots in local part
if isGmailLikeDomain(domain) && strings.ContainsRune(local, '.') {
return true
}
return false
}
// NormalizeEmail returns a canonical form of the email for trial deduplication.
// Strips "+" aliases universally (user+tag@any.com → user@any.com).
// 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 {
return email
}
local, domain := parts[0], parts[1]
// Strip + alias
if idx := strings.IndexByte(local, '+'); idx != -1 {
local = local[:idx]
}
// Remove dots for Gmail-like providers that ignore dots in local part
if isGmailLikeDomain(domain) {
local = strings.ReplaceAll(local, ".", "")
}
return local + "@" + domain
}
func isGmailLikeDomain(domain string) bool {
switch domain {
case "gmail.com", "googlemail.com":
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).
func NormalizedEmailHasTrial(ctx context.Context, db *gorm.DB, email string, trialSubscribeId int64) bool {
normalized := NormalizeEmail(email)
if normalized == strings.ToLower(strings.TrimSpace(email)) {
return false // normalization changed nothing, skip cross-user check
}
parts := strings.SplitN(normalized, "@", 2)
if len(parts) != 2 {
return false
}
domain := parts[1]
var authMethods []usermodel.AuthMethods
if err := db.WithContext(ctx).
Model(&usermodel.AuthMethods{}).
Select("user_id, auth_identifier").
Where("auth_type = ? AND auth_identifier LIKE ?", "email", "%@"+domain).
Find(&authMethods).Error; err != nil {
return false
}
for _, am := range authMethods {
if NormalizeEmail(am.AuthIdentifier) != normalized {
continue
}
var count int64
if err := db.WithContext(ctx).
Model(&usermodel.Subscribe{}).
Where("user_id = ? AND subscribe_id = ?", am.UserId, trialSubscribeId).
Count(&count).Error; err != nil {
continue
}
if count > 0 {
return true
}
}
return false
}