fix(auth): 收紧注册别名查重(根点绕过 / 误拒 / 无界扫描 / 并发竞态)

对 #4814 的审计跟进修复:

- 域名尾随点绕过:user@gmail.com. 的域名不在 gmail 家族名单内,点号折叠与
  googlemail 归一被整体跳过,别名刷号原样可复现。归一化入口统一去掉 FQDN 根点。
- 误拒合法用户:剥 "+后缀" 缺空串守卫,+alice@ 与 +bob@ 都折叠成 @domain,
  该域后续 "+x@" 注册会永久 EMAIL_EXISTS 且无自助恢复。改为仅当 "+" 不在首位时剥离。
- 无界不可索引全表扫描:原实现按 LOWER(email) LIKE '%@domain' 把整域邮箱读进内存,
  且挂在公开未鉴权的 send-verify-code 上。改为按去点邮箱
  REPLACE(LOWER(TRIM(email)), '.', '') 做等值 + "local+%@domain" 前缀探针并带 LIMIT,
  新增同表达式的部分索引(migrations/190)。TRIM 口径与既有精确匹配一致,
  历史带首尾空白的行同样命中;LIKE 元字符转义,% 与 _ 不会扩大匹配面。
- 并发竞态:注册改走 CreateWithEmailAliasGuard,在邮箱唯一性锁上追加收件箱身份锁并在
  锁内复查,避免同一收件箱的多个别名变体同时通过服务层前置查重。管理员建号仍走
  Create,不受别名限制。
- 能力断言静默 fail-open:别名查重方法上提到 UserRepository 端口(编译期强制),
  移除可选接口类型断言与静默降级分支。
- OAuth 邮箱注册的两条建号路径(同样发放注册赠额)纳入同一查重口径;邮箱换绑/绑定
  不纳入,否则用户把邮箱改成自己收件箱的别名会被误拒。
This commit is contained in:
shaw
2026-07-25 19:40:53 +08:00
parent b6f9277515
commit bc3acd6e28
19 changed files with 523 additions and 132 deletions
@@ -2978,6 +2978,17 @@ func (r *oauthPendingFlowUserRepo) Create(ctx context.Context, user *service.Use
return nil
}
func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error {
aliasExists, err := r.ExistsByEmailAlias(ctx, user.Email)
if err != nil {
return err
}
if aliasExists {
return service.ErrEmailExists
}
return r.Create(ctx, user)
}
func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
entity, err := r.client.User.Get(ctx, id)
if err != nil {
@@ -3185,6 +3196,20 @@ func (r *oauthPendingFlowUserRepo) ExistsByEmail(ctx context.Context, email stri
return count > 0, err
}
func (r *oauthPendingFlowUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
identity := service.NormalizeEmailForAliasDedup(email)
emails, err := r.client.User.Query().Select(dbuser.FieldEmail).Strings(ctx)
if err != nil {
return false, err
}
for _, stored := range emails {
if service.NormalizeEmailForAliasDedup(stored) == identity {
return true, nil
}
}
return false, nil
}
func (r *oauthPendingFlowUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
panic("unexpected RemoveGroupFromAllowedGroups call")
}
@@ -26,6 +26,9 @@ type userHandlerRepoStub struct {
}
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
func (s *userHandlerRepoStub) CreateWithEmailAliasGuard(context.Context, *service.User) error {
return nil
}
func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) {
cloned := *s.user
return &cloned, nil
@@ -97,6 +100,9 @@ func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int,
return 0, nil
}
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (s *userHandlerRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) {
return false, nil
}
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
+107 -26
View File
@@ -43,6 +43,16 @@ func newUserRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *userRepos
}
func (r *userRepository) Create(ctx context.Context, userIn *service.User) error {
return r.create(ctx, userIn, false)
}
// CreateWithEmailAliasGuard 见 service.UserRepository:在邮箱唯一性锁内复查收件箱身份,
// 供注册路径使用。
func (r *userRepository) CreateWithEmailAliasGuard(ctx context.Context, userIn *service.User) error {
return r.create(ctx, userIn, true)
}
func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool) error {
if userIn == nil {
return nil
}
@@ -69,11 +79,16 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
}
}
lockKeys := []string{normalizedEmailUniquenessLockKey(userIn.Email)}
if guardEmailAlias {
// 别名变体的字面量不同,唯一索引无法兜底;用收件箱身份锁把同一收件箱的并发注册串行化。
lockKeys = append(lockKeys, emailAliasUniquenessLockKey(userIn.Email))
}
releaseEmailLock, err := lockRepositoryScopedKeys(
txCtx,
txClient,
txAwareSQLExecutor(txCtx, r.sql, r.client),
normalizedEmailUniquenessLockKey(userIn.Email),
lockKeys...,
)
if err != nil {
return err
@@ -84,6 +99,16 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
return err
}
if guardEmailAlias {
aliasExists, err := existsByEmailAliasWithClient(txCtx, txClient, userIn.Email)
if err != nil {
return err
}
if aliasExists {
return service.ErrEmailExists
}
}
created, err := txClient.User.Create().
SetEmail(userIn.Email).
SetUsername(userIn.Username).
@@ -904,37 +929,83 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
}
// ListEmailsByDomains returns the emails of all users whose domain matches one
// of the given domains (case-insensitive). It is used by registration alias
// dedup (service.existsByEmailOrAlias) to scan the candidate set for plus /
// dot-trick collisions. Soft-delete filtering follows the same default as
// ExistsByEmail (via r.client.User.Query()).
func (r *userRepository) ListEmailsByDomains(ctx context.Context, domains []string) ([]string, error) {
if len(domains) == 0 {
return nil, nil
// emailAliasCandidateLimit 限制一次别名查重最多取回的候选行数。探针都以去点后的
// 本地部分为前缀锚定(见 dotStrippedEmailExpr),正常收件箱的变体只有个位数;
// 上限只是兜底,避免公开未鉴权的注册/发码端点把大表整张读进内存。
const emailAliasCandidateLimit = 50
// ExistsByEmailAlias 见 service.UserRepository。软删除过滤沿用 ExistsByEmail 的默认行为。
func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
return existsByEmailAliasWithClient(ctx, clientFromContext(ctx, r.client), email)
}
func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) {
if client == nil {
return false, nil
}
preds := make([]predicate.User, 0, len(domains))
for _, domain := range domains {
suffix := "@" + strings.ToLower(strings.TrimSpace(domain))
if suffix == "@" {
continue
}
preds = append(preds, predicate.User(func(s *entsql.Selector) {
s.Where(entsql.P(func(b *entsql.Builder) {
b.WriteString("LOWER(").
Ident(s.C(dbuser.FieldEmail)).
WriteString(") LIKE ").
Arg("%" + suffix)
}))
}))
probes := service.EmailAliasDedupProbes(email)
if len(probes) == 0 {
return false, nil
}
if len(preds) == 0 {
return nil, nil
preds := make([]predicate.User, 0, 2*len(probes))
for _, probe := range probes {
preds = append(preds,
dotStrippedEmailEQ(probe.Local+"@"+probe.Domain),
// "+后缀"的内容未知,只能按前缀匹配。
dotStrippedEmailLike(escapeLikeWildcards(probe.Local)+"+%@"+escapeLikeWildcards(probe.Domain)),
)
}
return r.client.User.Query().
candidates, err := client.User.Query().
Where(dbuser.Or(preds...)).
Limit(emailAliasCandidateLimit).
Select(dbuser.FieldEmail).
Strings(ctx)
if err != nil {
return false, err
}
// 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。
identity := service.NormalizeEmailForAliasDedup(email)
for _, candidate := range candidates {
if service.NormalizeEmailForAliasDedup(candidate) == identity {
return true, nil
}
}
return false, nil
}
// dotStrippedEmailExpr 渲染 REPLACE(LOWER(TRIM(email)), '.', ''):去掉大小写、首尾空白
// (与 userEmailLookupPredicate 的精确匹配口径一致,历史数据存在带空白的行)以及全部点号。
// 两侧都去点,因此一个域名探针即可同时覆盖 Gmail 点号变体与 FQDN 根点(user@gmail.com.)。
// migrations/190 为同一表达式建了索引。
func dotStrippedEmailExpr(b *entsql.Builder, s *entsql.Selector) *entsql.Builder {
return b.WriteString("REPLACE(LOWER(TRIM(").
Ident(s.C(dbuser.FieldEmail)).
WriteString(")), '.', '')")
}
func dotStrippedEmailEQ(value string) predicate.User {
return predicate.User(func(s *entsql.Selector) {
s.Where(entsql.P(func(b *entsql.Builder) {
dotStrippedEmailExpr(b, s).WriteString(" = ").Arg(value)
}))
})
}
func dotStrippedEmailLike(pattern string) predicate.User {
return predicate.User(func(s *entsql.Selector) {
s.Where(entsql.P(func(b *entsql.Builder) {
dotStrippedEmailExpr(b, s).WriteString(" LIKE ").Arg(pattern).WriteString(` ESCAPE '\'`)
}))
})
}
// escapeLikeWildcards 转义 LIKE 元字符:本地部分合法可含 % 与 _,不转义会扩大匹配面。
var likeWildcardEscaper = strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`)
func escapeLikeWildcards(value string) string {
return likeWildcardEscaper.Replace(value)
}
func ensureNormalizedEmailAvailableWithClient(ctx context.Context, client *dbent.Client, userID int64, email string) error {
@@ -984,6 +1055,16 @@ func normalizedEmailUniquenessLockKey(email string) string {
return "users:normalized-email:" + normalized
}
// emailAliasUniquenessLockKey 按收件箱身份(而非邮箱字面量)加锁,使同一收件箱的不同
// 别名变体在注册时互斥。
func emailAliasUniquenessLockKey(email string) string {
identity := service.NormalizeEmailForAliasDedup(email)
if identity == "" {
return ""
}
return "users:email-alias-identity:" + identity
}
func (r *userRepository) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
client := clientFromContext(ctx, r.client)
err := client.UserAllowedGroup.Create().
@@ -0,0 +1,100 @@
package repository
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func seedUserForAliasTest(t *testing.T, repo *userRepository, email string) {
t.Helper()
require.NoError(t, repo.Create(context.Background(), &service.User{
Email: email,
Username: email,
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
}))
}
func TestUserRepositoryExistsByEmailAlias(t *testing.T) {
cases := []struct {
name string
stored string
probe string
want bool
}{
{"same address", "someone@gmail.com", "someone@gmail.com", true},
{"gmail plus alias", "someone@gmail.com", "someone+bulk294@gmail.com", true},
{"gmail dot trick", "d.axis.2026@gmail.com", "daxis2026@gmail.com", true},
{"gmail dot trick both sides", "d.axis.2026@gmail.com", "da.xis.2026@gmail.com", true},
{"stored plus alias found by canonical form", "someone+tag@gmail.com", "someone@gmail.com", true},
{"googlemail is a gmail alias", "someone@googlemail.com", "some.one@gmail.com", true},
{"fqdn root dot on probe", "d.axis.2026@gmail.com", "da.xis.2026@gmail.com.", true},
{"fqdn root dot on stored row", "d.axis.2026@gmail.com.", "daxis2026@gmail.com", true},
{"legacy row with spacing and case", " D.Axis.2026@Gmail.com ", "daxis2026@gmail.com", true},
{"non-gmail plus alias", "first.last@qq.com", "first.last+tag@qq.com", true},
{"different gmail inbox", "someone@gmail.com", "someoneelse@gmail.com", false},
{"non-gmail dots are significant", "first.last@qq.com", "firstlast@qq.com", false},
{"different domain", "someone@gmail.com", "someone@qq.com", false},
{"distinct plus-prefixed locals", "+alice@gmail.com", "+bob@gmail.com", false},
{"underscore is not a wildcard", "user_x@qq.com", "userax@qq.com", false},
{"percent is not a wildcard", "a%b@qq.com", "axxb@qq.com", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo, _ := newUserEntRepo(t)
seedUserForAliasTest(t, repo, tc.stored)
got, err := repo.ExistsByEmailAlias(context.Background(), tc.probe)
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}
func TestUserRepositoryExistsByEmailAliasIgnoresMalformedInput(t *testing.T) {
repo, _ := newUserEntRepo(t)
seedUserForAliasTest(t, repo, "someone@gmail.com")
got, err := repo.ExistsByEmailAlias(context.Background(), "not-an-email")
require.NoError(t, err)
require.False(t, got)
}
func TestUserRepositoryCreateWithEmailAliasGuard(t *testing.T) {
repo, _ := newUserEntRepo(t)
ctx := context.Background()
seedUserForAliasTest(t, repo, "d.axis.2026@gmail.com")
// 注册路径:别名变体在唯一性锁内被拒绝。
err := repo.CreateWithEmailAliasGuard(ctx, &service.User{
Email: "da.xis.2026+free@googlemail.com",
Username: "alias-variant",
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
})
require.ErrorIs(t, err, service.ErrEmailExists)
// 不同收件箱仍可注册。
require.NoError(t, repo.CreateWithEmailAliasGuard(ctx, &service.User{
Email: "other.person@gmail.com",
Username: "other-person",
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
}))
// 管理员建号(Create)不受别名限制。
require.NoError(t, repo.Create(ctx, &service.User{
Email: "daxis2026+support@gmail.com",
Username: "admin-created",
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
}))
}
@@ -1495,6 +1495,10 @@ func (r *stubUserRepo) Create(ctx context.Context, user *service.User) error {
return errors.New("not implemented")
}
func (r *stubUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error {
return errors.New("not implemented")
}
func (r *stubUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
user, ok := r.users[id]
if !ok {
@@ -1574,6 +1578,10 @@ func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, e
return false, errors.New("not implemented")
}
func (r *stubUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
return false, errors.New("not implemented")
}
func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
return 0, errors.New("not implemented")
}
@@ -131,6 +131,10 @@ func (s *stubUserRepo) Create(ctx context.Context, user *service.User) error {
panic("unexpected Create call")
}
func (s *stubUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error {
panic("unexpected CreateWithEmailAliasGuard call")
}
func (s *stubUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
if s.getByID == nil {
panic("GetByID not stubbed")
@@ -208,6 +212,10 @@ func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, e
panic("unexpected ExistsByEmail call")
}
func (s *stubUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
panic("unexpected ExistsByEmailAlias call")
}
func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected RemoveGroupFromAllowedGroups call")
}
@@ -33,6 +33,9 @@ func (s *userRepoStubForGroupUpdate) AddGroupToAllowedGroups(_ context.Context,
}
func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) CreateWithEmailAliasGuard(context.Context, *User) error {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) {
panic("unexpected")
}
@@ -81,6 +84,9 @@ func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64,
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) ExistsByEmailAlias(context.Context, string) (bool, error) {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
panic("unexpected")
}
@@ -13,18 +13,21 @@ import (
)
type userRepoStub struct {
user *User
getErr error
createErr error
deleteErr error
exists bool
existsErr error
nextID int64
created []*User
updated []*User
deletedIDs []int64
usersByEmail map[string]*User
getByEmailErr error
user *User
getErr error
createErr error
deleteErr error
exists bool
existsErr error
aliasExists bool
aliasErr error
guardedCreates int
nextID int64
created []*User
updated []*User
deletedIDs []int64
usersByEmail map[string]*User
getByEmailErr error
}
func (s *userRepoStub) Create(ctx context.Context, user *User) error {
@@ -43,6 +46,17 @@ func (s *userRepoStub) Create(ctx context.Context, user *User) error {
return nil
}
func (s *userRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *User) error {
s.guardedCreates++
if s.aliasErr != nil {
return s.aliasErr
}
if s.aliasExists {
return ErrEmailExists
}
return s.Create(ctx, user)
}
func (s *userRepoStub) GetByID(ctx context.Context, id int64) (*User, error) {
if s.getErr != nil {
return nil, s.getErr
@@ -144,6 +158,13 @@ func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, e
return s.exists, nil
}
func (s *userRepoStub) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
if s.aliasErr != nil {
return false, s.aliasErr
}
return s.aliasExists, nil
}
func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected RemoveGroupFromAllowedGroups call")
}
@@ -35,6 +35,10 @@ type emailSyncRepoStub struct {
replaceErr error
}
func (s *emailSyncRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *User) error {
return s.Create(ctx, user)
}
func (s *emailSyncRepoStub) Create(_ context.Context, user *User) error {
if s.nextID != 0 && user.ID == 0 {
user.ID = s.nextID
@@ -109,6 +113,10 @@ func (s *emailSyncRepoStub) UpdateConcurrency(context.Context, int64, int) error
func (s *emailSyncRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (s *emailSyncRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) {
return false, nil
}
func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
@@ -132,7 +132,8 @@ func (s *AuthService) RegisterOAuthEmailAccount(
return nil, nil, err
}
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
// 含 +别名 / Gmail 点号 / FQDN 根点变体归一化:该路径同样发放注册赠额,不能被单个收件箱刷号。
existsEmail, err := s.existsByEmailOrAlias(ctx, email)
if err != nil {
slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error())
return nil, nil, ErrServiceUnavailable
@@ -159,7 +160,7 @@ func (s *AuthService) RegisterOAuthEmailAccount(
SignupSource: signupSource,
}
if err := s.userRepo.Create(ctx, user); err != nil {
if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil {
if errors.Is(err, ErrEmailExists) {
return nil, nil, ErrEmailExists
}
@@ -211,7 +212,8 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
return nil, nil, err
}
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
// 与本地注册同口径:同一收件箱的别名变体不能各自建号(该路径也发放注册赠额)。
existsEmail, err := s.existsByEmailOrAlias(ctx, email)
if err != nil {
return nil, nil, ErrServiceUnavailable
}
@@ -241,7 +243,7 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
SignupSource: signupSource,
}
if err := s.userRepo.Create(ctx, user); err != nil {
if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil {
if errors.Is(err, ErrEmailExists) {
return nil, nil, ErrEmailExists
}
+1 -1
View File
@@ -224,7 +224,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
Status: StatusActive,
}
if err := s.userRepo.Create(ctx, user); err != nil {
if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil {
// 优先检查邮箱冲突错误(竞态条件下可能发生)
if errors.Is(err, ErrEmailExists) {
return "", nil, ErrEmailExists
@@ -874,6 +874,10 @@ func newEmailBindUserRepoStub(user *service.User) *emailBindUserRepoStub {
func (s *emailBindUserRepoStub) Create(context.Context, *service.User) error { return nil }
func (s *emailBindUserRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error {
return s.Create(ctx, user)
}
func (s *emailBindUserRepoStub) GetByID(_ context.Context, id int64) (*service.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -957,6 +961,18 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (
return ok, nil
}
func (s *emailBindUserRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
identity := service.NormalizeEmailForAliasDedup(email)
for stored := range s.usersByEmail {
if service.NormalizeEmailForAliasDedup(stored) == identity {
return true, nil
}
}
return false, nil
}
func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
@@ -365,6 +365,30 @@ func TestAuthService_Register_EmailExists(t *testing.T) {
require.ErrorIs(t, err, ErrEmailExists)
}
func TestAuthService_Register_AliasDuplicateRejected(t *testing.T) {
repo := &userRepoStub{aliasExists: true}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
}, nil, nil)
_, _, err := service.Register(context.Background(), "some.one+bulk294@gmail.com", "password")
require.ErrorIs(t, err, ErrEmailExists)
require.Empty(t, repo.created)
}
func TestAuthService_Register_UsesAliasGuardedCreate(t *testing.T) {
// 注册必须走带别名兜底的创建路径:服务层前置查重与写入之间存在竞态窗口。
repo := &userRepoStub{nextID: 91}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
}, nil, nil)
_, user, err := service.Register(context.Background(), "newuser@gmail.com", "password")
require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, 1, repo.guardedCreates)
}
func TestAuthService_Register_CheckEmailError(t *testing.T) {
repo := &userRepoStub{existsErr: errors.New("db down")}
service := newAuthService(repo, map[string]string{
@@ -168,6 +168,10 @@ func (r *contentModerationTestUserRepo) Create(ctx context.Context, user *User)
panic("unexpected Create call")
}
func (r *contentModerationTestUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *User) error {
panic("unexpected CreateWithEmailAliasGuard call")
}
func (r *contentModerationTestUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
if r.user == nil {
return nil, ErrUserNotFound
@@ -257,6 +261,10 @@ func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email
panic("unexpected ExistsByEmail call")
}
func (r *contentModerationTestUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
panic("unexpected ExistsByEmailAlias call")
}
func (r *contentModerationTestUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected RemoveGroupFromAllowedGroups call")
}
@@ -13,6 +13,8 @@ import (
// - Plus addressing: user+tag@gmail.com is delivered to user@gmail.com
// (supported by Gmail, Outlook/Hotmail, Yahoo, iCloud, Fastmail, and more).
// - Gmail dot trick: u.s.e.r@gmail.com is delivered to user@gmail.com.
// - FQDN root dot: user@gmail.com. is the absolute form of user@gmail.com,
// passes the registration validator, and reaches the same mailbox.
//
// This lets abusers bulk-register accounts (e.g. to farm signup grants) while
// the domain whitelist and email verification see each variant as a distinct,
@@ -20,9 +22,10 @@ import (
// a single "inbox identity" so the registration path can reject duplicates.
//
// Normalization rules:
// - All domains: lowercase, trim, and strip the local-part "+suffix".
// Stripping the plus suffix on domains that do not support plus addressing
// is harmless — those exact addresses are virtually never registered.
// - All domains: lowercase, trim, drop the FQDN root dot, and strip the
// local-part "+suffix". Stripping the plus suffix on domains that do not
// support plus addressing is harmless — those exact addresses are virtually
// never registered.
// - Gmail family (gmail.com / googlemail.com): additionally remove dots from
// the local part and fold the domain to gmail.com.
//
@@ -38,72 +41,102 @@ var gmailFamilyDomains = map[string]struct{}{
// email. Malformed input is returned lowercased/trimmed unchanged; format
// validation is the caller's responsibility.
func NormalizeEmailForAliasDedup(email string) string {
local, domain, ok := splitEmailForPolicy(email)
local, domain, ok := splitEmailForAliasDedup(email)
if !ok {
return strings.ToLower(strings.TrimSpace(email))
}
if idx := strings.IndexByte(local, '+'); idx >= 0 {
local = local[:idx]
}
if _, isGmail := gmailFamilyDomains[domain]; isGmail {
local = strings.ReplaceAll(local, ".", "")
local = stripEmailPlusSuffix(local)
if isGmailFamilyDomain(domain) {
local = stripEmailLocalDots(local)
domain = "gmail.com"
}
return local + "@" + domain
}
// aliasDedupCandidateDomains returns the stored domains to scan when checking
// for an alias collision: gmail-family domains are mutual aliases, every other
// domain only collides with itself.
func aliasDedupCandidateDomains(email string) []string {
_, domain, ok := splitEmailForPolicy(email)
// EmailAliasProbe describes a shape a stored duplicate can have, expressed on the
// dot-stripped email form used by UserRepository.ExistsByEmailAlias: Local is the
// plus-stripped and dot-stripped local part, Domain the dot-stripped candidate
// domain. Dots are removed on both sides of that comparison so one probe per
// domain also covers the Gmail dot trick and the FQDN root dot. The over-matching
// this introduces (dots stay significant outside the Gmail family) is filtered by
// re-checking every candidate with NormalizeEmailForAliasDedup.
type EmailAliasProbe struct {
Local string
Domain string
}
// EmailAliasDedupProbes returns the probes covering every stored address that
// could resolve to the same inbox as email: gmail-family domains are mutual
// aliases, every other domain only collides with itself. It returns nil when
// there is nothing to probe (malformed address, or a local part made of dots
// only, which no provider delivers).
func EmailAliasDedupProbes(email string) []EmailAliasProbe {
local, domain, ok := splitEmailForAliasDedup(email)
if !ok {
return nil
}
if _, isGmail := gmailFamilyDomains[domain]; isGmail {
return []string{"gmail.com", "googlemail.com"}
probeLocal := strings.ReplaceAll(stripEmailPlusSuffix(local), ".", "")
if probeLocal == "" {
return nil
}
return []string{domain}
domains := []string{domain}
if isGmailFamilyDomain(domain) {
domains = []string{"gmail.com", "googlemail.com"}
}
probes := make([]EmailAliasProbe, 0, len(domains))
for _, candidate := range domains {
probes = append(probes, EmailAliasProbe{
Local: probeLocal,
Domain: strings.ReplaceAll(candidate, ".", ""),
})
}
return probes
}
// emailAliasLookupRepo is an optional capability of UserRepository, declared at
// the point of use so the core interface (and its test doubles) stay untouched.
type emailAliasLookupRepo interface {
ListEmailsByDomains(ctx context.Context, domains []string) ([]string, error)
func splitEmailForAliasDedup(email string) (local string, domain string, ok bool) {
local, domain, ok = splitEmailForPolicy(email)
if !ok {
return "", "", false
}
domain = strings.TrimRight(domain, ".")
if domain == "" {
return "", "", false
}
return local, domain, true
}
func stripEmailPlusSuffix(local string) string {
// idx > 0 only: "+tag@host" has no local part left to keep, and folding every
// "+x@host" into "@host" would lock unrelated senders out of that domain.
if idx := strings.IndexByte(local, '+'); idx > 0 {
return local[:idx]
}
return local
}
func stripEmailLocalDots(local string) string {
if stripped := strings.ReplaceAll(local, ".", ""); stripped != "" {
return stripped
}
return local
}
func isGmailFamilyDomain(domain string) bool {
_, ok := gmailFamilyDomains[domain]
return ok
}
// existsByEmailOrAlias reports whether an email — or any alias variant that
// resolves to the same inbox — is already registered.
//
// It first performs the exact ExistsByEmail check, then, only on a miss, scans
// the candidate domains for an alias collision. Consistent with ExistsByEmail,
// a lookup error is surfaced (fail-closed) so the registration path returns a
// service error rather than letting an attacker bypass the check by inducing
// errors. Repositories without the alias-lookup capability degrade to the exact
// check.
// It first performs the exact ExistsByEmail check, then, only on a miss, probes
// for an alias collision. Consistent with ExistsByEmail, lookup errors are
// surfaced (fail-closed) so the registration path returns a service error instead
// of letting an attacker bypass the check by inducing errors.
func (s *AuthService) existsByEmailOrAlias(ctx context.Context, email string) (bool, error) {
exists, err := s.userRepo.ExistsByEmail(ctx, email)
if err != nil || exists {
return exists, err
}
repo, ok := s.userRepo.(emailAliasLookupRepo)
if !ok {
return false, nil
}
domains := aliasDedupCandidateDomains(email)
if len(domains) == 0 {
return false, nil
}
existing, err := repo.ListEmailsByDomains(ctx, domains)
if err != nil {
return false, err
}
normalized := NormalizeEmailForAliasDedup(email)
for _, candidate := range existing {
if NormalizeEmailForAliasDedup(candidate) == normalized {
return true, nil
}
}
return false, nil
return s.userRepo.ExistsByEmailAlias(ctx, email)
}
@@ -24,6 +24,10 @@ func TestNormalizeEmailForAliasDedup(t *testing.T) {
{"gmail dots and plus", "s.o.m.e+x@gmail.com", "some@gmail.com"},
{"googlemail folded to gmail", "user@googlemail.com", "user@gmail.com"},
{"non-gmail keeps dots", "first.last@qq.com", "first.last@qq.com"},
{"fqdn root dot dropped", "d.axis.2026@gmail.com.", "daxis2026@gmail.com"},
{"fqdn root dot on other domain", "first.last@qq.com.", "first.last@qq.com"},
{"leading plus keeps local part", "+alice@gmail.com", "+alice@gmail.com"},
{"dot-only local part kept", "...@gmail.com", "...@gmail.com"},
{"invalid keeps lowered raw", "not-an-email", "not-an-email"},
}
for _, tc := range cases {
@@ -33,11 +37,33 @@ func TestNormalizeEmailForAliasDedup(t *testing.T) {
}
}
func TestAliasDedupCandidateDomains(t *testing.T) {
require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, aliasDedupCandidateDomains("user@gmail.com"))
require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, aliasDedupCandidateDomains("user@googlemail.com"))
require.Equal(t, []string{"qq.com"}, aliasDedupCandidateDomains("user@qq.com"))
require.Nil(t, aliasDedupCandidateDomains("not-an-email"))
func TestNormalizeEmailForAliasDedupKeepsDistinctInboxes(t *testing.T) {
// 剥离 "+后缀" 不能把同域下不同用户折叠成同一身份。
require.NotEqual(t,
NormalizeEmailForAliasDedup("+alice@gmail.com"),
NormalizeEmailForAliasDedup("+bob@gmail.com"),
)
require.NotEqual(t,
NormalizeEmailForAliasDedup("alice@gmail.com"),
NormalizeEmailForAliasDedup("bob@gmail.com"),
)
}
func TestEmailAliasDedupProbes(t *testing.T) {
require.ElementsMatch(t,
[]EmailAliasProbe{{Local: "someone", Domain: "gmailcom"}, {Local: "someone", Domain: "googlemailcom"}},
EmailAliasDedupProbes("Some.One+tag@gmail.com"),
)
require.ElementsMatch(t,
[]EmailAliasProbe{{Local: "daxis2026", Domain: "gmailcom"}, {Local: "daxis2026", Domain: "googlemailcom"}},
EmailAliasDedupProbes("d.axis.2026@googlemail.com."),
)
require.Equal(t,
[]EmailAliasProbe{{Local: "firstlast", Domain: "qqcom"}},
EmailAliasDedupProbes("first.last+tag@qq.com"),
)
require.Nil(t, EmailAliasDedupProbes("not-an-email"))
require.Nil(t, EmailAliasDedupProbes("...@gmail.com"))
}
// aliasDedupRepoStub implements only the methods alias dedup uses; other
@@ -45,30 +71,29 @@ func TestAliasDedupCandidateDomains(t *testing.T) {
// would panic, failing the test).
type aliasDedupRepoStub struct {
UserRepository
exists bool
existsErr error
emails []string
listErr error
scanned [][]string
exists bool
existsErr error
stored []string
aliasErr error
aliasChecks []string
}
func (s *aliasDedupRepoStub) ExistsByEmail(context.Context, string) (bool, error) {
return s.exists, s.existsErr
}
func (s *aliasDedupRepoStub) ListEmailsByDomains(_ context.Context, domains []string) ([]string, error) {
s.scanned = append(s.scanned, domains)
return s.emails, s.listErr
}
// exactOnlyRepoStub only supports the exact check (no alias-lookup capability).
type exactOnlyRepoStub struct {
UserRepository
exists bool
}
func (s *exactOnlyRepoStub) ExistsByEmail(context.Context, string) (bool, error) {
return s.exists, nil
func (s *aliasDedupRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) {
s.aliasChecks = append(s.aliasChecks, email)
if s.aliasErr != nil {
return false, s.aliasErr
}
identity := NormalizeEmailForAliasDedup(email)
for _, candidate := range s.stored {
if NormalizeEmailForAliasDedup(candidate) == identity {
return true, nil
}
}
return false, nil
}
func TestExistsByEmailOrAlias(t *testing.T) {
@@ -80,11 +105,11 @@ func TestExistsByEmailOrAlias(t *testing.T) {
got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com")
require.NoError(t, err)
require.True(t, got)
require.Empty(t, repo.scanned, "no alias scan expected after exact hit")
require.Empty(t, repo.aliasChecks, "no alias probe expected after exact hit")
})
t.Run("plus alias variant detected", func(t *testing.T) {
repo := &aliasDedupRepoStub{emails: []string{"someone+bulk294@gmail.com"}}
repo := &aliasDedupRepoStub{stored: []string{"someone+bulk294@gmail.com"}}
svc := &AuthService{userRepo: repo}
got, err := svc.existsByEmailOrAlias(ctx, "Someone@gmail.com")
require.NoError(t, err)
@@ -92,32 +117,39 @@ func TestExistsByEmailOrAlias(t *testing.T) {
})
t.Run("gmail dot variant detected", func(t *testing.T) {
repo := &aliasDedupRepoStub{emails: []string{"some.one@gmail.com"}}
repo := &aliasDedupRepoStub{stored: []string{"some.one@gmail.com"}}
svc := &AuthService{userRepo: repo}
got, err := svc.existsByEmailOrAlias(ctx, "someone@gmail.com")
require.NoError(t, err)
require.True(t, got)
})
t.Run("gmail scans both gmail-family domains", func(t *testing.T) {
repo := &aliasDedupRepoStub{}
t.Run("fqdn root dot variant detected", func(t *testing.T) {
repo := &aliasDedupRepoStub{stored: []string{"d.axis.2026@gmail.com"}}
svc := &AuthService{userRepo: repo}
_, err := svc.existsByEmailOrAlias(ctx, "user@googlemail.com")
got, err := svc.existsByEmailOrAlias(ctx, "da.xis.2026@gmail.com.")
require.NoError(t, err)
require.Len(t, repo.scanned, 1)
require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, repo.scanned[0])
require.True(t, got)
})
t.Run("different inbox allowed", func(t *testing.T) {
repo := &aliasDedupRepoStub{emails: []string{"other@gmail.com"}}
repo := &aliasDedupRepoStub{stored: []string{"other@gmail.com"}}
svc := &AuthService{userRepo: repo}
got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com")
require.NoError(t, err)
require.False(t, got)
})
t.Run("list error fails closed", func(t *testing.T) {
repo := &aliasDedupRepoStub{listErr: errors.New("db down")}
t.Run("distinct plus-prefixed locals allowed", func(t *testing.T) {
repo := &aliasDedupRepoStub{stored: []string{"+alice@gmail.com"}}
svc := &AuthService{userRepo: repo}
got, err := svc.existsByEmailOrAlias(ctx, "+bob@gmail.com")
require.NoError(t, err)
require.False(t, got)
})
t.Run("alias probe error fails closed", func(t *testing.T) {
repo := &aliasDedupRepoStub{aliasErr: errors.New("db down")}
svc := &AuthService{userRepo: repo}
_, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com")
require.Error(t, err)
@@ -129,11 +161,4 @@ func TestExistsByEmailOrAlias(t *testing.T) {
_, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com")
require.Error(t, err)
})
t.Run("repo without capability falls back to exact check", func(t *testing.T) {
svc := &AuthService{userRepo: &exactOnlyRepoStub{exists: false}}
got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com")
require.NoError(t, err)
require.False(t, got)
})
}
+8
View File
@@ -85,6 +85,11 @@ type UserListFilters struct {
type UserRepository interface {
Create(ctx context.Context, user *User) error
// CreateWithEmailAliasGuard 创建用户,并在邮箱唯一性锁内复查"收件箱身份"是否已被占用
// (+别名 / Gmail 点号 / FQDN 根点变体,见 NormalizeEmailForAliasDedup),
// 冲突时返回 ErrEmailExists。仅注册路径使用:同一收件箱的多个别名变体并发注册时,
// 服务层的前置查重会同时通过,必须由这里串行化兜底。管理员建号仍走 Create,不受限制。
CreateWithEmailAliasGuard(ctx context.Context, user *User) error
GetByID(ctx context.Context, id int64) (*User, error)
// GetByIDIncludeDeleted 绕过软删除过滤按 ID 取用户(含已删)。仅供管理员审计/usage 点击使用。
GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error)
@@ -109,6 +114,9 @@ type UserRepository interface {
BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
// ExistsByEmailAlias 判断是否已有账号与该邮箱指向同一收件箱(+别名 / Gmail 点号 /
// FQDN 根点变体,见 NormalizeEmailForAliasDedup)。用于注册与发送验证码前的查重。
ExistsByEmailAlias(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
@@ -90,7 +90,8 @@ func (m *mockUserSettingRepo) Delete(context.Context, string) error {
panic("unexpected Delete call")
}
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
func (m *mockUserRepo) CreateWithEmailAliasGuard(context.Context, *User) error { return nil }
func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
if m.getByIDErr != nil {
return nil, m.getByIDErr
@@ -202,6 +203,9 @@ func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float
}
func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil }
func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (m *mockUserRepo) ExistsByEmailAlias(context.Context, string) (bool, error) {
return false, nil
}
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
@@ -0,0 +1,8 @@
-- Registration alias dedup (repository.existsByEmailAliasWithClient) probes users
-- by the dot-stripped email form, on the public register / send-verify-code paths.
-- Index that exact expression so the probes stay index lookups instead of a
-- sequential scan. text_pattern_ops serves both the equality probe and the
-- "local+%@domain" prefix probe regardless of database collation.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_dot_stripped
ON users ((REPLACE(LOWER(TRIM(email)), '.', '')) text_pattern_ops)
WHERE deleted_at IS NULL;