owpengram-server/internal/app/auth/service.go
A 6dc42942c8 auth: complete account authorization flows
(cherry picked from commit 04f4527df32ad5c35720cccc41d27fe51549612f)
2026-06-16 01:43:31 +08:00

436 lines
14 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"
"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/store"
)
// 登录错误。
var (
ErrCodeExpired = errors.New("phone code expired or not found")
ErrCodeInvalid = errors.New("phone code invalid")
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
)
// 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
fixedCode string
codeTTL time.Duration
}
// Option 调整登录服务的可选依赖。
type Option func(*Service)
// WithLoginMessages 在登录成功后写入官方系统账号的登录消息与会话摘要。
func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option {
return func(s *Service) {
s.messages = messages
s.dialogs = dialogs
}
}
// 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
}
}
// 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}
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。
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
}
if binding.ExpiresAt <= int(time.Now().Unix()) {
return [8]byte{}, false, nil
}
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
}
return a.UserID, true, nil
}
// SendCode 为 phone 生成 phone_code_hash暂存开发固定验证码返回 hash。
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
hash, err := randomHex(8)
if err != nil {
return "", err
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: normalizePhone(phone), Code: s.fixedCode}, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
return hash, nil
}
// ResendCode invalidates an existing code hash and sends a fresh code to the same phone.
func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) {
phone = normalizePhone(phone)
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return "", err
}
if !found {
return "", ErrCodeExpired
}
if rec.Phone != phone {
return "", ErrCodeInvalid
}
_ = s.codes.Del(ctx, phoneCodeHash)
return s.SendCode(ctx, phone)
}
// CancelCode invalidates a pending login code hash.
func (s *Service) CancelCode(ctx context.Context, 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
}
return s.codes.Del(ctx, phoneCodeHash)
}
// 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)
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
if !found {
return domain.User{}, domain.Message{}, false, ErrCodeExpired
}
if rec.Phone != phone || rec.Code != code {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
existing, found, err := s.users.ByPhone(ctx, phone)
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
if !found {
return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册
}
if err := s.bind(ctx, auth, existing.ID); err != nil {
return domain.User{}, domain.Message{}, false, err
}
if s.passwordNeeded(ctx, existing.ID) {
_ = s.codes.Del(ctx, phoneCodeHash)
return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded
}
loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code)
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
_ = s.codes.Del(ctx, phoneCodeHash)
return existing, loginMessage, false, nil
}
// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。
// signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。
func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) {
phone = normalizePhone(phone)
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.Phone != phone {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
accessHash, err := randomInt64()
if err != nil {
return domain.User{}, domain.Message{}, err
}
u, err := s.users.Create(ctx, domain.User{
AccessHash: accessHash,
Phone: phone,
FirstName: firstName,
LastName: lastName,
})
if 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, err := s.recordLoginMessage(ctx, u.ID, rec.Code)
if err != nil {
return domain.User{}, domain.Message{}, err
}
_ = s.codes.Del(ctx, phoneCodeHash)
return u, loginMessage, nil
}
// LogOut 解绑当前 auth_key 的授权。
func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error {
return s.auths.Delete(ctx, authKeyID)
}
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
}
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
}
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
auth.UserID = userID
return s.auths.Bind(ctx, auth)
}
func (s *Service) passwordNeeded(ctx context.Context, userID int64) bool {
if s.passwords == nil {
return false
}
settings, found, err := s.passwords.GetByUser(ctx, userID)
return err == nil && found && settings.HasPassword
}
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.Upsert(ctx, userID, domain.Dialog{
Peer: msg.Peer,
TopMessage: msg.ID,
TopMessageDate: msg.Date,
UnreadCount: 1,
}); err != nil {
return domain.Message{}, err
}
return msg, nil
}
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 {
var b strings.Builder
b.Grow(len(phone))
for _, r := range phone {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return phone
}
return b.String()
}
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 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
}