feat: sync login email verification support
This commit is contained in:
parent
e0cabb4930
commit
9a501f900a
39 changed files with 2198 additions and 117 deletions
|
|
@ -4,8 +4,10 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +27,17 @@ func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User
|
|||
return u
|
||||
}
|
||||
|
||||
type captureMailSender struct {
|
||||
to string
|
||||
code string
|
||||
}
|
||||
|
||||
func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
|
||||
s.to = to
|
||||
s.code = code
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始
|
||||
// 地址只在 LoginEmail 读路径可见。
|
||||
func TestSetLoginEmailPersistsAndMasks(t *testing.T) {
|
||||
|
|
@ -88,6 +101,24 @@ func TestSetLoginEmailRejectsInvalid(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetLoginEmailRejectsDuplicateCaseInsensitive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, users := newLoginEmailService(t)
|
||||
u1 := createUser(t, users, "15550010103")
|
||||
u2 := createUser(t, users, "15550010104")
|
||||
|
||||
if err := svc.SetLoginEmail(ctx, u1.ID, "Alice@Example.Test"); err != nil {
|
||||
t.Fatalf("SetLoginEmail user1: %v", err)
|
||||
}
|
||||
if err := svc.SetLoginEmail(ctx, u2.ID, "alice@example.test"); !errors.Is(err, domain.ErrEmailOccupied) {
|
||||
t.Fatalf("SetLoginEmail duplicate err = %v, want ErrEmailOccupied", err)
|
||||
}
|
||||
email, found, err := svc.LoginEmail(ctx, u1.ID)
|
||||
if err != nil || !found || email != "alice@example.test" {
|
||||
t.Fatalf("LoginEmail user1 = %q found=%v err=%v", email, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern 是核心解耦回归:设置 2FA 恢复邮箱
|
||||
// 不得把恢复邮箱掩码写进 login_email_pattern(历史 bug)。
|
||||
func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
|
||||
|
|
@ -114,3 +145,149 @@ func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
|
|||
t.Fatal("HasRecovery = false, want true after setting recovery email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendLoginEmailCodeRejectsDuplicateBeforeSending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
passwords := memory.NewPasswordStore()
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(passwords,
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||||
u1 := createUser(t, users, "15550010105")
|
||||
u2 := createUser(t, users, "15550010106")
|
||||
if err := svc.SetLoginEmail(ctx, u1.ID, "taken@example.test"); err != nil {
|
||||
t.Fatalf("SetLoginEmail user1: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, u2.ID, "", "", "TAKEN@example.test", false); !errors.Is(err, domain.ErrEmailOccupied) {
|
||||
t.Fatalf("SendLoginEmailCode duplicate err = %v, want ErrEmailOccupied", err)
|
||||
}
|
||||
if sender.to != "" || sender.code != "" {
|
||||
t.Fatalf("duplicate email sent to=%q code=%q, want no send", sender.to, sender.code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailSetupRejectsAlreadyOwnedEmailForNewPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
passwords := memory.NewPasswordStore()
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(passwords,
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||||
owner := createUser(t, users, "15550010107")
|
||||
if err := svc.SetLoginEmail(ctx, owner.ID, "owner@example.test"); err != nil {
|
||||
t.Fatalf("SetLoginEmail owner: %v", err)
|
||||
}
|
||||
if err := codes.Set(ctx, "new-phone-hash", store.PhoneCode{Phone: "15550010108", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
|
||||
t.Fatalf("seed phone code: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, 0, "+1 555 001 0108", "new-phone-hash", "OWNER@example.test", true); !errors.Is(err, domain.ErrEmailOccupied) {
|
||||
t.Fatalf("setup duplicate email err = %v, want ErrEmailOccupied", err)
|
||||
}
|
||||
if sender.to != "" || sender.code != "" {
|
||||
t.Fatalf("duplicate setup email sent to=%q code=%q, want no send", sender.to, sender.code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendVerifyLoginEmailPersistsOnlyAfterVerify(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||||
u := createUser(t, users, "15550010005")
|
||||
|
||||
pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "alice@example.test", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||||
}
|
||||
if pattern != "a***e@example.test" || length != 6 || sender.to != "alice@example.test" || len(sender.code) != 6 {
|
||||
t.Fatalf("send result pattern=%q length=%d to=%q code=%q", pattern, length, sender.to, sender.code)
|
||||
}
|
||||
if _, found, err := svc.LoginEmail(ctx, u.ID); err != nil || found {
|
||||
t.Fatalf("LoginEmail before verify found=%v err=%v, want not found", found, err)
|
||||
}
|
||||
email, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", sender.code, false)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyLoginEmail: %v", err)
|
||||
}
|
||||
if email != "alice@example.test" {
|
||||
t.Fatalf("verified email = %q", email)
|
||||
}
|
||||
got, found, err := svc.LoginEmail(ctx, u.ID)
|
||||
if err != nil || !found || got != "alice@example.test" {
|
||||
t.Fatalf("LoginEmail after verify = %q found=%v err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailSetupStoresPendingEmailOnPhoneCodeHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(memory.NewUserStore()),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||||
if err := codes.Set(ctx, "phone-hash", store.PhoneCode{Phone: "15550010006", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
|
||||
t.Fatalf("seed phone code: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, 0, "+1 555 001 0006", "phone-hash", "new@example.test", true); err != nil {
|
||||
t.Fatalf("SendLoginEmailCode setup: %v", err)
|
||||
}
|
||||
email, err := svc.VerifyLoginEmail(ctx, 0, "+1 555 001 0006", "phone-hash", sender.code, true)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyLoginEmail setup: %v", err)
|
||||
}
|
||||
if email != "new@example.test" {
|
||||
t.Fatalf("verified setup email = %q", email)
|
||||
}
|
||||
rec, found, err := codes.Get(ctx, "phone-hash")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("phone code found=%v err=%v", found, err)
|
||||
}
|
||||
if rec.Channel != "email_login" || rec.Code != sender.code || rec.Email != "new@example.test" || !rec.VerifiedEmail || rec.PendingEmail != "new@example.test" {
|
||||
t.Fatalf("phone code after verify = %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginEmailDeletesCodeAfterMaxAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||||
u := createUser(t, users, "15550010007")
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "limit@example.test", false); err != nil {
|
||||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||||
}
|
||||
bad1 := "000000"
|
||||
if bad1 == sender.code {
|
||||
bad1 = "111111"
|
||||
}
|
||||
bad2 := "222222"
|
||||
if bad2 == sender.code {
|
||||
bad2 = "333333"
|
||||
}
|
||||
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", bad1, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||||
t.Fatalf("first bad VerifyLoginEmail err = %v, want ErrEmailCodeInvalid", err)
|
||||
}
|
||||
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", bad2, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||||
t.Fatalf("second bad VerifyLoginEmail err = %v, want ErrEmailCodeInvalid", err)
|
||||
}
|
||||
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", sender.code, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||||
t.Fatalf("VerifyLoginEmail after max attempts err = %v, want ErrEmailCodeInvalid", err)
|
||||
}
|
||||
if _, found, _ := svc.LoginEmail(ctx, u.ID); found {
|
||||
t.Fatal("login email was set after exhausted verification code")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,26 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/mail"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
|
||||
|
||||
const (
|
||||
passwordResetWait = 7 * 24 * time.Hour
|
||||
passwordResetRetry = 24 * time.Hour
|
||||
passwordResetWait = 7 * 24 * time.Hour
|
||||
passwordResetRetry = 24 * time.Hour
|
||||
loginEmailVerifyChangePrefix = "login-email-change:"
|
||||
loginEmailVerifySetupPrefix = "login-email-setup:"
|
||||
codeChannelEmailSetup = "email_setup"
|
||||
codeChannelEmailChange = "email_change"
|
||||
codeChannelEmailLogin = "email_login"
|
||||
)
|
||||
|
||||
// Service 提供账号安全配置查询。
|
||||
|
|
@ -30,8 +37,13 @@ type Service struct {
|
|||
savedMusic store.SavedMusicStore
|
||||
business store.BusinessAutomationStore
|
||||
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
||||
users store.UserStore
|
||||
publicBaseURL string
|
||||
users store.UserStore
|
||||
publicBaseURL string
|
||||
codes store.CodeStore
|
||||
loginEmailSender mail.Sender
|
||||
loginEmailCodeTTL time.Duration
|
||||
loginEmailCodeMaxAttempts int
|
||||
loginEmailCodeLength int
|
||||
}
|
||||
|
||||
// ServiceOption 调整 account 服务依赖。
|
||||
|
|
@ -99,9 +111,25 @@ func WithPublicBaseURL(baseURL string) ServiceOption {
|
|||
}
|
||||
}
|
||||
|
||||
func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.codes = codes
|
||||
s.loginEmailSender = sender
|
||||
if ttl > 0 {
|
||||
s.loginEmailCodeTTL = ttl
|
||||
}
|
||||
if maxAttempts > 0 {
|
||||
s.loginEmailCodeMaxAttempts = maxAttempts
|
||||
}
|
||||
if length > 0 {
|
||||
s.loginEmailCodeLength = length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL}
|
||||
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL, loginEmailCodeTTL: 5 * time.Minute, loginEmailCodeMaxAttempts: 5, loginEmailCodeLength: 6}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
@ -200,6 +228,7 @@ func normalizePasswordSettings(settings domain.PasswordSettings) domain.Password
|
|||
}
|
||||
// login_email_pattern 始终从已确认的登录邮箱派生,与 2FA 恢复邮箱 RecoveryEmail
|
||||
// 解耦(历史实现曾把恢复邮箱掩码误写进此字段,导致客户端把恢复邮箱当成登录邮箱显示)。
|
||||
settings.LoginEmail = normalizeLoginEmail(settings.LoginEmail)
|
||||
settings.LoginEmailPattern = emailPattern(settings.LoginEmail)
|
||||
return settings
|
||||
}
|
||||
|
|
@ -467,25 +496,195 @@ func randomInt64() (int64, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func randomDigits(n int) (string, error) {
|
||||
if n <= 0 {
|
||||
n = 6
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(n)
|
||||
for _, v := range b {
|
||||
out.WriteByte(byte('0') + v%10)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func emailPattern(email string) string {
|
||||
return domain.MaskEmail(email)
|
||||
}
|
||||
|
||||
func normalizeLoginEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// validLoginEmail 是登录邮箱的最小校验:非空且含 '@'。开发环境不做更严格的 RFC 校验。
|
||||
func validLoginEmail(email string) bool {
|
||||
email = normalizeLoginEmail(email)
|
||||
return email != "" && strings.Contains(email, "@")
|
||||
}
|
||||
|
||||
func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, phoneCodeHash, email string, setup bool) (string, int, error) {
|
||||
email = normalizeLoginEmail(email)
|
||||
if !validLoginEmail(email) {
|
||||
return "", 0, domain.ErrEmailInvalid
|
||||
}
|
||||
if s == nil || s.codes == nil || s.loginEmailSender == nil {
|
||||
return "", 0, domain.ErrEmailNotAllowed
|
||||
}
|
||||
key := loginEmailVerifyChangePrefix + fmt.Sprint(userID)
|
||||
rec := store.PhoneCode{
|
||||
Code: "",
|
||||
Channel: codeChannelEmailChange,
|
||||
PendingEmail: email,
|
||||
MaxAttempts: s.loginEmailCodeMaxAttempts,
|
||||
}
|
||||
if setup {
|
||||
phone = domain.NormalizePhone(phone)
|
||||
phoneRec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if !found {
|
||||
return "", 0, domain.ErrEmailCodeInvalid
|
||||
}
|
||||
if phoneRec.Phone != phone {
|
||||
return "", 0, domain.ErrEmailInvalid
|
||||
}
|
||||
targetUserID := int64(0)
|
||||
if existingUserID, found, err := s.userIDByPhone(ctx, phone); err != nil {
|
||||
return "", 0, err
|
||||
} else if found {
|
||||
targetUserID = existingUserID
|
||||
}
|
||||
if err := s.ensureLoginEmailAvailable(ctx, targetUserID, email); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
key = loginEmailVerifySetupPrefix + phoneCodeHash
|
||||
rec.Phone = phone
|
||||
rec.Channel = codeChannelEmailSetup
|
||||
} else if userID == 0 {
|
||||
return "", 0, domain.ErrEmailInvalid
|
||||
} else if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
code, err := randomDigits(s.loginEmailCodeLength)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
rec.Code = code
|
||||
if err := s.codes.Set(ctx, key, rec, s.loginEmailCodeTTL); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil {
|
||||
_ = s.codes.Del(ctx, key)
|
||||
return "", 0, err
|
||||
}
|
||||
return emailPattern(email), len(code), nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error) {
|
||||
if s == nil || s.codes == nil {
|
||||
return "", domain.ErrEmailNotAllowed
|
||||
}
|
||||
key := loginEmailVerifyChangePrefix + fmt.Sprint(userID)
|
||||
if setup {
|
||||
key = loginEmailVerifySetupPrefix + phoneCodeHash
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
return "", domain.ErrEmailCodeInvalid
|
||||
}
|
||||
if strings.TrimSpace(code) == "" || subtle.ConstantTimeCompare([]byte(rec.Code), []byte(strings.TrimSpace(code))) != 1 {
|
||||
return "", s.rejectEmailCode(ctx, key, rec)
|
||||
}
|
||||
email := normalizeLoginEmail(rec.PendingEmail)
|
||||
if !validLoginEmail(email) {
|
||||
_ = s.codes.Del(ctx, key)
|
||||
return "", domain.ErrEmailInvalid
|
||||
}
|
||||
if setup {
|
||||
phone = domain.NormalizePhone(phone)
|
||||
phoneRec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || phoneRec.Phone != phone {
|
||||
return "", domain.ErrEmailCodeInvalid
|
||||
}
|
||||
targetUserID := int64(0)
|
||||
if existingUserID, found, err := s.userIDByPhone(ctx, phone); err != nil {
|
||||
return "", err
|
||||
} else if found {
|
||||
targetUserID = existingUserID
|
||||
}
|
||||
if err := s.ensureLoginEmailAvailable(ctx, targetUserID, email); err != nil {
|
||||
_ = s.codes.Del(ctx, key)
|
||||
return "", err
|
||||
}
|
||||
_ = s.codes.Del(ctx, key)
|
||||
phoneRec.Channel = codeChannelEmailLogin
|
||||
phoneRec.Code = strings.TrimSpace(code)
|
||||
phoneRec.Email = email
|
||||
phoneRec.PendingEmail = email
|
||||
phoneRec.VerifiedEmail = true
|
||||
phoneRec.Attempts = 0
|
||||
phoneRec.MaxAttempts = s.loginEmailCodeMaxAttempts
|
||||
if err := s.codes.Update(ctx, phoneCodeHash, phoneRec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, found, err := s.userIDByPhone(ctx, phone); err != nil {
|
||||
return "", err
|
||||
} else if found {
|
||||
if err := s.SetLoginEmailByPhone(ctx, phone, email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
|
||||
_ = s.codes.Del(ctx, key)
|
||||
return "", err
|
||||
}
|
||||
_ = s.codes.Del(ctx, key)
|
||||
if err := s.SetLoginEmail(ctx, userID, email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (s *Service) rejectEmailCode(ctx context.Context, key string, rec store.PhoneCode) error {
|
||||
rec.Attempts++
|
||||
max := rec.MaxAttempts
|
||||
if max <= 0 {
|
||||
max = s.loginEmailCodeMaxAttempts
|
||||
}
|
||||
if max > 0 && rec.Attempts >= max {
|
||||
_ = s.codes.Del(ctx, key)
|
||||
return domain.ErrEmailCodeInvalid
|
||||
}
|
||||
_ = s.codes.Update(ctx, key, rec)
|
||||
return domain.ErrEmailCodeInvalid
|
||||
}
|
||||
|
||||
// SetLoginEmail 为已登录用户写入登录邮箱(authed 的 emailVerifyPurposeLoginChange)。
|
||||
// 账号无 2FA 也可设置:account_passwords 行可在 has_password=false 下仅承载登录邮箱。
|
||||
func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string) error {
|
||||
if s == nil || s.passwords == nil || userID == 0 {
|
||||
return domain.ErrEmailInvalid
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
email = normalizeLoginEmail(email)
|
||||
if !validLoginEmail(email) {
|
||||
return domain.ErrEmailInvalid
|
||||
}
|
||||
if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -520,7 +719,7 @@ func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, e
|
|||
if !found || settings.LoginEmail == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return settings.LoginEmail, true, nil
|
||||
return normalizeLoginEmail(settings.LoginEmail), true, nil
|
||||
}
|
||||
|
||||
// LoginEmailByPhone 按手机号返回登录邮箱原始地址(供 auth.sendCode 检测是否改投邮箱、
|
||||
|
|
@ -551,6 +750,24 @@ func (s *Service) ClearLoginEmailByPhone(ctx context.Context, phone string) erro
|
|||
return s.passwords.Save(ctx, userID, settings)
|
||||
}
|
||||
|
||||
func (s *Service) ensureLoginEmailAvailable(ctx context.Context, userID int64, email string) error {
|
||||
if s == nil || s.passwords == nil {
|
||||
return domain.ErrEmailInvalid
|
||||
}
|
||||
email = normalizeLoginEmail(email)
|
||||
if !validLoginEmail(email) {
|
||||
return domain.ErrEmailInvalid
|
||||
}
|
||||
ownerUserID, found, err := s.passwords.LoginEmailOwner(ctx, email)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
if ownerUserID != userID {
|
||||
return domain.ErrEmailOccupied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) userIDByPhone(ctx context.Context, phone string) (int64, bool, error) {
|
||||
if s == nil || s.users == nil {
|
||||
return 0, false, nil
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue