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
|
||||
|
|
|
|||
134
internal/app/auth/login_email_config_test.go
Normal file
134
internal/app/auth/login_email_config_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type testLoginEmailStore struct {
|
||||
emails map[string]string
|
||||
}
|
||||
|
||||
func (s *testLoginEmailStore) LoginEmailByPhone(_ context.Context, phone string) (string, bool, error) {
|
||||
email, ok := s.emails[domain.NormalizePhone(phone)]
|
||||
return email, ok, nil
|
||||
}
|
||||
|
||||
func (s *testLoginEmailStore) SetLoginEmailByPhone(_ context.Context, phone, email string) error {
|
||||
s.emails[domain.NormalizePhone(phone)] = email
|
||||
return nil
|
||||
}
|
||||
|
||||
type testMailSender struct {
|
||||
to string
|
||||
code string
|
||||
}
|
||||
|
||||
func (s *testMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
|
||||
s.to = to
|
||||
s.code = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009101", FirstName: "Email"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{"15550009101": "alice@example.test"}}
|
||||
sender := &testMailSender{}
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
CodeLength: 6,
|
||||
Store: emails,
|
||||
Sender: sender,
|
||||
}),
|
||||
WithCodeMaxAttempts(2))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+1 555 000 9101")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if sender.to != "alice@example.test" || len(sender.code) != 6 {
|
||||
t.Fatalf("sent email to/code = %q/%q, want alice@example.test/6 digits", sender.to, sender.code)
|
||||
}
|
||||
delivery, found, err := svc.CodeDelivery(ctx, hash)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("CodeDelivery found=%v err=%v", found, err)
|
||||
}
|
||||
if delivery.Kind != domain.AuthCodeDeliveryEmail || delivery.EmailPattern != "a***e@example.test" || delivery.Length != 6 {
|
||||
t.Fatalf("delivery = %+v, want email masked length 6", delivery)
|
||||
}
|
||||
bad1 := wrongCode(sender.code, '0')
|
||||
bad2 := wrongCode(sender.code, '1')
|
||||
if bad2 == bad1 {
|
||||
bad2 = wrongCode(sender.code, '2')
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad1); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("first bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad2); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("second bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, sender.code); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("SignInWithEmail after max attempts err = %v, want ErrCodeExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func wrongCode(code string, digit byte) string {
|
||||
if code == "" {
|
||||
return string(digit)
|
||||
}
|
||||
out := make([]byte, len(code))
|
||||
for i := range out {
|
||||
out[i] = digit
|
||||
}
|
||||
if string(out) != code {
|
||||
return string(out)
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = '9'
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009102", FirstName: "Email"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{"15550009102": "bob@example.test"}}
|
||||
sender := &testMailSender{}
|
||||
var key [8]byte
|
||||
key[0] = 0x91
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
CodeLength: 5,
|
||||
Store: emails,
|
||||
Sender: sender,
|
||||
}))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550009102")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009102", hash, sender.code)
|
||||
if err != nil {
|
||||
t.Fatalf("SignInWithEmail: %v", err)
|
||||
}
|
||||
if needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("SignInWithEmail got user=%d needSignUp=%v, want %d/false", got.ID, needSignUp, u.ID)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
mtcrypto "github.com/gotd/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mail"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -35,6 +36,12 @@ var (
|
|||
ErrSystemUserLoginForbidden = errors.New("system user login forbidden")
|
||||
)
|
||||
|
||||
const (
|
||||
codeChannelPhone = "phone"
|
||||
codeChannelEmailLogin = "email_login"
|
||||
codeChannelEmailSetupRequired = "email_setup_required"
|
||||
)
|
||||
|
||||
// validPhone 校验规范化后的手机号:5-32 位纯数字(上限对齐 users.phone 列宽)。
|
||||
// 核心目的是拒绝空/非数字 phone(防 0090 partial index 下无限铸造幽灵账号),
|
||||
// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone)。
|
||||
|
|
@ -61,21 +68,40 @@ func systemLoginPhoneForbidden(phone string) bool {
|
|||
|
||||
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
auths store.AuthorizationStore
|
||||
codes store.CodeStore
|
||||
authKeys store.AuthKeyStore
|
||||
tempKeys store.TempAuthKeyBindingStore
|
||||
passwords store.PasswordStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
bots store.BotStore
|
||||
fixedCode string
|
||||
codeTTL time.Duration
|
||||
users store.UserStore
|
||||
auths store.AuthorizationStore
|
||||
codes store.CodeStore
|
||||
authKeys store.AuthKeyStore
|
||||
tempKeys store.TempAuthKeyBindingStore
|
||||
passwords store.PasswordStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
bots store.BotStore
|
||||
fixedCode string
|
||||
codeTTL time.Duration
|
||||
codeMaxAttempts int
|
||||
loginEmails loginEmailStore
|
||||
loginEmailSender mail.Sender
|
||||
loginEmailEnabled bool
|
||||
loginEmailRequireSetup bool
|
||||
loginEmailCodeLength int
|
||||
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
|
||||
premiumGrantMonths int
|
||||
}
|
||||
|
||||
type loginEmailStore interface {
|
||||
LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error)
|
||||
SetLoginEmailByPhone(ctx context.Context, phone, email string) error
|
||||
}
|
||||
|
||||
type LoginEmailOptions struct {
|
||||
Enabled bool
|
||||
RequireSetup bool
|
||||
CodeLength int
|
||||
Store loginEmailStore
|
||||
Sender mail.Sender
|
||||
}
|
||||
|
||||
type authorizationRevoker interface {
|
||||
RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
||||
RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
||||
|
|
@ -114,9 +140,38 @@ func WithPremiumGrant(months int) Option {
|
|||
}
|
||||
}
|
||||
|
||||
func WithCodeTTL(ttl time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if ttl > 0 {
|
||||
s.codeTTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithCodeMaxAttempts(max int) Option {
|
||||
return func(s *Service) {
|
||||
if max > 0 {
|
||||
s.codeMaxAttempts = max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoginEmail(opts LoginEmailOptions) Option {
|
||||
return func(s *Service) {
|
||||
s.loginEmailEnabled = opts.Enabled
|
||||
s.loginEmailRequireSetup = opts.RequireSetup
|
||||
s.loginEmailCodeLength = opts.CodeLength
|
||||
if s.loginEmailCodeLength <= 0 {
|
||||
s.loginEmailCodeLength = 6
|
||||
}
|
||||
s.loginEmails = opts.Store
|
||||
s.loginEmailSender = opts.Sender
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建登录服务。fixedCode 为开发固定验证码。
|
||||
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute}
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
@ -214,7 +269,8 @@ func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte)
|
|||
return s.auths.MarkPasswordPassed(ctx, authKeyID)
|
||||
}
|
||||
|
||||
// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。
|
||||
// SendCode 为 phone 生成 phone_code_hash,按配置选择开发 app code、登录邮箱 code
|
||||
// 或登录邮箱 setup-required 状态,返回 hash。
|
||||
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if !validPhone(phone) {
|
||||
|
|
@ -223,16 +279,105 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
|||
if systemLoginPhoneForbidden(phone) {
|
||||
return "", ErrSystemUserLoginForbidden
|
||||
}
|
||||
if s.loginEmailEnabled && s.loginEmails != nil {
|
||||
email, found, err := s.loginEmails.LoginEmailByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if found && strings.TrimSpace(email) != "" {
|
||||
return s.createEmailLoginCode(ctx, phone, email)
|
||||
}
|
||||
if s.loginEmailRequireSetup {
|
||||
return s.createSetupRequiredCode(ctx, phone)
|
||||
}
|
||||
}
|
||||
return s.createPhoneCode(ctx, phone)
|
||||
}
|
||||
|
||||
func (s *Service) createPhoneCode(ctx context.Context, phone string) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: phone, Code: s.fixedCode}, s.codeTTL); err != nil {
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||||
Phone: phone,
|
||||
Code: s.fixedCode,
|
||||
Channel: codeChannelPhone,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) createSetupRequiredCode(ctx context.Context, phone string) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||||
Phone: phone,
|
||||
Channel: codeChannelEmailSetupRequired,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
code, err := randomDigits(s.loginEmailCodeLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
Channel: codeChannelEmailLogin,
|
||||
Email: strings.TrimSpace(email),
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store email code: %w", err)
|
||||
}
|
||||
if s.loginEmailSender == nil {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return "", fmt.Errorf("login email sender is not configured")
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return "", fmt.Errorf("send login email code: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) {
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil || !found {
|
||||
return domain.AuthCodeDelivery{}, found, err
|
||||
}
|
||||
return codeDelivery(rec), true, nil
|
||||
}
|
||||
|
||||
func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
|
||||
switch rec.Channel {
|
||||
case codeChannelEmailLogin:
|
||||
return domain.AuthCodeDelivery{
|
||||
Kind: domain.AuthCodeDeliveryEmail,
|
||||
EmailPattern: domain.MaskEmail(rec.Email),
|
||||
Length: len(rec.Code),
|
||||
}
|
||||
case codeChannelEmailSetupRequired:
|
||||
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryEmailSetupRequired}
|
||||
default:
|
||||
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryPhone, Length: len(rec.Code)}
|
||||
}
|
||||
}
|
||||
|
||||
// ResendCode invalidates an existing code hash and sends a fresh code to the same phone.
|
||||
func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) {
|
||||
phone = normalizePhone(phone)
|
||||
|
|
@ -247,6 +392,12 @@ func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (
|
|||
return "", ErrCodeInvalid
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
if rec.Channel == codeChannelEmailLogin && strings.TrimSpace(rec.Email) != "" {
|
||||
return s.createEmailLoginCode(ctx, phone, rec.Email)
|
||||
}
|
||||
if rec.Channel == codeChannelEmailSetupRequired {
|
||||
return s.createSetupRequiredCode(ctx, phone)
|
||||
}
|
||||
return s.SendCode(ctx, phone)
|
||||
}
|
||||
|
||||
|
|
@ -280,9 +431,15 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
if !found {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone || rec.Code != code {
|
||||
if rec.Phone != phone || rec.Channel == codeChannelEmailSetupRequired {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel == codeChannelEmailLogin {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if rec.Code != code {
|
||||
return domain.User{}, domain.Message{}, false, s.rejectCode(ctx, phoneCodeHash, rec, ErrCodeInvalid)
|
||||
}
|
||||
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
|
|
@ -295,9 +452,10 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
}
|
||||
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开发环境接受任意非空邮箱码
|
||||
// (与短信固定码同口径,"随意输入");仍校验 phone_code_hash 有效、手机号匹配,并与短信
|
||||
// 登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号同样会停在 SESSION_PASSWORD_NEEDED。
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
|
||||
// 随机邮箱码;未开启该特性时仅保留旧开发路径的任意非空兼容。仍校验 phone_code_hash
|
||||
// 有效、手机号匹配,并与短信登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号
|
||||
// 同样会停在 SESSION_PASSWORD_NEEDED。
|
||||
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
|
|
@ -313,8 +471,15 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
|
|||
if rec.Phone != phone {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
if rec.Channel != codeChannelEmailLogin {
|
||||
if s.loginEmailEnabled {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
} else if rec.Code != strings.TrimSpace(code) {
|
||||
return domain.User{}, domain.Message{}, false, s.rejectCode(ctx, phoneCodeHash, rec, ErrCodeInvalid)
|
||||
}
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
|
|
@ -378,6 +543,12 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
if rec.Phone != phone {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel == codeChannelEmailSetupRequired {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
|
|
@ -398,6 +569,11 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) != "" && s.loginEmails != nil {
|
||||
if err := s.loginEmails.SetLoginEmailByPhone(ctx, phone, rec.PendingEmail); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
}
|
||||
if err := s.bind(ctx, auth, u.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
|
|
@ -525,6 +701,43 @@ func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byt
|
|||
return s.auths.UpdateLayer(ctx, authKeyID, layer)
|
||||
}
|
||||
|
||||
func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
|
||||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return domain.AuthKeyClientInfo{}, false, nil
|
||||
}
|
||||
key, found, err := s.authKeys.Get(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return domain.AuthKeyClientInfo{}, found, err
|
||||
}
|
||||
info := domain.AuthKeyClientInfo{
|
||||
Layer: key.Layer,
|
||||
DeviceModel: key.DeviceModel,
|
||||
Platform: key.Platform,
|
||||
SystemVersion: key.SystemVersion,
|
||||
APIID: key.APIID,
|
||||
AppVersion: key.AppVersion,
|
||||
}
|
||||
if info.Layer == 0 && info.DeviceModel == "" && info.Platform == "" &&
|
||||
info.SystemVersion == "" && info.APIID == 0 && info.AppVersion == "" {
|
||||
return domain.AuthKeyClientInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error {
|
||||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return nil
|
||||
}
|
||||
return s.authKeys.UpdateClientInfo(ctx, authKeyID, store.AuthKeyClientInfo{
|
||||
Layer: info.Layer,
|
||||
DeviceModel: info.DeviceModel,
|
||||
Platform: info.Platform,
|
||||
SystemVersion: info.SystemVersion,
|
||||
APIID: info.APIID,
|
||||
AppVersion: info.AppVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
|
||||
if s == nil || s.auths == nil || userID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -752,6 +965,20 @@ func authKeyIDInt64(id [8]byte) int64 {
|
|||
return int64(binary.LittleEndian.Uint64(id[:]))
|
||||
}
|
||||
|
||||
func (s *Service) rejectCode(ctx context.Context, hash string, rec store.PhoneCode, ret error) error {
|
||||
rec.Attempts++
|
||||
max := rec.MaxAttempts
|
||||
if max <= 0 {
|
||||
max = s.codeMaxAttempts
|
||||
}
|
||||
if max > 0 && rec.Attempts >= max {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return ret
|
||||
}
|
||||
_ = s.codes.Update(ctx, hash, rec)
|
||||
return ret
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
return domain.NormalizePhone(phone)
|
||||
}
|
||||
|
|
@ -764,6 +991,22 @@ func randomHex(n int) (string, error) {
|
|||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func randomDigits(n int) (string, error) {
|
||||
if n <= 0 {
|
||||
n = 6
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(n)
|
||||
for _, v := range b {
|
||||
out.WriteByte(byte('0') + v%10)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func randomInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue