1572 lines
56 KiB
Go
1572 lines
56 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/aes"
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/gotd/ige"
|
||
"github.com/iamxvbaba/td/bin"
|
||
mtcrypto "github.com/iamxvbaba/td/crypto"
|
||
|
||
"telesrv/internal/branding"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/otpdelivery"
|
||
"telesrv/internal/store"
|
||
)
|
||
|
||
// 登录错误。
|
||
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 后补发”。
|
||
ErrLoginCodeDeliveryUnavailable = errors.New("login code durable delivery unavailable")
|
||
// ErrLoginCodeDeliveryFailed 表示 durable 投递未成功。SendCode/ResendCode
|
||
// 必须同时撤销刚写入的 CodeStore hash,防止客户拿到无法送达的码。
|
||
ErrLoginCodeDeliveryFailed = errors.New("login code durable delivery failed")
|
||
// ErrPhoneNumberInvalid 表示手机号为空或非纯数字/长度越界。
|
||
// 0090 把 users.phone 唯一约束改为忽略空串的部分索引(bot 行 phone=''),
|
||
// 因此 phone 校验必须前移到 auth 入口,否则 sendCode/signUp 可无限铸造
|
||
// phone='' 的幽灵人类账号(且因 ByPhone('') 短路永远无法再登录)。
|
||
ErrPhoneNumberInvalid = errors.New("phone number invalid")
|
||
// ErrSystemUserLoginForbidden 表示内置系统账号被尝试绑定为普通业务会话。
|
||
ErrSystemUserLoginForbidden = errors.New("system user login forbidden")
|
||
)
|
||
|
||
const (
|
||
codeChannelPhone = store.PhoneCodeChannelPhone
|
||
codeChannelSMS = store.PhoneCodeChannelSMS
|
||
codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin
|
||
codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired
|
||
loginCodeRollbackTimeout = 2 * time.Second
|
||
)
|
||
|
||
// validPhone 校验规范化后的手机号:5-32 位纯数字(上限对齐 users.phone 列宽)。
|
||
// 核心目的是拒绝空/非数字 phone(防 0090 partial index 下无限铸造幽灵账号),
|
||
// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone)。
|
||
func validPhone(phone string) bool {
|
||
return domain.ValidPhone(phone)
|
||
}
|
||
|
||
func systemUserLoginForbidden(u domain.User) bool {
|
||
return domain.IsSystemUserID(u.ID)
|
||
}
|
||
|
||
func systemLoginPhoneForbidden(phone string) bool {
|
||
_, ok := domain.SystemUserByPhone(phone)
|
||
return ok
|
||
}
|
||
|
||
// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider
|
||
// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable
|
||
// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。
|
||
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
|
||
loginCodeDelivery store.LoginCodeDeliveryStore
|
||
bots store.BotStore
|
||
fixedCode string
|
||
codeTTL time.Duration
|
||
codeMaxAttempts int
|
||
loginEmails loginEmailStore
|
||
loginEmailSender otpdelivery.Sender
|
||
phoneCodeSender otpdelivery.Sender
|
||
otpDeliveryFailure func(context.Context, otpdelivery.Request, error)
|
||
phoneCodeLength int
|
||
loginEmailEnabled bool
|
||
loginEmailRequireSetup bool
|
||
loginEmailCodeLength int
|
||
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
|
||
premiumGrantMonths int
|
||
}
|
||
|
||
type loginEmailStore interface {
|
||
LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error)
|
||
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
||
}
|
||
|
||
type LoginEmailOptions struct {
|
||
Enabled bool
|
||
RequireSetup bool
|
||
CodeLength int
|
||
Store loginEmailStore
|
||
Sender otpdelivery.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)
|
||
}
|
||
|
||
// Option 调整登录服务的可选依赖。
|
||
type Option func(*Service)
|
||
|
||
// WithLoginMessages 在新用户注册成功后写入官方系统账号的首条登录消息与会话摘要。
|
||
// 已有账号的 app 验证码必须在 auth.sendCode/resendCode 阶段通过
|
||
// WithLoginCodeDelivery 持久化,禁止在 signIn 成功后补发。
|
||
func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option {
|
||
return func(s *Service) {
|
||
s.messages = messages
|
||
s.dialogs = dialogs
|
||
}
|
||
}
|
||
|
||
// WithLoginCodeDelivery 注入已有账号 app-code 的 durable 投递边界。
|
||
// 实现必须以 user_id + phone_code_hash 幂等,并原子写入 777000
|
||
// message/dialog/user update event/dispatch outbox。
|
||
func WithLoginCodeDelivery(delivery store.LoginCodeDeliveryStore) Option {
|
||
return func(s *Service) {
|
||
s.loginCodeDelivery = delivery
|
||
}
|
||
}
|
||
|
||
// WithPasswords lets sign-in stop at SESSION_PASSWORD_NEEDED for 2FA accounts.
|
||
func WithPasswords(passwords store.PasswordStore) Option {
|
||
return func(s *Service) {
|
||
s.passwords = passwords
|
||
}
|
||
}
|
||
|
||
// WithBotLogin 启用 auth.importBotAuthorization 的 bot token 登录。
|
||
func WithBotLogin(bots store.BotStore) Option {
|
||
return func(s *Service) {
|
||
s.bots = bots
|
||
}
|
||
}
|
||
|
||
// WithPremiumGrant 让新注册账号默认获得 months 个月会员(0 = 关闭赠送)。
|
||
// 存量账号的同等赠送由迁移 0094 一次性 backfill。
|
||
func WithPremiumGrant(months int) Option {
|
||
return func(s *Service) {
|
||
s.premiumGrantMonths = months
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// 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, phoneCodeLength: 5}
|
||
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
|
||
linker.LinkAuthKeyAuthority(authKeys)
|
||
}
|
||
for _, opt := range opts {
|
||
opt(s)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// 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, 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
|
||
}
|
||
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。
|
||
//
|
||
// 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
|
||
}
|
||
binding, found, err := s.tempKeys.GetByTemp(ctx, authKeyID)
|
||
if err != nil || !found {
|
||
return [8]byte{}, found, err
|
||
}
|
||
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
|
||
}
|
||
|
||
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||
if s == nil || s.auths == nil {
|
||
return 0, false, nil
|
||
}
|
||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||
if err != nil || !found {
|
||
return 0, found, err
|
||
}
|
||
if a.PasswordPending {
|
||
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
|
||
return 0, false, nil
|
||
}
|
||
if domain.IsSystemUserID(a.UserID) {
|
||
_ = s.auths.Delete(ctx, authKeyID)
|
||
return 0, false, nil
|
||
}
|
||
return a.UserID, true, nil
|
||
}
|
||
|
||
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
|
||
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
|
||
func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||
if s == nil || s.auths == nil {
|
||
return 0, false, nil
|
||
}
|
||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||
if err != nil || !found || !a.PasswordPending {
|
||
return 0, false, err
|
||
}
|
||
if domain.IsSystemUserID(a.UserID) {
|
||
_ = s.auths.Delete(ctx, authKeyID)
|
||
return 0, false, nil
|
||
}
|
||
return a.UserID, true, nil
|
||
}
|
||
|
||
// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
||
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error {
|
||
if s == nil || s.auths == nil {
|
||
return nil
|
||
}
|
||
return s.auths.MarkPasswordPassed(ctx, authKeyID)
|
||
}
|
||
|
||
// 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) {
|
||
return "", ErrPhoneNumberInvalid
|
||
}
|
||
if systemLoginPhoneForbidden(phone) {
|
||
return "", ErrSystemUserLoginForbidden
|
||
}
|
||
existing, found, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return "", fmt.Errorf("lookup login-code recipient: %w", err)
|
||
}
|
||
if found && systemUserLoginForbidden(existing) {
|
||
return "", ErrSystemUserLoginForbidden
|
||
}
|
||
issuedUserID := int64(0)
|
||
if found {
|
||
issuedUserID = existing.ID
|
||
}
|
||
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, issuedUserID)
|
||
}
|
||
if s.loginEmailRequireSetup {
|
||
return s.createSetupRequiredCode(ctx, phone, issuedUserID)
|
||
}
|
||
}
|
||
return s.createPhoneCode(ctx, phone, issuedUserID)
|
||
}
|
||
|
||
func (s *Service) currentPhoneOwner(ctx context.Context, phone string) (domain.User, bool, error) {
|
||
if s == nil || s.users == nil {
|
||
return domain.User{}, false, fmt.Errorf("user store is not configured")
|
||
}
|
||
return s.users.ByPhone(ctx, phone)
|
||
}
|
||
|
||
func (s *Service) issuedOwnerMatches(ctx context.Context, phone string, issuedUserID int64) (bool, error) {
|
||
current, found, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
currentUserID := int64(0)
|
||
if found {
|
||
currentUserID = current.ID
|
||
}
|
||
return currentUserID == issuedUserID, nil
|
||
}
|
||
|
||
func (s *Service) ensureIssuedOwnerAfterSet(ctx context.Context, hash string, rec store.PhoneCode) error {
|
||
matches, err := s.issuedOwnerMatches(ctx, rec.Phone, rec.IssuedUserID)
|
||
if err == nil && matches {
|
||
return nil
|
||
}
|
||
cause := err
|
||
if cause == nil {
|
||
cause = ErrCodeInvalid
|
||
}
|
||
return s.rollbackUndeliveredCode(ctx, hash, cause)
|
||
}
|
||
|
||
func (s *Service) invalidateLoginCodeDetached(ctx context.Context, hash, phone string) {
|
||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
|
||
defer cancel()
|
||
_, _ = s.codes.InvalidateLoginCode(cleanupCtx, hash, phone)
|
||
}
|
||
|
||
func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUserID int64) (string, error) {
|
||
hash, err := randomHex(8)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
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: code,
|
||
DeliveryID: deliveryID,
|
||
Channel: channel,
|
||
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 code: %w", err)
|
||
}
|
||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||
return "", err
|
||
}
|
||
// 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
|
||
}
|
||
// 新手机号还没有 owner/dialog,不能在签发阶段创建 777000 消息;
|
||
// 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。
|
||
return hash, nil
|
||
}
|
||
|
||
func (s *Service) deliverLoginCode(ctx context.Context, userID int64, phoneCodeHash, code string) error {
|
||
if s.loginCodeDelivery == nil {
|
||
return ErrLoginCodeDeliveryUnavailable
|
||
}
|
||
now := time.Now()
|
||
if _, err := s.loginCodeDelivery.DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
|
||
UserID: userID,
|
||
PhoneCodeHash: phoneCodeHash,
|
||
Code: code,
|
||
Date: int(now.Unix()),
|
||
ExpiresAt: now.Add(s.codeTTL).Unix(),
|
||
}); err != nil {
|
||
return errors.Join(ErrLoginCodeDeliveryFailed, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) rollbackUndeliveredCode(ctx context.Context, phoneCodeHash string, cause error) error {
|
||
// lib/pq can report an I/O failure after COMMIT reached PostgreSQL. In that
|
||
// state deleting the code could turn an already delivered 777000 message
|
||
// into an unusable login attempt. Preserve it and let the delivery receipt
|
||
// make the retry idempotent.
|
||
if errors.Is(cause, domain.ErrLoginCodeDeliveryCommitAmbiguous) {
|
||
return cause
|
||
}
|
||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
|
||
defer cancel()
|
||
if err := s.codes.Del(cleanupCtx, phoneCodeHash); err != nil {
|
||
return errors.Join(cause, fmt.Errorf("rollback undelivered login code: %w", err))
|
||
}
|
||
return cause
|
||
}
|
||
|
||
func (s *Service) createSetupRequiredCode(ctx context.Context, phone string, issuedUserID int64) (string, error) {
|
||
hash, err := randomHex(8)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||
Version: store.PhoneCodeVersionCurrent,
|
||
IssuedUserID: issuedUserID,
|
||
Phone: phone,
|
||
Channel: codeChannelEmailSetupRequired,
|
||
MaxAttempts: s.codeMaxAttempts,
|
||
}, s.codeTTL); err != nil {
|
||
return "", fmt.Errorf("store code: %w", err)
|
||
}
|
||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, store.PhoneCode{Phone: phone, IssuedUserID: issuedUserID}); err != nil {
|
||
return "", err
|
||
}
|
||
return hash, nil
|
||
}
|
||
|
||
func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string, issuedUserID int64) (string, error) {
|
||
hash, err := randomHex(8)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
code, err := randomDigits(s.loginEmailCodeLength)
|
||
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"))
|
||
}
|
||
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 {
|
||
return "", err
|
||
}
|
||
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 {
|
||
return domain.AuthCodeDelivery{}, found, err
|
||
}
|
||
return codeDelivery(rec), true, nil
|
||
}
|
||
|
||
func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
|
||
if rec.Purpose == store.PhoneCodePurposeChangePhone {
|
||
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,
|
||
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) {
|
||
return s.resendCode(ctx, [8]byte{}, phone, phoneCodeHash)
|
||
}
|
||
|
||
// ResendCodeForAuthKey 对已登录敏感操作额外校验发起 auth key;普通登录码
|
||
// 没有 AuthKeyID 作用域,行为与 ResendCode 相同。
|
||
func (s *Service) ResendCodeForAuthKey(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) (string, error) {
|
||
return s.resendCode(ctx, authKeyID, phone, phoneCodeHash)
|
||
}
|
||
|
||
func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) (string, error) {
|
||
phone = normalizePhone(phone)
|
||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !found {
|
||
return "", ErrCodeExpired
|
||
}
|
||
if rec.Phone != phone {
|
||
return "", ErrCodeInvalid
|
||
}
|
||
if rec.Purpose == store.PhoneCodePurposeChangePhone {
|
||
if authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID {
|
||
return "", ErrCodeInvalid
|
||
}
|
||
consumed, ok, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, rec.Scope())
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !ok {
|
||
return "", ErrCodeExpired
|
||
}
|
||
return s.recreateChangePhoneCode(ctx, consumed)
|
||
}
|
||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
return "", ErrCodeExpired
|
||
}
|
||
if matches, err := s.issuedOwnerMatches(ctx, phone, rec.IssuedUserID); err != nil {
|
||
return "", err
|
||
} else if !matches {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return "", ErrCodeInvalid
|
||
}
|
||
consumed, ok, err := s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !ok {
|
||
return "", ErrCodeExpired
|
||
}
|
||
rec = consumed
|
||
if matches, err := s.issuedOwnerMatches(ctx, phone, rec.IssuedUserID); err != nil {
|
||
return "", err
|
||
} else if !matches {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return "", ErrCodeInvalid
|
||
}
|
||
if rec.Channel == codeChannelEmailLogin && strings.TrimSpace(rec.Email) != "" {
|
||
return s.createEmailLoginCode(ctx, phone, rec.Email, rec.IssuedUserID)
|
||
}
|
||
if rec.Channel == codeChannelEmailSetupRequired {
|
||
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
|
||
}
|
||
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS {
|
||
return "", ErrCodeInvalid
|
||
}
|
||
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
|
||
}
|
||
|
||
func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCode) (string, error) {
|
||
hash, err := randomHex(8)
|
||
if err != nil {
|
||
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
|
||
}
|
||
|
||
// CancelCode invalidates a pending login code hash.
|
||
func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) error {
|
||
return s.cancelCode(ctx, [8]byte{}, phone, phoneCodeHash)
|
||
}
|
||
|
||
// CancelCodeForAuthKey 是 ResendCodeForAuthKey 对应的取消路径。
|
||
func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) error {
|
||
return s.cancelCode(ctx, authKeyID, phone, phoneCodeHash)
|
||
}
|
||
|
||
// ConsumeLoginEmailReset authorizes auth.resetLoginEmail with the exact
|
||
// email-login hash previously issued for this phone owner. Possession of only
|
||
// a phone number is never sufficient to remove an authentication factor.
|
||
func (s *Service) ConsumeLoginEmailReset(ctx context.Context, phone, phoneCodeHash string) (int64, error) {
|
||
phone = normalizePhone(phone)
|
||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !found {
|
||
return 0, ErrCodeExpired
|
||
}
|
||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
return 0, ErrCodeExpired
|
||
}
|
||
if rec.Purpose != "" || rec.Phone != phone || rec.Channel != codeChannelEmailLogin || rec.SignUpVerified {
|
||
return 0, ErrCodeInvalid
|
||
}
|
||
before, beforeFound, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !beforeFound || systemUserLoginForbidden(before) || rec.IssuedUserID == 0 || rec.IssuedUserID != before.ID {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return 0, ErrCodeInvalid
|
||
}
|
||
consumed, consumedOK, err := s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !consumedOK {
|
||
return 0, ErrCodeExpired
|
||
}
|
||
if consumed.Channel != codeChannelEmailLogin || consumed.IssuedUserID != before.ID {
|
||
return 0, ErrCodeInvalid
|
||
}
|
||
after, afterFound, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !afterFound || after.ID != before.ID {
|
||
return 0, ErrCodeInvalid
|
||
}
|
||
return before.ID, nil
|
||
}
|
||
|
||
// SendPhoneCodeAfterLoginEmailReset issues the replacement app code only for
|
||
// the exact user selected by ConsumeLoginEmailReset. It deliberately bypasses
|
||
// SendCode's phone→owner reclassification so an A→B transfer cannot send B a
|
||
// code and return that hash to A's reset flow.
|
||
func (s *Service) SendPhoneCodeAfterLoginEmailReset(ctx context.Context, phone string, expectedUserID int64) (string, error) {
|
||
phone = normalizePhone(phone)
|
||
if !validPhone(phone) {
|
||
return "", ErrPhoneNumberInvalid
|
||
}
|
||
if expectedUserID == 0 || systemLoginPhoneForbidden(phone) {
|
||
return "", ErrCodeInvalid
|
||
}
|
||
owner, found, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !found || owner.ID != expectedUserID || systemUserLoginForbidden(owner) {
|
||
return "", ErrCodeInvalid
|
||
}
|
||
return s.createPhoneCode(ctx, phone, expectedUserID)
|
||
}
|
||
|
||
func (s *Service) cancelCode(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) error {
|
||
phone = normalizePhone(phone)
|
||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrCodeExpired
|
||
}
|
||
if rec.Phone != phone {
|
||
return ErrCodeInvalid
|
||
}
|
||
if rec.Purpose == store.PhoneCodePurposeChangePhone {
|
||
if authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID {
|
||
return ErrCodeInvalid
|
||
}
|
||
_, consumed, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, rec.Scope())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !consumed {
|
||
return ErrCodeExpired
|
||
}
|
||
return nil
|
||
}
|
||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
return ErrCodeExpired
|
||
}
|
||
if matches, err := s.issuedOwnerMatches(ctx, phone, rec.IssuedUserID); err != nil {
|
||
return err
|
||
} else if !matches {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return ErrCodeInvalid
|
||
}
|
||
_, consumed, err := s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !consumed {
|
||
return ErrCodeExpired
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SignIn 校验验证码并尝试登录。
|
||
// needSignUp=true 表示验证码正确但用户不存在,调用方应引导注册(此时不删验证码,留给 SignUp)。
|
||
func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (u domain.User, loginMessage domain.Message, needSignUp bool, err error) {
|
||
phone = normalizePhone(phone)
|
||
if systemLoginPhoneForbidden(phone) {
|
||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||
}
|
||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code)
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, false, err
|
||
}
|
||
if !found {
|
||
return domain.User{}, domain.Message{}, true, nil
|
||
}
|
||
return s.finishSignIn(ctx, auth, existing)
|
||
}
|
||
|
||
// 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))
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, false, err
|
||
}
|
||
if !found {
|
||
return domain.User{}, domain.Message{}, true, nil
|
||
}
|
||
return s.finishSignIn(ctx, auth, existing)
|
||
}
|
||
|
||
// verifyLoginCode closes the login-code state transition around one atomic
|
||
// 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) (store.PhoneCode, domain.User, bool, error) {
|
||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||
if err != nil {
|
||
return store.PhoneCode{}, domain.User{}, false, err
|
||
}
|
||
if !found {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeExpired
|
||
}
|
||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeExpired
|
||
}
|
||
if rec.Phone != phone || rec.Purpose != "" {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
if !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
|
||
before, beforeFound, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return store.PhoneCode{}, domain.User{}, false, err
|
||
}
|
||
beforeUserID := int64(0)
|
||
if beforeFound {
|
||
if systemUserLoginForbidden(before) {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrSystemUserLoginForbidden
|
||
}
|
||
beforeUserID = before.ID
|
||
}
|
||
if rec.IssuedUserID != beforeUserID {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
// A verified sign-up marker may precede auth.signIn on the email-setup
|
||
// path, and a normal signIn response can be lost and retried. The marker is
|
||
// already the durable authorization fact; return signUpRequired
|
||
// idempotently without asking CodeStore to verify it a second time.
|
||
if rec.SignUpVerified {
|
||
if beforeFound || rec.IssuedUserID != 0 || subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) != 1 {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
after, afterFound, err := s.currentPhoneOwner(ctx, phone)
|
||
if err != nil {
|
||
return store.PhoneCode{}, domain.User{}, false, err
|
||
}
|
||
if afterFound || after.ID != 0 {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
return rec, domain.User{}, false, nil
|
||
}
|
||
|
||
result, err := s.codes.VerifyLogin(ctx, phoneCodeHash, phone, code, !beforeFound, s.codeMaxAttempts)
|
||
if err != nil {
|
||
return store.PhoneCode{}, domain.User{}, false, err
|
||
}
|
||
after, afterFound, ownerErr := s.currentPhoneOwner(ctx, phone)
|
||
if ownerErr != nil {
|
||
return store.PhoneCode{}, domain.User{}, false, ownerErr
|
||
}
|
||
afterUserID := int64(0)
|
||
if afterFound {
|
||
afterUserID = after.ID
|
||
}
|
||
recordOwnerMismatch := result.Status != store.LoginCodeVerifyMissing && result.Record.IssuedUserID != rec.IssuedUserID
|
||
if beforeUserID != afterUserID || recordOwnerMismatch {
|
||
// keepForSignUp may have left a verified marker behind. Remove it on
|
||
// owner drift so a later transfer-back cannot resurrect authorization.
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
switch result.Status {
|
||
case store.LoginCodeVerifyMissing:
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeExpired
|
||
case store.LoginCodeVerifyInvalid:
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
case store.LoginCodeVerifyAccepted:
|
||
if result.Record.Version != store.PhoneCodeVersionCurrent || result.Record.Phone != phone || result.Record.IssuedUserID != afterUserID {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
if afterFound && systemUserLoginForbidden(after) {
|
||
return store.PhoneCode{}, domain.User{}, false, ErrSystemUserLoginForbidden
|
||
}
|
||
return result.Record, after, afterFound, nil
|
||
default:
|
||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||
}
|
||
}
|
||
|
||
// finishSignIn 是短信/邮箱两条登录路径在「验证码已通过、用户已存在」之后的共用收尾:
|
||
// 验证码已由 VerifyLogin 原子消费;这里只处理 2FA password_pending 绑定。已有账号的 app-code 消息已在
|
||
// SendCode/ResendCode 返回前持久化与入 outbox,这里绝不能再创建或补发;
|
||
// 否则未完成登录/2FA 的真实验证码反而不会及时到达旧设备。
|
||
func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, existing domain.User) (domain.User, domain.Message, bool, error) {
|
||
if systemUserLoginForbidden(existing) {
|
||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||
}
|
||
// 开启两步验证的账号:把授权标记为 password_pending 再写入,业务鉴权据此拒绝该 auth_key,
|
||
// 直到 auth.checkPassword 通过。绝不能先以完全授权写入再返回 SESSION_PASSWORD_NEEDED,
|
||
// 否则客户端忽略该错误即可直接调用业务 RPC 绕过两步验证。
|
||
passwordNeeded, err := s.passwordNeeded(ctx, existing.ID)
|
||
if err != nil {
|
||
// Password state is part of the authentication decision. Treat store
|
||
// failures as fail-closed and leave the auth key entirely unbound.
|
||
return domain.User{}, domain.Message{}, false, err
|
||
}
|
||
auth.PasswordPending = passwordNeeded
|
||
if err := s.bind(ctx, auth, existing.ID); err != nil {
|
||
return domain.User{}, domain.Message{}, false, err
|
||
}
|
||
if passwordNeeded {
|
||
return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded
|
||
}
|
||
return existing, domain.Message{}, false, nil
|
||
}
|
||
|
||
// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。
|
||
// signUp 的 TL 请求不带验证码,因此只消费由正确 SignIn/email setup 原子
|
||
// 标记过的 hash。直接 SendCode→SignUp 永远不能创建账号。
|
||
func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) {
|
||
phone = normalizePhone(phone)
|
||
if !validPhone(phone) {
|
||
return domain.User{}, domain.Message{}, ErrPhoneNumberInvalid
|
||
}
|
||
if systemLoginPhoneForbidden(phone) {
|
||
return domain.User{}, domain.Message{}, ErrSystemUserLoginForbidden
|
||
}
|
||
firstName = strings.TrimSpace(firstName)
|
||
lastName = strings.TrimSpace(lastName)
|
||
if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 {
|
||
return domain.User{}, domain.Message{}, domain.ErrFirstNameInvalid
|
||
}
|
||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, err
|
||
}
|
||
if !found {
|
||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||
}
|
||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||
_, _, _ = s.codes.ConsumeSignUpVerified(ctx, phoneCodeHash, phone)
|
||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||
}
|
||
if rec.Phone != phone || rec.Purpose != "" {
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
if !rec.SignUpVerified {
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
if rec.IssuedUserID != 0 {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
if !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" {
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
|
||
return domain.User{}, domain.Message{}, err
|
||
} else if currentFound || current.ID != 0 {
|
||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
consumed, consumedOK, err := s.codes.ConsumeSignUpVerified(ctx, phoneCodeHash, phone)
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, err
|
||
}
|
||
if !consumedOK {
|
||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||
}
|
||
rec = consumed
|
||
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 {
|
||
return domain.User{}, domain.Message{}, err
|
||
} else if currentFound || current.ID != 0 {
|
||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||
}
|
||
|
||
accessHash, err := randomInt64()
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, err
|
||
}
|
||
newUser := domain.User{
|
||
AccessHash: accessHash,
|
||
Phone: phone,
|
||
FirstName: firstName,
|
||
LastName: lastName,
|
||
}
|
||
// 新账号默认赠送会员:到期时间 = 注册时刻 + N 个月(与迁移 0094 对存量
|
||
// 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。
|
||
if s.premiumGrantMonths > 0 {
|
||
newUser.PremiumUntil = int(time.Now().AddDate(0, s.premiumGrantMonths, 0).Unix())
|
||
}
|
||
u, err := s.users.Create(ctx, newUser)
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, err
|
||
}
|
||
if rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) != "" && s.loginEmails != nil {
|
||
if err := s.loginEmails.SetLoginEmail(ctx, u.ID, 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
|
||
}
|
||
loginMessage := domain.Message{}
|
||
// 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 and email setup registration retain only their verified fact.
|
||
if rec.Channel == codeChannelPhone {
|
||
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||
if err != nil {
|
||
return domain.User{}, domain.Message{}, err
|
||
}
|
||
}
|
||
return u, loginMessage, nil
|
||
}
|
||
|
||
// SignInBot 处理 auth.importBotAuthorization:校验 bot token 并把当前 auth_key
|
||
// 绑定到 bot 账号。token 校验必须先于 bind(bind 即授权生效);bot 无 2FA,
|
||
// PasswordPending 恒 false;不写登录消息、不推 signIn 通知(手机登录语义)。
|
||
// 任何校验失败统一返回 domain.ErrBotTokenInvalid,不区分原因避免泄漏存在性。
|
||
func (s *Service) SignInBot(ctx context.Context, auth domain.Authorization, token string) (domain.User, error) {
|
||
if s == nil || s.bots == nil || s.users == nil {
|
||
return domain.User{}, domain.ErrBotTokenInvalid
|
||
}
|
||
botUserID, secret, ok := domain.ParseBotToken(strings.TrimSpace(token))
|
||
if !ok {
|
||
return domain.User{}, domain.ErrBotTokenInvalid
|
||
}
|
||
profile, found, err := s.bots.GetBot(ctx, botUserID)
|
||
if err != nil {
|
||
return domain.User{}, err
|
||
}
|
||
// 空 secret(内置 BotFather)永不可登录;比较走常数时间。
|
||
if !found || profile.TokenSecret == "" ||
|
||
subtle.ConstantTimeCompare([]byte(profile.TokenSecret), []byte(secret)) != 1 {
|
||
return domain.User{}, domain.ErrBotTokenInvalid
|
||
}
|
||
u, found, err := s.users.ByID(ctx, botUserID)
|
||
if err != nil {
|
||
return domain.User{}, err
|
||
}
|
||
if !found || !u.Bot {
|
||
return domain.User{}, domain.ErrBotTokenInvalid
|
||
}
|
||
if systemUserLoginForbidden(u) {
|
||
return domain.User{}, domain.ErrBotTokenInvalid
|
||
}
|
||
auth.PasswordPending = false
|
||
if err := s.bind(ctx, auth, u.ID); err != nil {
|
||
return domain.User{}, err
|
||
}
|
||
// check-bind-recheck:SignInBot 的「校验 secret → bind」非原子,并发 /revoke
|
||
// 可能在两步之间换 secret 并删除已有 authorization。此处 bind 写入的新行不会被
|
||
// 那次删除覆盖(删除发生在 bind 之前),会逃过 session 撤销。bind 后复核 secret:
|
||
// 若已被换掉,撤销刚写入的授权并拒登,闭合竞态窗口。
|
||
if again, found, err := s.bots.GetBot(ctx, botUserID); err != nil {
|
||
_ = s.auths.Delete(ctx, auth.AuthKeyID)
|
||
return domain.User{}, err
|
||
} else if !found || again.TokenSecret == "" ||
|
||
subtle.ConstantTimeCompare([]byte(again.TokenSecret), []byte(secret)) != 1 {
|
||
_ = s.auths.Delete(ctx, auth.AuthKeyID)
|
||
return domain.User{}, domain.ErrBotTokenInvalid
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
// BindVerifiedLogin 把当前 auth_key 绑定到一个已由外部强因子(如 passkey)验证过身份的
|
||
// 用户,直接完成授权。passkey 是独立强因子,不再叠加 2FA password(PasswordPending=false),
|
||
// 与官方"passkey 登录跳过密码步骤"一致。校验已发生在调用方(passkey 断言验证),此处只负责绑定。
|
||
func (s *Service) BindVerifiedLogin(ctx context.Context, auth domain.Authorization, userID int64) (domain.User, error) {
|
||
if s == nil || s.users == nil || userID == 0 {
|
||
return domain.User{}, domain.ErrPasskeyInvalid
|
||
}
|
||
u, found, err := s.users.ByID(ctx, userID)
|
||
if err != nil {
|
||
return domain.User{}, err
|
||
}
|
||
if !found {
|
||
return domain.User{}, domain.ErrPasskeyNotFound
|
||
}
|
||
if systemUserLoginForbidden(u) {
|
||
return domain.User{}, ErrSystemUserLoginForbidden
|
||
}
|
||
auth.PasswordPending = false
|
||
if err := s.bind(ctx, auth, userID); err != nil {
|
||
return domain.User{}, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
// AcceptLoginToken 把 QR 登录请求方的 auth_key 绑定到扫码确认的 user。
|
||
func (s *Service) AcceptLoginToken(ctx context.Context, auth domain.Authorization, userID int64) (domain.Authorization, error) {
|
||
if s == nil || s.auths == nil || userID == 0 || auth.AuthKeyID == ([8]byte{}) {
|
||
return domain.Authorization{}, fmt.Errorf("accept login token: invalid authorization")
|
||
}
|
||
if domain.IsSystemUserID(userID) {
|
||
return domain.Authorization{}, ErrSystemUserLoginForbidden
|
||
}
|
||
auth.PasswordPending = false
|
||
if err := s.bind(ctx, auth, userID); err != nil {
|
||
return domain.Authorization{}, err
|
||
}
|
||
bound, found, err := s.auths.ByAuthKey(ctx, auth.AuthKeyID)
|
||
if err != nil {
|
||
return domain.Authorization{}, err
|
||
}
|
||
if found {
|
||
return bound, nil
|
||
}
|
||
auth.UserID = userID
|
||
return auth, nil
|
||
}
|
||
|
||
// LogOut 解绑当前 auth_key 的授权。
|
||
func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error {
|
||
return s.auths.Delete(ctx, authKeyID)
|
||
}
|
||
|
||
func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) {
|
||
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) {
|
||
return domain.Authorization{}, false, nil
|
||
}
|
||
return s.auths.ByAuthKey(ctx, authKeyID)
|
||
}
|
||
|
||
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,
|
||
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 == "" {
|
||
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
|
||
}
|
||
if err := 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,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
if s.auths != nil {
|
||
// 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
|
||
}
|
||
|
||
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
|
||
if s == nil || s.auths == nil || userID == 0 {
|
||
return nil, nil
|
||
}
|
||
return s.auths.ListByUser(ctx, userID)
|
||
}
|
||
|
||
func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||
if s == nil || s.auths == nil || userID == 0 {
|
||
return domain.Authorization{}, false, nil
|
||
}
|
||
if revoker, ok := s.auths.(authorizationRevoker); ok {
|
||
return revoker.RevokeByHash(ctx, userID, hash)
|
||
}
|
||
return s.auths.DeleteByHash(ctx, userID, hash)
|
||
}
|
||
|
||
func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||
if s == nil || s.auths == nil || userID == 0 {
|
||
return nil, nil
|
||
}
|
||
if revoker, ok := s.auths.(authorizationRevoker); ok {
|
||
return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID)
|
||
}
|
||
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
|
||
}
|
||
|
||
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 一并删除。
|
||
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) {
|
||
if s.passwords == nil {
|
||
return false, nil
|
||
}
|
||
settings, found, err := s.passwords.GetByUser(ctx, userID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return found && settings.HasPassword, nil
|
||
}
|
||
|
||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||
|
||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||
|
||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||
|
||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||
if s.messages == nil || s.dialogs == nil {
|
||
return domain.Message{}, nil
|
||
}
|
||
body := fmt.Sprintf(loginMessageTpl, code)
|
||
codeOffset := len("Login code: ")
|
||
msg, err := s.messages.Create(ctx, domain.Message{
|
||
OwnerUserID: userID,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||
Date: int(time.Now().Unix()),
|
||
Body: body,
|
||
Entities: []domain.MessageEntity{
|
||
{Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||
{Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||
},
|
||
})
|
||
if err != nil {
|
||
return domain.Message{}, err
|
||
}
|
||
if err := s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{
|
||
Peer: msg.Peer,
|
||
TopMessage: msg.ID,
|
||
TopMessageDate: msg.Date,
|
||
}); err != nil {
|
||
return domain.Message{}, err
|
||
}
|
||
return msg, nil
|
||
}
|
||
|
||
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{}, 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{}, 0, err
|
||
}
|
||
if !found || perm.ExpiresAt != 0 {
|
||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||
}
|
||
|
||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||
if err != nil {
|
||
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{}, 0, ErrEncryptedMessageInvalid
|
||
}
|
||
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) {
|
||
var msg mtcrypto.EncryptedMessage
|
||
if err := msg.Decode(&bin.Buffer{Buf: encrypted}); err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
if msg.AuthKeyID != perm.ID || len(msg.EncryptedData) == 0 || len(msg.EncryptedData)%aes.BlockSize != 0 {
|
||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||
}
|
||
|
||
key, iv := mtcrypto.KeysV1(mtcrypto.Key(perm.Value), msg.MsgKey)
|
||
block, err := aes.NewCipher(key[:])
|
||
if err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
plaintext := make([]byte, len(msg.EncryptedData))
|
||
ige.DecryptBlocks(block, iv[:], plaintext, msg.EncryptedData)
|
||
|
||
const headerLen = 16 + 8 + 4 + 4
|
||
if len(plaintext) < headerLen {
|
||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||
}
|
||
b := &bin.Buffer{Buf: plaintext}
|
||
randomPrefix := make([]byte, 16)
|
||
if err := b.ConsumeN(randomPrefix, len(randomPrefix)); err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
if _, err := b.Long(); err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
if _, err := b.Int32(); err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
msgLen, err := b.Int32()
|
||
if err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
if msgLen <= 0 {
|
||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||
}
|
||
bodyEnd := headerLen + int(msgLen)
|
||
if bodyEnd > len(plaintext) {
|
||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||
}
|
||
if msg.MsgKey != mtcrypto.MessageKeyV1(plaintext[:bodyEnd]) {
|
||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||
}
|
||
|
||
body := plaintext[headerLen:bodyEnd]
|
||
var inner mtcrypto.BindAuthKeyInner
|
||
if err := inner.Decode(&bin.Buffer{Buf: body}); err != nil {
|
||
return mtcrypto.BindAuthKeyInner{}, err
|
||
}
|
||
return inner, nil
|
||
}
|
||
|
||
func authKeyIDFromInt64(v int64) [8]byte {
|
||
var id [8]byte
|
||
binary.LittleEndian.PutUint64(id[:], uint64(v))
|
||
return id
|
||
}
|
||
|
||
func authKeyIDInt64(id [8]byte) int64 {
|
||
return int64(binary.LittleEndian.Uint64(id[:]))
|
||
}
|
||
|
||
func normalizePhone(phone string) string {
|
||
return domain.NormalizePhone(phone)
|
||
}
|
||
|
||
func randomHex(n int) (string, error) {
|
||
b := make([]byte, n)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "", fmt.Errorf("rand: %w", err)
|
||
}
|
||
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 {
|
||
return 0, fmt.Errorf("rand: %w", err)
|
||
}
|
||
return int64(binary.LittleEndian.Uint64(b[:])), nil
|
||
}
|