mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user