owpengram-server/internal/app/auth/service.go
2026-07-14 12:27:53 +03:00

1547 lines
56 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/gotd/td/bin"
mtcrypto "github.com/gotd/td/crypto"
"telesrv/internal/domain"
"telesrv/internal/mail"
"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")
// 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 = "phone"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
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 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
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 mail.Sender
loginEmailEnabled bool
loginEmailRequireSetup bool
loginEmailCodeLength int
// emailSignupEnabled 打开后888 前缀的合成号码domain.EncodeEmailPhone
// 在 sendCode 阶段直接解码出邮箱、复用登录邮箱同一投递通道发码,与
// loginEmailEnabled手机号账号的第二验证渠道是两条独立开关。
emailSignupEnabled bool
// 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 mail.Sender
}
type authorizationRevoker interface {
RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
}
// 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
}
}
// WithEmailSignup 打开「邮箱即身份」登录方式:见 emailSignupEnabled 字段注释。
// 邮件投递复用 WithLoginEmail 注入的 loginEmailSender调用方需保证两条配置
// 共用同一组 SMTP 设置时任一开关打开都会构造好 sender见 internal/config 校验)。
func WithEmailSignup(enabled bool) Option {
return func(s *Service) {
s.emailSignupEnabled = enabled
}
}
// 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}
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, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
if err != nil {
return err
}
binding.TempSessionID = inner.TempSessionID
}
if s.tempKeys == nil {
return nil
}
return s.tempKeys.Save(ctx, binding)
}
// 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内。收紧为显式硬化任务需客户端验证。
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
}
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
}
// 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
}
if err := s.auths.MarkPasswordPassed(ctx, authKeyID); err != nil {
return err
}
// This is where a 2FA account's sign-in actually finishes — finishSignIn
// deliberately skipped the welcome message while password_pending.
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found {
if u, found, err := s.users.ByID(ctx, a.UserID); err == nil && found {
s.recordWelcomeMessage(ctx, u)
}
}
return nil
}
// 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.emailSignupEnabled {
if email, ok := domain.DecodeEmailPhone(phone); ok {
return s.createEmailLoginCode(ctx, phone, email, issuedUserID)
}
}
// A real (non-encoded) phone number reaching here on an email-signup
// server is someone using the client's explicit "log in by phone number"
// fallback (see EmailSignupWidget) — a real phone is otherwise
// unreachable from the intro flow. This server never sends real SMS, so
// that account must get a login email bound immediately: force the setup
// gate on regardless of the raw TELESRV_LOGIN_EMAIL_REQUIRE_SETUP config.
if (s.loginEmailEnabled || s.emailSignupEnabled) && 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.requireLoginEmailSetup() {
return s.createSetupRequiredCode(ctx, phone, issuedUserID)
}
}
return s.createPhoneCode(ctx, phone, issuedUserID)
}
// requireLoginEmailSetup reports whether a phone-number account without a
// bound login email must set one up before it can finish signing in/up. This
// is forced on for every real phone number once email-signup is enabled —
// see SendCode — independent of the raw TELESRV_LOGIN_EMAIL_REQUIRE_SETUP
// config value, since this server has no real SMS delivery to fall back to.
func (s *Service) requireLoginEmailSetup() bool {
return s.loginEmailRequireSetup || s.emailSignupEnabled
}
// currentPhoneOwner resolves the account currently identified by a wire
// phone value. For email-signup phones (see domain.EncodeEmailPhone) the
// account's real users.phone is a short, unrelated "888" display number
// (domain.NewEmailSignupDisplayPhone) assigned at SignUp — the wire value
// itself is never stored — so lookup goes through the decoded email and
// User.SignupEmail instead of ByPhone. Every owner-drift/idempotency
// invariant elsewhere in this file (issuedOwnerMatches, verifyLoginCode,
// SignUp's occupied checks, ...) is expressed purely in terms of "the
// account currentPhoneOwner resolves to", so fixing this one lookup keeps
// all of them correct for email-signup phones with no further changes.
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")
}
if s.emailSignupEnabled {
if email, ok := domain.DecodeEmailPhone(phone); ok {
return s.users.ByEmail(ctx, email)
}
}
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
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: existingUserID,
Phone: phone,
Code: s.fixedCode,
Channel: codeChannelPhone,
MaxAttempts: s.codeMaxAttempts,
}, 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 {
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
}
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
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: issuedUserID,
Phone: phone,
Code: code,
Channel: codeChannelEmailLogin,
Email: strings.TrimSpace(email),
MaxAttempts: s.codeMaxAttempts,
}
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store email code: %w", err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
if s.loginEmailSender == 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 {
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
}
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 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 {
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.Channel = codeChannelPhone
rec.Attempts = 0
if rec.MaxAttempts <= 0 {
rec.MaxAttempts = s.codeMaxAttempts
}
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store resent phone change code: %w", 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, false)
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账号设置了登录邮箱后新设备
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
// 随机邮箱码;未开启该特性时仍允许旧客户端把 phone channel 放进
// email_verification但必须精确匹配该 phone code不能再接受任意非空值。
// 两条路径共用 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)
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, emailPath bool) (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
}
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
if emailPath {
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
}
if !channelAllowed {
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
}
// 2FA accounts only really finish authorizing in CompletePasswordSignIn;
// firing the welcome message here too would notify about an attempt that
// never actually got past the password check.
s.recordWelcomeMessage(ctx, existing)
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 rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
// Email-signup accounts (888-encoded phone) already proved ownership of
// their email through the code they just entered — their whole identity
// is that email. The separate loginEmailRequireSetup gate exists to force
// a *phone*-based account to additionally configure a recovery/login
// email via the legacy VerifiedEmail/PendingEmail flow; it does not apply
// here and would otherwise permanently block SignUp for every
// email-signup account.
if s.requireLoginEmailSetup() && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" && !domain.IsEmailSignupPhone(phone) {
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 || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
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,
}
// Email-signup accounts don't keep the long email-encoded wire value as
// their permanent phone — that only ever needed to travel on the wire to
// get here. Assign a short, normal-looking "888" number instead and
// record the email separately (SignupEmail), which is what
// currentPhoneOwner uses to find this account again on a later login.
if s.emailSignupEnabled && domain.IsEmailSignupPhone(phone) {
email, ok := domain.DecodeEmailPhone(phone)
if !ok {
return domain.User{}, domain.Message{}, ErrPhoneNumberInvalid
}
displayPhone, err := s.assignEmailSignupDisplayPhone(ctx)
if err != nil {
return domain.User{}, domain.Message{}, err
}
newUser.Phone = displayPhone
newUser.SignupEmail = domain.NormalizeEmailForPhone(email)
}
// 新账号默认赠送会员:到期时间 = 注册时刻 + 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{}
// 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.
if rec.Channel == codeChannelPhone {
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
if err != nil {
return domain.User{}, domain.Message{}, err
}
}
s.recordWelcomeMessage(ctx, u)
return u, loginMessage, nil
}
// SignInBot 处理 auth.importBotAuthorization校验 bot token 并把当前 auth_key
// 绑定到 bot 账号。token 校验必须先于 bindbind 即授权生效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-recheckSignInBot 的「校验 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) 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
}
key, found, err := s.authKeys.Get(ctx, authKeyID)
if err != nil || !found {
return domain.AuthKeyClientInfo{}, found, err
}
info := domain.AuthKeyClientInfo{
Layer: key.Layer,
DeviceModel: key.DeviceModel,
Platform: key.Platform,
SystemVersion: key.SystemVersion,
APIID: key.APIID,
AppVersion: key.AppVersion,
}
if info.Layer == 0 && info.DeviceModel == "" && info.Platform == "" &&
info.SystemVersion == "" && info.APIID == 0 && info.AppVersion == "" {
return domain.AuthKeyClientInfo{}, false, nil
}
return info, true, nil
}
func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
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 {
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
}
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)
}
target, found, err := s.authorizationByHash(ctx, userID, hash)
if err != nil || !found {
return target, found, err
}
if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil {
return target, true, err
}
deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash)
if err != nil || !found {
return deleted, found, err
}
return deleted, true, nil
}
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)
}
targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
for _, a := range targets {
if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil {
return nil, err
}
}
deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
return deleted, nil
}
func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
return s.authKeys.Delete(ctx, authKeyID)
}
func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return domain.Authorization{}, false, err
}
for _, a := range items {
if a.Hash == hash {
return a, true, nil
}
}
return domain.Authorization{}, false, nil
}
func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]domain.Authorization, 0, len(items))
for _, a := range items {
if a.AuthKeyID != keepAuthKeyID {
out = append(out, a)
}
}
return out, nil
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
auth.UserID = userID
// Bind 是授权切换的持久化状态边界:生产 store 会先清同 auth key 的旧用户
// update state再原子建立新用户 baseline。RPC 层不得在 Bind 成功后清整个 key
// 否则会把刚建立的 retained-floor checkpoint 一并删除。
return s.auths.Bind(ctx, auth)
}
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 Telegram!
This code can be used to log in to your Telegram 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
}
// recordWelcomeMessage writes the unconditional "Welcome to OwpenGram!"
// 777000 message for every completed sign-in (SignUp and every subsequent
// SignIn/SignInWithEmail), regardless of channel. Best-effort: a failure here
// must never fail the sign-in itself, since unlike recordLoginMessage it
// carries no secret the caller needs.
func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
if s == nil || s.messages == nil || s.dialogs == nil {
return
}
msg, err := domain.OfficialWelcomeMessage(u.ID, domain.SignInMethodLabel(u), int(time.Now().Unix()))
if err != nil {
return
}
created, err := s.messages.Create(ctx, msg)
if err != nil {
return
}
_ = s.dialogs.UpsertInbox(ctx, u.ID, domain.Dialog{
Peer: created.Peer,
TopMessage: created.ID,
TopMessageDate: created.Date,
})
}
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
if binding.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
}
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
perm, found, err := s.authKeys.Get(ctx, permID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, err
}
if !found {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
}
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 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 inner, nil
}
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)
}
// assignEmailSignupDisplayPhone generates a short "888" display number for a
// new email-signup account (see domain.NewEmailSignupDisplayPhone),
// re-rolling on the astronomically unlikely collision with an existing
// account's phone. maxEmailSignupPhoneAttempts bounds the loop so a store
// failure can't spin forever.
const maxEmailSignupPhoneAttempts = 20
func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) {
for i := 0; i < maxEmailSignupPhoneAttempts; i++ {
candidate, err := domain.NewEmailSignupDisplayPhone()
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)
}
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
}