338 lines
13 KiB
Go
338 lines
13 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/otpdelivery"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
|
|
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
|
|
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
|
|
// SessionID 被记录用于审计,但验证时不要求相等:同一设备在等待短信期间发生
|
|
// MTProto session 重建仍可完成流程;其它设备因 auth_key 不同无法复用。
|
|
func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) {
|
|
phone = domain.NormalizePhone(phone)
|
|
if !domain.ValidPhone(phone) {
|
|
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid
|
|
}
|
|
if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
} else if found && existing.ID != 0 {
|
|
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
|
|
}
|
|
if s.codes == nil {
|
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
|
}
|
|
// Email-as-identity mode: this server has no real SMS delivery at all, so
|
|
// letting an account switch to an arbitrary non-encoded number would just
|
|
// hand out the universal dev code with no real verification. Only numbers
|
|
// that decode back to an email are accepted; the code goes to that inbox.
|
|
if s.emailSignupEnabled {
|
|
return s.sendChangePhoneCodeByEmail(ctx, userID, authKeyID, sessionID, phone)
|
|
}
|
|
if s.phoneCodeSender == nil && strings.TrimSpace(s.phoneChangeCode) == "" {
|
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
|
}
|
|
hash, err := phoneChangeHash()
|
|
if err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
code := s.phoneChangeCode
|
|
channel := store.PhoneCodeChannelPhone
|
|
deliveryID := ""
|
|
if s.phoneCodeSender != nil {
|
|
code, err = randomDigits(s.phoneCodeLength)
|
|
if err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
deliveryID, err = otpdelivery.NewDeliveryID()
|
|
if err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
channel = store.PhoneCodeChannelSMS
|
|
}
|
|
rec := store.PhoneCode{
|
|
Version: store.PhoneCodeVersionCurrent,
|
|
Phone: phone,
|
|
Code: code,
|
|
DeliveryID: deliveryID,
|
|
Channel: channel,
|
|
Purpose: store.PhoneCodePurposeChangePhone,
|
|
UserID: userID,
|
|
AuthKeyID: authKeyID,
|
|
SessionID: sessionID,
|
|
MaxAttempts: s.phoneChangeMaxAttempts,
|
|
}
|
|
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
|
|
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
|
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
|
|
}
|
|
if s.phoneCodeSender != nil {
|
|
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
|
DeliveryID: deliveryID,
|
|
Purpose: otpdelivery.PurposeChangePhone,
|
|
Channel: otpdelivery.ChannelSMS,
|
|
Recipient: phone,
|
|
Code: code,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
|
defer cancel()
|
|
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
|
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change code: %w", cleanupErr))
|
|
}
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
}
|
|
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
|
|
}
|
|
|
|
func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) {
|
|
email, ok := domain.DecodeEmailPhone(phone)
|
|
if !ok {
|
|
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid
|
|
}
|
|
// The wire value never equals any stored users.phone once assigned (see
|
|
// ChangePhone), so the generic ByPhone occupied check above is a no-op
|
|
// for this path; the real "is this email already someone else's
|
|
// account" guard is by SignupEmail instead.
|
|
if existing, found, err := s.users.ByEmail(ctx, email); err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
} else if found && existing.ID != userID {
|
|
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
|
|
}
|
|
if s.loginEmailSender == nil {
|
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("email signup sender is not configured")
|
|
}
|
|
code, err := randomDigits(6)
|
|
if err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
hash, err := phoneChangeHash()
|
|
if err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
ttl := s.phoneChangeCodeTTL
|
|
deliveryID, err := otpdelivery.NewDeliveryID()
|
|
if err != nil {
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
rec := store.PhoneCode{
|
|
Version: store.PhoneCodeVersionCurrent,
|
|
Phone: phone,
|
|
Code: code,
|
|
DeliveryID: deliveryID,
|
|
Channel: store.PhoneCodeChannelEmailLogin,
|
|
Purpose: store.PhoneCodePurposeChangePhone,
|
|
Email: email,
|
|
UserID: userID,
|
|
AuthKeyID: authKeyID,
|
|
SessionID: sessionID,
|
|
MaxAttempts: s.phoneChangeMaxAttempts,
|
|
}
|
|
expiresAt := time.Now().Add(ttl)
|
|
if err := s.codes.Set(ctx, hash, rec, ttl); err != nil {
|
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
|
|
}
|
|
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
|
|
DeliveryID: deliveryID,
|
|
Purpose: otpdelivery.PurposeChangePhone,
|
|
Channel: otpdelivery.ChannelEmail,
|
|
Recipient: email,
|
|
Code: code,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
|
defer cancel()
|
|
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
|
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change email code: %w", cleanupErr))
|
|
}
|
|
return "", domain.AuthCodeDelivery{}, err
|
|
}
|
|
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryEmail, EmailPattern: emailPattern(email), Length: len(code)}, nil
|
|
}
|
|
|
|
// ChangePhone 验证作用域和验证码后执行原子改号。返回事件用于当前 session 的
|
|
// pts 簿记;其它 session 由 transactional outbox 投递 updateUserPhone。
|
|
func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, originRawAuthKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error) {
|
|
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeEmpty
|
|
}
|
|
phone = domain.NormalizePhone(phone)
|
|
if !domain.ValidPhone(phone) {
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
|
|
}
|
|
if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil {
|
|
return domain.PhoneChangeResult{}, err
|
|
}
|
|
if s.codes == nil || s.phoneChanges == nil {
|
|
return domain.PhoneChangeResult{}, fmt.Errorf("phone change service is not configured")
|
|
}
|
|
scope := store.PhoneCodeScope{
|
|
Purpose: store.PhoneCodePurposeChangePhone,
|
|
UserID: userID,
|
|
AuthKeyID: authKeyID,
|
|
Phone: phone,
|
|
}
|
|
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
|
|
if err != nil {
|
|
return domain.PhoneChangeResult{}, err
|
|
}
|
|
switch verified.Status {
|
|
case store.LoginCodeVerifyMissing:
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired
|
|
case store.LoginCodeVerifyInvalid:
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
|
case store.LoginCodeVerifyAccepted:
|
|
default:
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
|
}
|
|
if existing, occupied, err := s.users.ByPhone(ctx, phone); err != nil {
|
|
return domain.PhoneChangeResult{}, err
|
|
} else if occupied && existing.ID != userID {
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
|
}
|
|
consumed := verified.Record
|
|
channelOK := consumed.Channel == store.PhoneCodeChannelPhone ||
|
|
consumed.Channel == store.PhoneCodeChannelSMS ||
|
|
consumed.Channel == store.PhoneCodeChannelEmailLogin
|
|
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || !channelOK {
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
|
}
|
|
if date == 0 {
|
|
date = int(time.Now().Unix())
|
|
}
|
|
req := domain.PhoneChangeRequest{
|
|
UserID: userID,
|
|
Phone: phone,
|
|
Date: date,
|
|
// Authorization/code scope is the stable business (perm) key, while dispatch exclusion
|
|
// must use the physical raw key. They differ on PFS/temp connections; conflating them
|
|
// echoes updateUserPhone back to the initiating device and suppresses the wrong session.
|
|
ExcludeAuthKeyID: originRawAuthKeyID,
|
|
ExcludeSessionID: sessionID,
|
|
}
|
|
// Rebinding to a different email: the account keeps its short "888"
|
|
// display number (or gets a fresh one), never the long wire value —
|
|
// same reasoning as SignUp. ByPhone above is a no-op for this case since
|
|
// the wire value never equals a stored users.phone; the real
|
|
// already-bound-elsewhere guard is by SignupEmail.
|
|
if s.emailSignupEnabled && domain.IsEmailSignupPhone(phone) {
|
|
email, ok := domain.DecodeEmailPhone(phone)
|
|
if !ok {
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
|
|
}
|
|
email = domain.NormalizeEmailForPhone(email)
|
|
if existing, found, err := s.users.ByEmail(ctx, email); err != nil {
|
|
return domain.PhoneChangeResult{}, err
|
|
} else if found && existing.ID != userID {
|
|
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
|
}
|
|
displayPhone, err := s.assignEmailSignupDisplayPhone(ctx)
|
|
if err != nil {
|
|
return domain.PhoneChangeResult{}, err
|
|
}
|
|
req.Phone = displayPhone
|
|
req.SignupEmail = email
|
|
}
|
|
result, err := s.phoneChanges.ChangePhone(ctx, req)
|
|
if err != nil {
|
|
return domain.PhoneChangeResult{}, err
|
|
}
|
|
if s.userCache != nil && result.User.ID != 0 {
|
|
_ = s.userCache.Delete(ctx, []int64{result.User.ID})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID [8]byte) (domain.User, error) {
|
|
if s == nil || s.users == nil || s.authorizations == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
|
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
|
|
}
|
|
a, found, err := s.authorizations.ByAuthKey(ctx, authKeyID)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
if !found || a.UserID != userID || a.PasswordPending {
|
|
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
|
|
}
|
|
u, found, err := s.users.ByID(ctx, userID)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
if !found {
|
|
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
|
|
}
|
|
if u.Bot || domain.IsSystemUserID(u.ID) {
|
|
return domain.User{}, domain.ErrPhoneChangeForbidden
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// maxEmailSignupPhoneAttempts bounds assignEmailSignupDisplayPhone's
|
|
// collision-retry loop so a store failure can't spin forever.
|
|
const maxEmailSignupPhoneAttempts = 20
|
|
|
|
// assignEmailSignupDisplayPhone generates a short display number for an
|
|
// email-signup account rebinding to a new email (see
|
|
// domain.NewEmailSignupDisplayPhone / auth.Service's SignUp counterpart),
|
|
// re-rolling on the astronomically unlikely collision with an existing
|
|
// account's phone.
|
|
func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) {
|
|
prefix, err := randomEmailSignupPhonePrefix(s.emailSignupPhonePrefixes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for i := 0; i < maxEmailSignupPhoneAttempts; i++ {
|
|
candidate, err := domain.NewEmailSignupDisplayPhone(prefix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, found, err := s.users.ByPhone(ctx, candidate); err != nil {
|
|
return "", err
|
|
} else if !found {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
|
|
}
|
|
|
|
// randomEmailSignupPhonePrefix mirrors auth.Service's identical helper
|
|
// (unexported to each package, but must pick with the same fairness): pick
|
|
// one entry at random from prefixes, falling back to domain.EmailPhonePrefix
|
|
// ("888") when the list is empty.
|
|
func randomEmailSignupPhonePrefix(prefixes []string) (string, error) {
|
|
if len(prefixes) == 0 {
|
|
return domain.EmailPhonePrefix, nil
|
|
}
|
|
if len(prefixes) == 1 {
|
|
return prefixes[0], nil
|
|
}
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", fmt.Errorf("pick email signup phone prefix: %w", err)
|
|
}
|
|
idx := binary.LittleEndian.Uint64(b[:]) % uint64(len(prefixes))
|
|
return prefixes[idx], nil
|
|
}
|
|
|
|
func phoneChangeHash() (string, error) {
|
|
var raw [8]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", fmt.Errorf("generate phone change hash: %w", err)
|
|
}
|
|
return hex.EncodeToString(raw[:]), nil
|
|
}
|