From 81e7b1206137a3032b7dfb3364c0909472de25b5 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 18 Jun 2026 20:37:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=98=B2=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=20=E2=80=94=20=E5=B7=B2=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=8F=91"=E5=B7=B2=E6=B3=A8=E5=86=8C"?= =?UTF-8?q?=E6=8F=90=E9=86=92,=E4=B8=8D=E5=86=85=E8=81=94=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发码/注册都不再泄露"邮箱是否已注册"(对翻墙工具尤其敏感:已注册=该人是用户)。 - SendCode:邮箱已注册时不发验证码、改发"您已注册请直接登录"邮件,接口统一 返回 204(注册/未注册无差别)→ 关掉发码侧枚举 - Register:命中已注册(ErrEmailTaken)改回通用 ErrCodeInvalid(与错码一致), 不再返回"该邮箱已注册"→ 关掉注册侧枚举 - Mailer 接口加 SendAlreadyRegistered(SMTP + Log 两实现) - 测试:DuplicateEmailConflict 改为断言"已注册不发码 + 强制码也只回通用错误"; integration 同步 Co-Authored-By: Claude Opus 4.8 --- server/internal/auth/helpers_test.go | 2 ++ server/internal/auth/integration_test.go | 7 +++-- server/internal/auth/mailer.go | 39 ++++++++++++++++++++++++ server/internal/auth/service.go | 23 +++++++++++++- server/internal/auth/service_test.go | 23 +++++++++++--- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/server/internal/auth/helpers_test.go b/server/internal/auth/helpers_test.go index ce23a51..1067f1a 100644 --- a/server/internal/auth/helpers_test.go +++ b/server/internal/auth/helpers_test.go @@ -133,6 +133,8 @@ func (m *captureMailer) SendCode(_ context.Context, to, code string) error { return nil } +func (m *captureMailer) SendAlreadyRegistered(_ context.Context, _ string) error { return nil } + func (m *captureMailer) codeFor(to string) string { m.mu.Lock() defer m.mu.Unlock() diff --git a/server/internal/auth/integration_test.go b/server/internal/auth/integration_test.go index eb11010..7f400d3 100644 --- a/server/internal/auth/integration_test.go +++ b/server/internal/auth/integration_test.go @@ -162,14 +162,15 @@ func TestIntegration_FullChain(t *testing.T) { t.Fatalf("trial length = %.2f days, want ~7", days) } - // 3. Duplicate email → 409. + // 3. Duplicate email — anti-enumeration: registered email gets no code, and + // even a forced code yields the generic code error (not "email exists"). if _, e := svc.SendCode(ctx, email, ""); e != nil && e.Code != ErrRateLimited.Code { t.Fatalf("second SendCode: %v", e) } // Force a fresh code regardless of rate limit. _ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err() - if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrEmailExists.Code { - t.Fatalf("want email_exists, got %v", e) + if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrCodeInvalid.Code { + t.Fatalf("want code_invalid (anti-enumeration), got %v", e) } // 4. Login. diff --git a/server/internal/auth/mailer.go b/server/internal/auth/mailer.go index b9aa9ac..5809b13 100644 --- a/server/internal/auth/mailer.go +++ b/server/internal/auth/mailer.go @@ -12,6 +12,11 @@ import ( // be safe for concurrent use; Send is typically invoked from a goroutine. type Mailer interface { SendCode(ctx context.Context, to, code string) error + // SendAlreadyRegistered notifies an address that it already has an account. + // Sent instead of a verification code when registration is attempted for an + // existing email, so the code-send API response is uniform regardless of + // whether the email is registered (defends against account enumeration). + SendAlreadyRegistered(ctx context.Context, to string) error } // -------------------------------------------------------------------------- @@ -63,6 +68,30 @@ func (m *SMTPMailer) SendCode(_ context.Context, to, code string) error { return nil } +// SendAlreadyRegistered notifies an address that it already has an account. +func (m *SMTPMailer) SendAlreadyRegistered(_ context.Context, to string) error { + subject := "Account notice / 账号提醒" + body := "This email already has an account. Please sign in instead of registering.\r\n" + + "该邮箱已注册账号,请直接登录,无需重新注册。如非本人操作可忽略此邮件。\r\n" + + msg := strings.Join([]string{ + "From: " + m.cfg.From, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + }, "\r\n") + + addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port) + auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil { + return fmt.Errorf("auth: smtp send (already-registered): %w", err) + } + return nil +} + // -------------------------------------------------------------------------- // Development log implementation // -------------------------------------------------------------------------- @@ -89,3 +118,13 @@ func (m *LogMailer) SendCode(_ context.Context, to, code string) error { } return nil } + +// SendAlreadyRegistered logs an already-registered notice for local development. +func (m *LogMailer) SendAlreadyRegistered(_ context.Context, to string) error { + if m.logger != nil { + m.logger.Printf("[dev-mailer] already-registered notice for %s", to) + } else { + log.Printf("[dev-mailer] already-registered notice for %s", to) + } + return nil +} diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index ec06ccb..84f52d2 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -121,6 +121,22 @@ func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter } } + // Anti-enumeration: a registered email gets a "you already have an account" + // notice instead of a verification code; the API response (204) is identical + // either way, so this endpoint can't be used to discover which emails are + // registered. (Critical for a circumvention tool: a registered email implies + // the person uses the service.) + if _, err := s.store.GetUserByEmail(ctx, email); err == nil { + go func(to string) { + sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = s.mailer.SendAlreadyRegistered(sendCtx, to) + }(email) + return 0, nil + } else if !errors.Is(err, ErrNotFound) { + return 0, ErrInternal + } + code, err := genNumericCode(6) if err != nil { return 0, ErrInternal @@ -165,7 +181,12 @@ func (s *Service) Register(ctx context.Context, rawEmail, code, password string) user, err := s.store.CreateUserWithTrial(ctx, email, pwHash, s.cfg.TrialDays) if err != nil { if errors.Is(err, ErrEmailTaken) { - return nil, ErrEmailExists + // Anti-enumeration: do NOT reveal the email is already registered. + // A registered email never receives a code (SendCode mails a login + // reminder instead), so this branch is normally unreachable; if hit + // (e.g. a race), return the same generic error as a wrong/expired + // code rather than "email exists". + return nil, ErrCodeInvalid } return nil, ErrInternal } diff --git a/server/internal/auth/service_test.go b/server/internal/auth/service_test.go index 30ea98b..a682b04 100644 --- a/server/internal/auth/service_test.go +++ b/server/internal/auth/service_test.go @@ -88,11 +88,24 @@ func TestService_DuplicateEmailConflict(t *testing.T) { t.Fatalf("first register: %v", e) } - // Second: new code, but the email is already taken → 409. - _, _ = svc.SendCode(ctx, email, "") - _, apiErr := svc.Register(ctx, email, codeInRedis(t, svc, email), "password2") - if apiErr == nil || apiErr.Code != ErrEmailExists.Code { - t.Fatalf("want email_exists, got %v", apiErr) + // Anti-enumeration: SendCode for a now-registered email must NOT store a + // verification code (it mails a login reminder instead), so the API response + // can't be used to discover that the email is registered. + if _, e := svc.SendCode(ctx, email, ""); e != nil { + t.Fatalf("second SendCode: %v", e) + } + if c := svc.rdb.Get(ctx, codeKey(email)).Val(); c != "" { + t.Fatalf("registered email must not receive a code, got %q", c) + } + + // Even if a code is forced (e.g. a race), Register returns the generic code + // error rather than "email exists" — no enumeration leak. + if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil { + t.Fatalf("force code: %v", err) + } + _, apiErr := svc.Register(ctx, email, "654321", "password2") + if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { + t.Fatalf("want code_invalid (anti-enumeration), got %v", apiErr) } }