Merge remote-tracking branch 'upstream/main' into dev
This commit is contained in:
commit
6b29556ef8
836 changed files with 1598388 additions and 64684 deletions
|
|
@ -14,11 +14,11 @@ import (
|
|||
"unicode/utf8"
|
||||
|
||||
"github.com/gotd/ige"
|
||||
"github.com/gotd/td/bin"
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
mtcrypto "github.com/iamxvbaba/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mail"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -27,6 +27,10 @@ var (
|
|||
ErrCodeExpired = errors.New("phone code expired or not found")
|
||||
ErrCodeInvalid = errors.New("phone code invalid")
|
||||
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
|
||||
ErrExpiresAtInvalid = errors.New("temporary auth key request expiry invalid")
|
||||
ErrTempAuthKeyEmpty = errors.New("temporary auth key missing or expired")
|
||||
ErrTempAuthKeyAlreadyBound = errors.New("temporary auth key already bound")
|
||||
ErrAuthKeyPermEmpty = errors.New("permanent auth key required")
|
||||
// ErrLoginCodeDeliveryUnavailable 表示已有账号的 app-code 没有可用的
|
||||
// durable message/event/outbox 投递边界。这是服务端配置错误,不能降级成
|
||||
// “继续返回 sentCode,等 signIn 后补发”。
|
||||
|
|
@ -44,9 +48,10 @@ var (
|
|||
)
|
||||
|
||||
const (
|
||||
codeChannelPhone = "phone"
|
||||
codeChannelEmailLogin = "email_login"
|
||||
codeChannelEmailSetupRequired = "email_setup_required"
|
||||
codeChannelPhone = store.PhoneCodeChannelPhone
|
||||
codeChannelSMS = store.PhoneCodeChannelSMS
|
||||
codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin
|
||||
codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired
|
||||
loginCodeRollbackTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
|
|
@ -66,7 +71,9 @@ func systemLoginPhoneForbidden(phone string) bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
|
||||
// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider
|
||||
// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable
|
||||
// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
auths store.AuthorizationStore
|
||||
|
|
@ -82,7 +89,10 @@ type Service struct {
|
|||
codeTTL time.Duration
|
||||
codeMaxAttempts int
|
||||
loginEmails loginEmailStore
|
||||
loginEmailSender mail.Sender
|
||||
loginEmailSender otpdelivery.Sender
|
||||
phoneCodeSender otpdelivery.Sender
|
||||
otpDeliveryFailure func(context.Context, otpdelivery.Request, error)
|
||||
phoneCodeLength int
|
||||
loginEmailEnabled bool
|
||||
loginEmailRequireSetup bool
|
||||
loginEmailCodeLength int
|
||||
|
|
@ -109,7 +119,7 @@ type LoginEmailOptions struct {
|
|||
RequireSetup bool
|
||||
CodeLength int
|
||||
Store loginEmailStore
|
||||
Sender mail.Sender
|
||||
Sender otpdelivery.Sender
|
||||
}
|
||||
|
||||
type authorizationRevoker interface {
|
||||
|
|
@ -207,9 +217,34 @@ func WithEmailSignupPhonePrefixes(prefixes []string) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithPhoneCodeDelivery enables an external SMS delivery provider. Existing
|
||||
// accounts keep their durable 777000 App-code and receive the same code through
|
||||
// the provider as an additional channel. A nil sender preserves development
|
||||
// behavior.
|
||||
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) Option {
|
||||
return func(s *Service) {
|
||||
s.phoneCodeSender = sender
|
||||
if length > 0 {
|
||||
s.phoneCodeLength = length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOTPDeliveryFailureObserver observes failures of an additional provider
|
||||
// delivery after an existing account already has a durable 777000 App-code.
|
||||
// Observers must not log the recipient or code.
|
||||
func WithOTPDeliveryFailureObserver(observer func(context.Context, otpdelivery.Request, error)) Option {
|
||||
return func(s *Service) {
|
||||
s.otpDeliveryFailure = observer
|
||||
}
|
||||
}
|
||||
|
||||
// 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, codeMaxAttempts: 5, loginEmailCodeLength: 6}
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6, phoneCodeLength: 5}
|
||||
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
|
||||
linker.LinkAuthKeyAuthority(authKeys)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
@ -219,26 +254,44 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
|
|||
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
|
||||
if s.authKeys != nil {
|
||||
inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binding.TempSessionID = inner.TempSessionID
|
||||
// The bind request's expires_at is a signed client assertion. TDesktop
|
||||
// intentionally adds a small grace interval, while Android derives its
|
||||
// value at handshake completion. Retention and edge admission must use the
|
||||
// server's p_q_inner_data_temp lifetime, never the client value.
|
||||
binding.ExpiresAt = protocolExpiresAt
|
||||
}
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
// The edge may admit the frame immediately before the temporary key's
|
||||
// absolute boundary and the encrypted proof may cross it. This is a temp-key
|
||||
// rotation condition, never a destructive permanent-key proof failure.
|
||||
return ErrTempAuthKeyEmpty
|
||||
}
|
||||
if s.tempKeys == nil {
|
||||
return nil
|
||||
}
|
||||
return s.tempKeys.Save(ctx, binding)
|
||||
if err := s.tempKeys.Save(ctx, binding); err != nil {
|
||||
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
return ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
return s.classifyBindingStoreInvalid(ctx, binding)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
|
||||
//
|
||||
// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey):
|
||||
// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝;perm 仍授权则继续解析,
|
||||
// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效
|
||||
// (不以 perm 授权豁免),但收紧前需先核实目标客户端(TDesktop/DrKLO)会在过期前主动
|
||||
// 轮换 temp key 并优雅处理拒绝,否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired
|
||||
// 已把残留窗口限制在 expires_at + 宽限(约 24h)内。收紧为显式硬化任务,需客户端验证。
|
||||
// temp→perm 是握手/绑定形成的协议身份关系,与 perm 当前是否登录完全无关。即使
|
||||
// auth.logOut 已删除 authorization,只要绑定仍存在,后续登录 RPC 也必须继续落到同一
|
||||
// perm key,绝不能把 raw temp key 当成新的业务身份。协议过期由 mtprotoedge 在解密/RPC
|
||||
// 之前返回 -404 并关闭连接;这里不再用 authorization 状态猜测 key 类型。
|
||||
func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) {
|
||||
if s == nil || s.tempKeys == nil {
|
||||
return [8]byte{}, false, nil
|
||||
|
|
@ -247,19 +300,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
|
|||
if err != nil || !found {
|
||||
return [8]byte{}, found, err
|
||||
}
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) {
|
||||
return [8]byte{}, false, nil
|
||||
}
|
||||
return permID, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool {
|
||||
if s == nil || s.auths == nil {
|
||||
return false
|
||||
}
|
||||
_, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
return err == nil && found
|
||||
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
|
||||
}
|
||||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||||
|
|
@ -430,32 +471,68 @@ func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUse
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||||
code := s.fixedCode
|
||||
channel := codeChannelPhone
|
||||
deliveryID := ""
|
||||
if s.phoneCodeSender != nil {
|
||||
code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
deliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
channel = codeChannelSMS
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: existingUserID,
|
||||
Phone: phone,
|
||||
Code: s.fixedCode,
|
||||
Channel: codeChannelPhone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: channel,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}, s.codeTTL); err != nil {
|
||||
}
|
||||
expiresAt := time.Now().Add(s.codeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 新手机号还没有 owner/dialog,只能在 SignUp 创建用户后写第一条
|
||||
// 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code
|
||||
// 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。
|
||||
if existingUserID == 0 {
|
||||
// Existing accounts always retain the original durable App-code path. Commit
|
||||
// it before attempting the external mirror so a provider cannot replace the
|
||||
// message fact or leave an externally disclosed code without local state.
|
||||
if existingUserID != 0 {
|
||||
if err := s.deliverLoginCode(ctx, existingUserID, hash, code); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
request := otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeLoginSMS,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: phone,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if existingUserID != 0 {
|
||||
s.deliverOTPWithAppFallback(ctx, s.phoneCodeSender, request)
|
||||
} else if err := deliverOTP(ctx, s.phoneCodeSender, request); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login SMS code: %w", err))
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 新手机号还没有 owner/dialog,不能在签发阶段创建 777000 消息;
|
||||
// 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
|
|
@ -521,25 +598,60 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
deliveryID, err := otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: issuedUserID,
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: codeChannelEmailLogin,
|
||||
Email: strings.TrimSpace(email),
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}
|
||||
expiresAt := time.Now().Add(s.codeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store email code: %w", err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if issuedUserID != 0 {
|
||||
if err := s.deliverLoginCode(ctx, issuedUserID, hash, code); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if s.loginEmailSender == nil {
|
||||
if issuedUserID != 0 {
|
||||
s.reportOTPDeliveryFailure(ctx, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeLoginEmail,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: rec.Email,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}, fmt.Errorf("login email sender is not configured"))
|
||||
return hash, nil
|
||||
}
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured"))
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
|
||||
request := otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeLoginEmail,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: rec.Email,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if issuedUserID != 0 {
|
||||
s.deliverOTPWithAppFallback(ctx, s.loginEmailSender, request)
|
||||
} else if err := deliverOTP(ctx, s.loginEmailSender, request); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err))
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
|
|
@ -548,6 +660,34 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
|
|||
return hash, nil
|
||||
}
|
||||
|
||||
// deliverOTPWithAppFallback performs an additional provider delivery only
|
||||
// after the same code is durably visible through 777000. A provider failure
|
||||
// must not invalidate that visible code or fail the RPC; it remains observable
|
||||
// through the injected failure observer.
|
||||
func (s *Service) deliverOTPWithAppFallback(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) {
|
||||
if _, err := sender.Deliver(ctx, req); err != nil {
|
||||
s.reportOTPDeliveryFailure(ctx, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) reportOTPDeliveryFailure(ctx context.Context, req otpdelivery.Request, err error) {
|
||||
if s.otpDeliveryFailure != nil && err != nil {
|
||||
s.otpDeliveryFailure(ctx, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverOTP treats a transport-level unknown outcome as a successful issue:
|
||||
// the provider may already have accepted the request, so the code must remain
|
||||
// usable and the client needs the hash in order to verify or explicitly resend
|
||||
// it. Only an explicit provider rejection is safe to roll back.
|
||||
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
|
||||
_, err := sender.Deliver(ctx, req)
|
||||
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -561,6 +701,8 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
|
|||
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
|
||||
}
|
||||
switch rec.Channel {
|
||||
case codeChannelSMS:
|
||||
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
|
||||
case codeChannelEmailLogin:
|
||||
return domain.AuthCodeDelivery{
|
||||
Kind: domain.AuthCodeDeliveryEmail,
|
||||
|
|
@ -640,7 +782,7 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon
|
|||
if rec.Channel == codeChannelEmailSetupRequired {
|
||||
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
|
||||
}
|
||||
if rec.Channel != codeChannelPhone {
|
||||
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
|
||||
|
|
@ -652,14 +794,44 @@ func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCo
|
|||
return "", err
|
||||
}
|
||||
rec.Code = s.fixedCode
|
||||
rec.DeliveryID = ""
|
||||
rec.Channel = codeChannelPhone
|
||||
if s.phoneCodeSender != nil {
|
||||
rec.Code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec.DeliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec.Channel = codeChannelSMS
|
||||
}
|
||||
rec.Attempts = 0
|
||||
if rec.MaxAttempts <= 0 {
|
||||
rec.MaxAttempts = s.codeMaxAttempts
|
||||
}
|
||||
expiresAt := time.Now().Add(s.codeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store resent phone change code: %w", err)
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
||||
DeliveryID: rec.DeliveryID,
|
||||
Purpose: otpdelivery.PurposeChangePhone,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: rec.Phone,
|
||||
Code: rec.Code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
|
||||
defer cancel()
|
||||
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||
return "", errors.Join(err, fmt.Errorf("rollback undelivered phone change code: %w", cleanupErr))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +966,7 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code, false)
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
|
|
@ -804,17 +976,17 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
return s.finishSignIn(ctx, auth, existing)
|
||||
}
|
||||
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
|
||||
// 随机邮箱码;未开启该特性时仍允许旧客户端把 phone channel 放进
|
||||
// email_verification,但必须精确匹配该 phone code,不能再接受任意非空值。
|
||||
// 两条路径共用 owner 绑定、原子尝试计数与 2FA 门控。
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn。它与 SignIn
|
||||
// 共享同一个登录凭证状态机:TDesktop/Android 把邮箱码放在
|
||||
// email_verification,WebK 把同一邮箱码放在 phone_code;TL 字段只是 proof
|
||||
// carrier,服务端签发记录的 channel 才表示实际投递渠道。所有渠道都必须精确
|
||||
// 匹配签发码,并共用 owner 绑定、原子尝试计数、一次性消费与 2FA 门控。
|
||||
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) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code), true)
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code))
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
|
|
@ -828,7 +1000,7 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
|
|||
// CodeStore verification. The phone owner is read both before and after that
|
||||
// linearization point. A hash issued for an unregistered number therefore can
|
||||
// never authorize whichever account happens to acquire that number later.
|
||||
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string, emailPath bool) (store.PhoneCode, domain.User, bool, error) {
|
||||
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string) (store.PhoneCode, domain.User, bool, error) {
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, err
|
||||
|
|
@ -843,11 +1015,7 @@ func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, cod
|
|||
if rec.Phone != phone || rec.Purpose != "" {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
|
||||
if emailPath {
|
||||
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
|
||||
}
|
||||
if !channelAllowed {
|
||||
if !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
|
||||
|
|
@ -990,7 +1158,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
|
||||
if !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
// Email-signup accounts (888-encoded phone) already proved ownership of
|
||||
|
|
@ -1017,7 +1185,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
rec = consumed
|
||||
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
|
||||
if rec.IssuedUserID != 0 || !rec.SignUpVerified || !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
|
||||
|
|
@ -1081,10 +1249,11 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
loginMessage := domain.Message{}
|
||||
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
|
||||
// the normal phone/app-code registration path creates the bootstrap dialog
|
||||
// carrying the actual code; every account additionally gets the
|
||||
// welcome message below regardless of channel.
|
||||
// A new account has no owner/dialog at issuance time. Only the development
|
||||
// phone/App registration path creates its bootstrap 777000 message here;
|
||||
// external SMS, email setup, and email-signup registration retain only
|
||||
// their verified fact — every account additionally gets the welcome
|
||||
// message below regardless of channel.
|
||||
if rec.Channel == codeChannelPhone {
|
||||
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||||
if err != nil {
|
||||
|
|
@ -1204,13 +1373,6 @@ func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.
|
|||
return s.auths.ByAuthKey(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byte, layer int) error {
|
||||
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) || layer <= 0 {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
|
|
@ -1220,12 +1382,13 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
|
|||
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,
|
||||
Layer: key.Layer,
|
||||
LayerObservationID: key.LayerObservationID,
|
||||
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 == "" {
|
||||
|
|
@ -1249,7 +1412,15 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
|
|||
return err
|
||||
}
|
||||
if s.auths != nil {
|
||||
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
|
||||
// Layer is an ordered protocol fact. Its authorization-table mirror is
|
||||
// advanced atomically by the durable Layer evidence/bind transactions.
|
||||
// A generic metadata update is deliberately two-store and can race such
|
||||
// a transaction, so it must never write an older Layer after the primary
|
||||
// auth_keys row has already advanced.
|
||||
authorizationInfo := info
|
||||
authorizationInfo.Layer = 0
|
||||
authorizationInfo.LayerObservationID = 0
|
||||
return s.auths.UpdateClientInfo(ctx, authKeyID, authorizationInfo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1340,11 +1511,29 @@ func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64,
|
|||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
if s.authKeys != nil {
|
||||
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Defense in depth: Router normally converts a bound temp key to its perm
|
||||
// identity and edge rejects expired temp keys. Never let an unbound/sticky
|
||||
// temp key create authorization even if either outer boundary regresses.
|
||||
if !found || key.ExpiresAt != 0 {
|
||||
return ErrAuthKeyPermEmpty
|
||||
}
|
||||
}
|
||||
auth.UserID = userID
|
||||
// Bind 是授权切换的持久化状态边界:生产 store 会先清同 auth key 的旧用户
|
||||
// update state,再原子建立新用户 baseline。RPC 层不得在 Bind 成功后清整个 key,
|
||||
// 否则会把刚建立的 retained-floor checkpoint 一并删除。
|
||||
return s.auths.Bind(ctx, auth)
|
||||
if err := s.auths.Bind(ctx, auth); err != nil {
|
||||
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||
return ErrAuthKeyPermEmpty
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error) {
|
||||
|
|
@ -1418,32 +1607,60 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
}
|
||||
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
|
||||
// to a still-live binding. It may never create or reclassify a protocol key;
|
||||
// the caller normalizes durable retention to this handshake-authoritative
|
||||
// temp.ExpiresAt instead of trusting the client value.
|
||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
}
|
||||
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
}
|
||||
if !found {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
if !found || perm.ExpiresAt != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if inner.Nonce != binding.Nonce ||
|
||||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
||||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
||||
inner.TempSessionID != sessionID ||
|
||||
inner.ExpiresAt != binding.ExpiresAt {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
}
|
||||
return inner, nil
|
||||
if temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
}
|
||||
return inner, temp.ExpiresAt, nil
|
||||
}
|
||||
|
||||
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
|
||||
if s == nil || s.authKeys == nil {
|
||||
return ErrEncryptedMessageInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return ErrTempAuthKeyEmpty
|
||||
}
|
||||
return ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue