move gotd fork into repo. (#111)

- update to latest telegram layer
- remove some references to fields in tg.Entities that don't exist in
the schema
- originally added here:
https://github.com/beeper/td/commit/820929062a2ba0104397bc01235ab58a9cff780e
  - referenced here
-
https://github.com/mautrix/telegramgo/commit/124f0967ed195b5a380c9bd02e170ada9710dde3
-
https://github.com/mautrix/telegramgo/commit/4205047aab2e0639217148b5d125bfaab668bd8e
This commit is contained in:
Adam Van Ymeren
2025-06-27 20:03:37 -07:00
committed by GitHub
parent 0952df0244
commit 7a04f298d2
19264 changed files with 1539697 additions and 84 deletions
+23
View File
@@ -0,0 +1,23 @@
// Package auth provides authentication on top of tg.Client.
package auth
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
)
// IsKeyUnregistered reports whether err is AUTH_KEY_UNREGISTERED error.
//
// Deprecated: use IsUnauthorized.
func IsKeyUnregistered(err error) bool {
return tgerr.Is(err, "AUTH_KEY_UNREGISTERED")
}
// IsUnauthorized reports whether err is any 401 UNAUTHORIZED or is a 406
// NOT_ACCEPTABLE with AUTH_KEY_DUPLICATED.
//
// https://core.telegram.org/api/errors#401-unauthorized
// https://core.telegram.org/api/errors#406-not-acceptable
func IsUnauthorized(err error) bool {
return tgerr.IsCode(err, 401) ||
(tgerr.IsCode(err, 406) && tgerr.Is(err, "AUTH_KEY_DUPLICATED"))
}
+44
View File
@@ -0,0 +1,44 @@
package auth
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
const (
testAppID = 1
testAppHash = "hash"
)
func testClient(invoker tg.Invoker) *Client {
return &Client{
api: tg.NewClient(invoker),
rand: rand.Reader,
appID: testAppID,
appHash: testAppHash,
}
}
func mockClient(t *testing.T) (*tgmock.Mock, *Client) {
mock := tgmock.New(t)
return mock, NewClient(tg.NewClient(mock), testutil.ZeroRand{}, testAppID, testAppHash)
}
func mockTest(cb func(
a *require.Assertions,
mock *tgmock.Mock,
client *Client,
)) func(t *testing.T) {
return func(t *testing.T) {
a := require.New(t)
m, client := mockClient(t)
cb(a, m, client)
}
}
+26
View File
@@ -0,0 +1,26 @@
package auth
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Bot performs bot authentication request.
func (c *Client) Bot(ctx context.Context, token string) (*tg.AuthAuthorization, error) {
auth, err := c.api.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: c.appID,
APIHash: c.appHash,
BotAuthToken: token,
})
if err != nil {
return nil, err
}
result, err := checkResult(auth)
if err != nil {
return nil, errors.Wrap(err, "check")
}
return result, nil
}
+46
View File
@@ -0,0 +1,46 @@
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func TestClient_AuthBot(t *testing.T) {
const token = "12345:token"
t.Run("AuthAuthorization", func(t *testing.T) {
mock := tgmock.New(t)
testUser := &tg.User{}
testUser.SetBot(true)
mock.ExpectCall(&tg.AuthImportBotAuthorizationRequest{
BotAuthToken: token,
APIID: testAppID,
APIHash: testAppHash,
}).ThenResult(&tg.AuthAuthorization{User: testUser})
result, err := testClient(mock).Bot(context.Background(), token)
require.NoError(t, err)
require.Equal(t, testUser, result.User)
})
t.Run("AuthAuthorizationSignUpRequired", func(t *testing.T) {
mock := tgmock.New(t)
mock.ExpectCall(&tg.AuthImportBotAuthorizationRequest{
BotAuthToken: token,
APIID: testAppID,
APIHash: testAppHash,
}).ThenResult(&tg.AuthAuthorizationSignUpRequired{})
result, err := testClient(mock).Bot(context.Background(), token)
require.Error(t, err)
require.Nil(t, result)
})
}
+30
View File
@@ -0,0 +1,30 @@
package auth
import (
"io"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Client implements Telegram authentication.
type Client struct {
api *tg.Client
rand io.Reader
appID int
appHash string
}
// NewClient initializes and returns Telegram authentication client.
func NewClient(
api *tg.Client,
rand io.Reader,
appID int,
appHash string,
) *Client {
return &Client{
api: api,
rand: rand,
appID: appID,
appHash: appHash,
}
}
+307
View File
@@ -0,0 +1,307 @@
package auth
import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// NewFlow initializes new authentication flow.
func NewFlow(auth UserAuthenticator, opt SendCodeOptions) Flow {
return Flow{
Auth: auth,
Options: opt,
}
}
// Flow simplifies boilerplate for authentication flow.
type Flow struct {
Auth UserAuthenticator
Options SendCodeOptions
}
func (f Flow) handleSignUp(ctx context.Context, client FlowClient, phone, hash string, s *SignUpRequired) error {
if err := f.Auth.AcceptTermsOfService(ctx, s.TermsOfService); err != nil {
return errors.Wrap(err, "confirm TOS")
}
info, err := f.Auth.SignUp(ctx)
if err != nil {
return errors.Wrap(err, "sign up info not provided")
}
if _, err := client.SignUp(ctx, SignUp{
PhoneNumber: phone,
PhoneCodeHash: hash,
FirstName: info.FirstName,
LastName: info.LastName,
}); err != nil {
return errors.Wrap(err, "sign up")
}
return nil
}
// Run starts authentication flow on client.
func (f Flow) Run(ctx context.Context, client FlowClient) error {
if f.Auth == nil {
return errors.New("no UserAuthenticator provided")
}
phone, err := f.Auth.Phone(ctx)
if err != nil {
return errors.Wrap(err, "get phone")
}
sentCode, err := client.SendCode(ctx, phone, f.Options)
if err != nil {
return errors.Wrap(err, "send code")
}
switch s := sentCode.(type) {
case *tg.AuthSentCode:
hash := s.PhoneCodeHash
code, err := f.Auth.Code(ctx, s)
if err != nil {
return errors.Wrap(err, "get code")
}
_, signInErr := client.SignIn(ctx, phone, code, hash)
if errors.Is(signInErr, ErrPasswordAuthNeeded) {
password, err := f.Auth.Password(ctx)
if err != nil {
return errors.Wrap(err, "get password")
}
if _, err := client.Password(ctx, password); err != nil {
return errors.Wrap(err, "sign in with password")
}
return nil
}
var signUpRequired *SignUpRequired
if errors.As(signInErr, &signUpRequired) {
return f.handleSignUp(ctx, client, phone, hash, signUpRequired)
}
if signInErr != nil {
return errors.Wrap(signInErr, "sign in")
}
return nil
case *tg.AuthSentCodeSuccess:
switch a := s.Authorization.(type) {
case *tg.AuthAuthorization:
// Looks that we are already authorized.
return nil
case *tg.AuthAuthorizationSignUpRequired:
if err := f.handleSignUp(ctx, client, phone, "", &SignUpRequired{
TermsOfService: a.TermsOfService,
}); err != nil {
// TODO: not sure that blank hash will work here
return errors.Wrap(err, "sign up after auth sent code success")
}
return nil
default:
return errors.Errorf("unexpected authorization type: %T", a)
}
default:
return errors.Errorf("unexpected sent code type: %T", sentCode)
}
}
// FlowClient abstracts telegram client for Flow.
type FlowClient interface {
SignIn(ctx context.Context, phone, code, codeHash string) (*tg.AuthAuthorization, error)
SendCode(ctx context.Context, phone string, options SendCodeOptions) (tg.AuthSentCodeClass, error)
Password(ctx context.Context, password string) (*tg.AuthAuthorization, error)
SignUp(ctx context.Context, s SignUp) (*tg.AuthAuthorization, error)
}
// CodeAuthenticator asks user for received authentication code.
type CodeAuthenticator interface {
Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error)
}
// CodeAuthenticatorFunc is functional wrapper for CodeAuthenticator.
type CodeAuthenticatorFunc func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error)
// Code implements CodeAuthenticator interface.
func (c CodeAuthenticatorFunc) Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) {
return c(ctx, sentCode)
}
// UserInfo represents user info required for sign up.
type UserInfo struct {
FirstName string
LastName string
}
// UserAuthenticator asks user for phone, password and received authentication code.
type UserAuthenticator interface {
Phone(ctx context.Context) (string, error)
Password(ctx context.Context) (string, error)
AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error
SignUp(ctx context.Context) (UserInfo, error)
CodeAuthenticator
}
type noSignUp struct{}
func (c noSignUp) SignUp(ctx context.Context) (UserInfo, error) {
return UserInfo{}, errors.New("not implemented")
}
func (c noSignUp) AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error {
return &SignUpRequired{TermsOfService: tos}
}
type constantAuth struct {
phone, password string
CodeAuthenticator
noSignUp
}
func (c constantAuth) Phone(ctx context.Context) (string, error) {
return c.phone, nil
}
func (c constantAuth) Password(ctx context.Context) (string, error) {
return c.password, nil
}
// Constant creates UserAuthenticator with constant phone and password.
func Constant(phone, password string, code CodeAuthenticator) UserAuthenticator {
return constantAuth{
phone: phone,
password: password,
CodeAuthenticator: code,
}
}
type envAuth struct {
prefix string
CodeAuthenticator
noSignUp
}
func (e envAuth) lookup(k string) (string, error) {
env := e.prefix + k
v, ok := os.LookupEnv(env)
if !ok {
return "", errors.Errorf("environment variable %q not set", env)
}
return v, nil
}
func (e envAuth) Phone(ctx context.Context) (string, error) {
return e.lookup("PHONE")
}
func (e envAuth) Password(ctx context.Context) (string, error) {
p, err := e.lookup("PASSWORD")
if err != nil {
return "", ErrPasswordNotProvided
}
return p, nil
}
// Env creates UserAuthenticator which gets phone and password from environment variables.
func Env(prefix string, code CodeAuthenticator) UserAuthenticator {
return envAuth{
prefix: prefix,
CodeAuthenticator: code,
noSignUp: noSignUp{},
}
}
// ErrPasswordNotProvided means that password requested by Telegram,
// but not provided by user.
var ErrPasswordNotProvided = errors.New("password requested but not provided")
type codeOnlyAuth struct {
phone string
CodeAuthenticator
noSignUp
}
func (c codeOnlyAuth) Phone(ctx context.Context) (string, error) {
return c.phone, nil
}
func (c codeOnlyAuth) Password(ctx context.Context) (string, error) {
return "", ErrPasswordNotProvided
}
// CodeOnly creates UserAuthenticator with constant phone and no password.
func CodeOnly(phone string, code CodeAuthenticator) UserAuthenticator {
return codeOnlyAuth{
phone: phone,
CodeAuthenticator: code,
}
}
type testAuth struct {
dc int
phone string
}
func (t testAuth) Phone(ctx context.Context) (string, error) { return t.phone, nil }
func (t testAuth) Password(ctx context.Context) (string, error) { return "", ErrPasswordNotProvided }
func (t testAuth) Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) {
type notFlashing interface {
GetLength() int
}
length := 5
if sentCode != nil {
typ, ok := sentCode.Type.(notFlashing)
if !ok {
return "", errors.Errorf("unexpected type: %T", sentCode.Type)
}
length = typ.GetLength()
}
return strings.Repeat(strconv.Itoa(t.dc), length), nil
}
func (t testAuth) AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error {
return nil
}
func (t testAuth) SignUp(ctx context.Context) (UserInfo, error) {
return UserInfo{
FirstName: "Test",
LastName: "User",
}, nil
}
// Test returns UserAuthenticator that authenticates via testing credentials.
//
// Can be used only with testing server. Will perform sign up if test user is
// not registered.
func Test(randReader io.Reader, dc int) UserAuthenticator {
// 99966XYYYY, X = dc_id, Y = random numbers, code = X repeat 6.
// The n value is from 0000 to 9999.
n, err := crypto.RandInt64n(randReader, 1000)
if err != nil {
panic(err)
}
phone := fmt.Sprintf("99966%d%04d", dc, n)
return TestUser(phone, dc)
}
// TestUser returns UserAuthenticator that authenticates via testing credentials.
// Uses given phone to sign in/sign up.
//
// Can be used only with testing server. Will perform sign up if test user is
// not registered.
func TestUser(phone string, dc int) UserAuthenticator {
return testAuth{
dc: dc,
phone: phone,
}
}
+114
View File
@@ -0,0 +1,114 @@
package auth_test
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/auth"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func askCode(code string, err error) auth.CodeAuthenticatorFunc {
return func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) {
return code, err
}
}
func TestConstantAuth(t *testing.T) {
a := require.New(t)
authConst := auth.Constant("phone", "password", askCode("123", nil))
ctx := context.Background()
result, err := authConst.Code(ctx, nil)
a.NoError(err)
a.Equal("123", result)
result, err = authConst.Phone(ctx)
a.NoError(err)
a.Equal("phone", result)
result, err = authConst.Password(ctx)
a.NoError(err)
a.Equal("password", result)
}
func TestCodeOnlyAuth(t *testing.T) {
a := require.New(t)
authCodeOnly := auth.CodeOnly("phone", askCode("123", nil))
ctx := context.Background()
result, err := authCodeOnly.Code(ctx, nil)
a.NoError(err)
a.Equal("123", result)
result, err = authCodeOnly.Phone(ctx)
a.NoError(err)
a.Equal("phone", result)
_, err = authCodeOnly.Password(ctx)
a.ErrorIs(err, auth.ErrPasswordNotProvided)
}
func TestEnvAuth(t *testing.T) {
a := require.New(t)
ctx := context.Background()
prefix := "TEST_ENV_AUTH_"
authEnv := auth.Env(prefix, askCode("123", nil))
result, err := authEnv.Code(ctx, nil)
a.NoError(err)
a.Equal("123", result)
_, err = authEnv.Phone(ctx)
a.Error(err)
_, err = authEnv.Password(ctx)
a.ErrorIs(err, auth.ErrPasswordNotProvided)
// Set envs.
testutil.SetEnv(t, prefix+"PHONE", "phone")
testutil.SetEnv(t, prefix+"PASSWORD", "password")
result, err = authEnv.Phone(ctx)
a.NoError(err)
a.Equal("phone", result)
result, err = authEnv.Password(ctx)
a.NoError(err)
a.Equal("password", result)
}
func TestTestAuth(t *testing.T) {
a := require.New(t)
ctx := context.Background()
testAuth := auth.Test(testutil.ZeroRand{}, 2)
_, err := testAuth.Code(ctx, &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeFlashCall{},
})
a.Error(err)
result, err := testAuth.Code(ctx, nil)
a.NoError(err)
a.Equal("22222", result)
result, err = testAuth.Code(ctx, &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{
Length: 1,
},
})
a.NoError(err)
a.Equal("2", result)
result, err = testAuth.Phone(ctx)
a.NoError(err)
a.True(strings.HasPrefix(result, "999662"))
_, err = testAuth.Password(ctx)
a.ErrorIs(err, auth.ErrPasswordNotProvided)
}
+179
View File
@@ -0,0 +1,179 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto/srp"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// PasswordHash computes password hash to log in.
//
// See https://core.telegram.org/api/srp#checking-the-password-with-srp.
func PasswordHash(
password []byte,
srpID int64,
srpB, secureRandom []byte,
alg tg.PasswordKdfAlgoClass,
) (*tg.InputCheckPasswordSRP, error) {
s := srp.NewSRP(crypto.DefaultRand())
algo, ok := alg.(*tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow)
if !ok {
return nil, errors.Errorf("unsupported algo: %T", alg)
}
a, err := s.Hash(password, srpB, secureRandom, srp.Input(*algo))
if err != nil {
return nil, errors.Wrap(err, "create SRP answer")
}
return &tg.InputCheckPasswordSRP{
SRPID: srpID,
A: a.A,
M1: a.M1,
}, nil
}
// NewPasswordHash computes new password hash to update password.
//
// Notice that NewPasswordHash mutates given alg.
//
// See https://core.telegram.org/api/srp#setting-a-new-2fa-password.
func NewPasswordHash(
password []byte,
algo *tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow,
) (hash []byte, _ error) {
s := srp.NewSRP(crypto.DefaultRand())
hash, newSalt, err := s.NewHash(password, srp.Input(*algo))
if err != nil {
return nil, errors.Wrap(err, "create SRP answer")
}
algo.Salt1 = newSalt
return hash, nil
}
var (
emptyPassword tg.InputCheckPasswordSRPClass = &tg.InputCheckPasswordEmpty{}
)
// UpdatePasswordOptions is options structure for UpdatePassword.
type UpdatePasswordOptions struct {
// Hint is new password hint.
Hint string
// Password is password callback.
//
// If password was requested and Password is nil, ErrPasswordNotProvided error will be returned.
Password func(ctx context.Context) (string, error)
}
// UpdatePassword sets new cloud password for this account.
//
// See https://core.telegram.org/api/srp#setting-a-new-2fa-password.
func (c *Client) UpdatePassword(
ctx context.Context,
newPassword string,
opts UpdatePasswordOptions,
) error {
p, err := c.api.AccountGetPassword(ctx)
if err != nil {
return errors.Wrap(err, "get SRP parameters")
}
algo, ok := p.NewAlgo.(*tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow)
if !ok {
return errors.Errorf("unsupported algo: %T", p.NewAlgo)
}
newHash, err := NewPasswordHash([]byte(newPassword), algo)
if err != nil {
return errors.Wrap(err, "compute new password hash")
}
var old = emptyPassword
if p.HasPassword {
if opts.Password == nil {
return ErrPasswordNotProvided
}
oldPassword, err := opts.Password(ctx)
if err != nil {
return errors.Wrap(err, "get password")
}
hash, err := PasswordHash([]byte(oldPassword), p.SRPID, p.SRPB, p.SecureRandom, p.CurrentAlgo)
if err != nil {
return errors.Wrap(err, "compute old password hash")
}
old = hash
}
if _, err := c.api.AccountUpdatePasswordSettings(ctx, &tg.AccountUpdatePasswordSettingsRequest{
Password: old,
NewSettings: tg.AccountPasswordInputSettings{
NewAlgo: algo,
NewPasswordHash: newHash,
Hint: opts.Hint,
},
}); err != nil {
return errors.Wrap(err, "update password")
}
return nil
}
// ResetFailedWaitError reports that you recently requested a password reset that was cancel and need to wait until the
// specified date before requesting another reset.
type ResetFailedWaitError struct {
Result tg.AccountResetPasswordFailedWait
}
// Until returns time required to wait.
func (r ResetFailedWaitError) Until() time.Duration {
retryDate := time.Unix(int64(r.Result.RetryDate), 0)
return time.Until(retryDate)
}
// Error implements error.
func (r *ResetFailedWaitError) Error() string {
return fmt.Sprintf("wait to reset password (%s)", r.Until())
}
// ResetPassword resets cloud password and returns time to wait until reset be performed.
// If time is zero, password was successfully reset.
//
// May return ResetFailedWaitError.
//
// See https://core.telegram.org/api/srp#password-reset.
func (c *Client) ResetPassword(ctx context.Context) (time.Time, error) {
r, err := c.api.AccountResetPassword(ctx)
if err != nil {
return time.Time{}, errors.Wrap(err, "reset password")
}
switch v := r.(type) {
case *tg.AccountResetPasswordFailedWait:
return time.Time{}, &ResetFailedWaitError{Result: *v}
case *tg.AccountResetPasswordRequestedWait:
return time.Unix(int64(v.UntilDate), 0), nil
case *tg.AccountResetPasswordOk:
return time.Time{}, nil
default:
return time.Time{}, errors.Errorf("unexpected type %T", v)
}
}
// CancelPasswordReset cancels password reset.
//
// See https://core.telegram.org/api/srp#password-reset.
func (c *Client) CancelPasswordReset(ctx context.Context) error {
if _, err := c.api.AccountDeclinePasswordReset(ctx); err != nil {
return errors.Wrap(err, "cancel password reset")
}
return nil
}
@@ -0,0 +1,62 @@
package auth_test
import (
"context"
"fmt"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/auth"
)
func ExampleClient_UpdatePassword() {
ctx := context.Background()
client := telegram.NewClient(telegram.TestAppID, telegram.TestAppHash, telegram.Options{})
if err := client.Run(ctx, func(ctx context.Context) error {
// Updating password.
if err := client.Auth().UpdatePassword(ctx, "new_password", auth.UpdatePasswordOptions{
// Hint sets new password hint.
Hint: "new password hint",
// Password will be called if old password is requested by Telegram.
//
// If password was requested and Password is nil, auth.ErrPasswordNotProvided error will be returned.
Password: func(ctx context.Context) (string, error) {
return "old_password", nil
},
}); err != nil {
return err
}
return nil
}); err != nil {
panic(err)
}
}
func ExampleClient_ResetPassword() {
ctx := context.Background()
client := telegram.NewClient(telegram.TestAppID, telegram.TestAppHash, telegram.Options{})
if err := client.Run(ctx, func(ctx context.Context) error {
wait, err := client.Auth().ResetPassword(ctx)
var waitErr *auth.ResetFailedWaitError
switch {
case errors.As(err, &waitErr):
// Telegram requested wait until making new reset request.
fmt.Printf("Wait until %s to reset password.\n", wait.String())
case err != nil:
return err
}
// If returned time is zero, password was successfully reset.
if wait.IsZero() {
fmt.Println("Password was reset.")
return nil
}
fmt.Printf("Password will be reset on %s.\n", wait.String())
return nil
}); err != nil {
panic(err)
}
}
+169
View File
@@ -0,0 +1,169 @@
package auth
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func TestPasswordHash(t *testing.T) {
a := require.New(t)
_, err := PasswordHash(nil, 0, nil, nil, nil)
a.Error(err, "unsupported algo")
}
var testAlgo = &tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow{
Salt1: []uint8{
230, 200, 149, 125, 223, 152, 141, 72,
},
Salt2: []uint8{
159, 99, 68, 130, 43, 9, 108, 255, 135, 239, 164, 38, 245, 120, 87, 182,
},
G: 3,
P: []uint8{
199, 28, 174, 185, 198, 177, 201, 4, 142, 108, 82, 47, 112, 241, 63, 115,
152, 13, 64, 35, 142, 62, 33, 193, 73, 52, 208, 55, 86, 61, 147, 15,
72, 25, 138, 10, 167, 193, 64, 88, 34, 148, 147, 210, 37, 48, 244, 219,
250, 51, 111, 110, 10, 201, 37, 19, 149, 67, 174, 212, 76, 206, 124, 55,
32, 253, 81, 246, 148, 88, 112, 90, 198, 140, 212, 254, 107, 107, 19, 171,
220, 151, 70, 81, 41, 105, 50, 132, 84, 241, 143, 175, 140, 89, 95, 100,
36, 119, 254, 150, 187, 42, 148, 29, 91, 205, 29, 74, 200, 204, 73, 136,
7, 8, 250, 155, 55, 142, 60, 79, 58, 144, 96, 190, 230, 124, 249, 164,
164, 166, 149, 129, 16, 81, 144, 126, 22, 39, 83, 181, 107, 15, 107, 65,
13, 186, 116, 216, 168, 75, 42, 20, 179, 20, 78, 14, 241, 40, 71, 84,
253, 23, 237, 149, 13, 89, 101, 180, 185, 221, 70, 88, 45, 177, 23, 141,
22, 156, 107, 196, 101, 176, 214, 255, 156, 163, 146, 143, 239, 91, 154, 228,
228, 24, 252, 21, 232, 62, 190, 160, 248, 127, 169, 255, 94, 237, 112, 5,
13, 237, 40, 73, 244, 123, 249, 89, 217, 86, 133, 12, 233, 41, 133, 31,
13, 129, 21, 246, 53, 177, 5, 238, 46, 78, 21, 208, 75, 36, 84, 191,
111, 79, 173, 240, 52, 177, 4, 3, 17, 156, 216, 227, 185, 47, 204, 91,
},
}
func TestClient_UpdatePassword(t *testing.T) {
ctx := context.Background()
expectCall := func(a *require.Assertions, m *tgmock.Mock, hasPassword bool) *tgmock.RequestBuilder {
p := &tg.AccountPassword{
HasPassword: hasPassword,
NewAlgo: testAlgo,
NewSecureAlgo: &tg.SecurePasswordKdfAlgoUnknown{},
}
if hasPassword {
p.CurrentAlgo = testAlgo
}
p.SetFlags()
return m.ExpectCall(&tg.AccountGetPasswordRequest{}).
ThenResult(p).ExpectFunc(func(b bin.Encoder) {
a.IsType(&tg.AccountUpdatePasswordSettingsRequest{}, b)
r := b.(*tg.AccountUpdatePasswordSettingsRequest)
if !hasPassword {
a.Equal(emptyPassword, r.Password)
} else {
a.NotEqual(emptyPassword, r.Password)
}
a.NotEmpty(r.NewSettings.NewPasswordHash)
a.Equal("hint", r.NewSettings.Hint)
})
}
t.Run("PasswordNotRequired", mockTest(func(
a *require.Assertions,
m *tgmock.Mock,
client *Client,
) {
m.ExpectCall(&tg.AccountGetPasswordRequest{}).ThenErr(testutil.TestError())
a.Error(client.UpdatePassword(ctx, "", UpdatePasswordOptions{}))
expectCall(a, m, false).ThenTrue()
a.NoError(client.UpdatePassword(ctx, "", UpdatePasswordOptions{
Hint: "hint",
}))
}))
t.Run("PasswordRequired", mockTest(func(
a *require.Assertions,
m *tgmock.Mock,
client *Client,
) {
m.ExpectCall(&tg.AccountGetPasswordRequest{}).
ThenResult(&tg.AccountPassword{
HasPassword: true,
NewAlgo: testAlgo,
CurrentAlgo: testAlgo,
NewSecureAlgo: &tg.SecurePasswordKdfAlgoUnknown{},
})
a.ErrorIs(client.UpdatePassword(ctx, "", UpdatePasswordOptions{}), ErrPasswordNotProvided)
m.ExpectCall(&tg.AccountGetPasswordRequest{}).
ThenResult(&tg.AccountPassword{
HasPassword: true,
NewAlgo: testAlgo,
CurrentAlgo: testAlgo,
NewSecureAlgo: &tg.SecurePasswordKdfAlgoUnknown{},
})
a.ErrorIs(client.UpdatePassword(ctx, "", UpdatePasswordOptions{
Hint: "hint",
Password: func(ctx context.Context) (string, error) {
return "", testutil.TestError()
},
}), testutil.TestError())
expectCall(a, m, true).ThenTrue()
a.NoError(client.UpdatePassword(ctx, "", UpdatePasswordOptions{
Hint: "hint",
Password: func(ctx context.Context) (string, error) {
return "password", nil
},
}))
}))
}
func TestClient_ResetPassword(t *testing.T) {
ctx := context.Background()
wait := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
mockTest(func(a *require.Assertions, mock *tgmock.Mock, client *Client) {
mock.ExpectCall(&tg.AccountResetPasswordRequest{}).ThenErr(testutil.TestError())
_, err := client.ResetPassword(ctx)
a.Error(err)
mock.ExpectCall(&tg.AccountResetPasswordRequest{}).ThenResult(&tg.AccountResetPasswordFailedWait{
RetryDate: int(wait),
})
var waitErr *ResetFailedWaitError
_, err = client.ResetPassword(ctx)
a.ErrorAs(err, &waitErr)
a.Equal(int(wait), waitErr.Result.RetryDate)
a.NotEmpty(waitErr.Error())
mock.ExpectCall(&tg.AccountResetPasswordRequest{}).ThenResult(&tg.AccountResetPasswordOk{})
r, err := client.ResetPassword(ctx)
a.NoError(err)
a.True(r.IsZero())
mock.ExpectCall(&tg.AccountResetPasswordRequest{}).ThenResult(&tg.AccountResetPasswordRequestedWait{
UntilDate: int(wait),
})
r, err = client.ResetPassword(ctx)
a.NoError(err)
a.False(r.IsZero())
})(t)
}
func TestClient_CancelPasswordReset(t *testing.T) {
ctx := context.Background()
mockTest(func(a *require.Assertions, mock *tgmock.Mock, client *Client) {
mock.ExpectCall(&tg.AccountDeclinePasswordResetRequest{}).ThenErr(testutil.TestError())
a.Error(client.CancelPasswordReset(ctx))
mock.ExpectCall(&tg.AccountDeclinePasswordResetRequest{}).ThenTrue()
a.NoError(client.CancelPasswordReset(ctx))
})(t)
}
+20
View File
@@ -0,0 +1,20 @@
package qrlogin
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// AcceptQR accepts given token.
//
// See https://core.telegram.org/api/qr-login#accepting-a-login-token.
func AcceptQR(ctx context.Context, raw *tg.Client, t Token) (*tg.Authorization, error) {
auth, err := raw.AuthAcceptLoginToken(ctx, t.token)
if err != nil {
return nil, errors.Wrap(err, "accept")
}
return auth, nil
}
+23
View File
@@ -0,0 +1,23 @@
package qrlogin
import (
"fmt"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// MigrationNeededError reports that Telegram requested DC migration to continue login.
type MigrationNeededError struct {
MigrateTo *tg.AuthLoginTokenMigrateTo
// Tried indicates that the migration was attempted.
//
// Deprecated: do not use. QR login uses migrate function passed via
// options.
Tried bool
}
// Error implements error.
func (m *MigrationNeededError) Error() string {
return fmt.Sprintf("migration to %d needed", m.MigrateTo.DCID)
}
+20
View File
@@ -0,0 +1,20 @@
package qrlogin
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/clock"
)
// Options of QR.
type Options struct {
Migrate func(ctx context.Context, dcID int) error
Clock clock.Clock
}
func (o *Options) setDefaults() {
// It's okay to use zero value Migrate.
if o.Clock == nil {
o.Clock = clock.System
}
}
+175
View File
@@ -0,0 +1,175 @@
// Package qrlogin provides QR login flow implementation.
//
// See https://core.telegram.org/api/qr-login.
package qrlogin
import (
"context"
"time"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/clock"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// QR implements Telegram QR login flow.
type QR struct {
api *tg.Client
appID int
appHash string
migrate func(ctx context.Context, dcID int) error
clock clock.Clock
}
// NewQR creates new QR
func NewQR(api *tg.Client, appID int, appHash string, opts Options) QR {
opts.setDefaults()
return QR{
api: api,
appID: appID,
appHash: appHash,
clock: opts.Clock,
migrate: opts.Migrate,
}
}
// Export exports new login token.
//
// See https://core.telegram.org/api/qr-login#exporting-a-login-token.
func (q QR) Export(ctx context.Context, exceptIDs ...int64) (Token, error) {
result, err := q.api.AuthExportLoginToken(ctx, &tg.AuthExportLoginTokenRequest{
APIID: q.appID,
APIHash: q.appHash,
ExceptIDs: exceptIDs,
})
if err != nil {
return Token{}, errors.Wrap(err, "export")
}
t, ok := result.(*tg.AuthLoginToken)
if !ok {
return Token{}, errors.Errorf("unexpected type %T", result)
}
return NewToken(t.Token, t.Expires), nil
}
// Accept accepts given token.
//
// See https://core.telegram.org/api/qr-login#accepting-a-login-token.
func (q QR) Accept(ctx context.Context, t Token) (*tg.Authorization, error) {
return AcceptQR(ctx, q.api, t)
}
// Import imports accepted token.
//
// See https://core.telegram.org/api/qr-login#confirming-importing-the-login-token.
func (q QR) Import(ctx context.Context) (*tg.AuthAuthorization, error) {
result, err := q.api.AuthExportLoginToken(ctx, &tg.AuthExportLoginTokenRequest{
APIID: q.appID,
APIHash: q.appHash,
})
if err != nil {
return nil, errors.Wrap(err, "import")
}
switch t := result.(type) {
case *tg.AuthLoginTokenMigrateTo:
if q.migrate == nil {
return nil, &MigrationNeededError{
MigrateTo: t,
}
}
if err := q.migrate(ctx, t.DCID); err != nil {
return nil, errors.Wrap(err, "migrate")
}
res, err := q.api.AuthImportLoginToken(ctx, t.Token)
if err != nil {
return nil, errors.Wrap(err, "import")
}
success, ok := res.(*tg.AuthLoginTokenSuccess)
if !ok {
return nil, errors.Errorf("unexpected type %T", res)
}
auth, ok := success.Authorization.(*tg.AuthAuthorization)
if !ok {
return nil, errors.Errorf("unexpected type %T", success.Authorization)
}
return auth, nil
case *tg.AuthLoginTokenSuccess:
auth, ok := t.Authorization.(*tg.AuthAuthorization)
if !ok {
return nil, errors.Errorf("unexpected type %T", t.Authorization)
}
return auth, nil
default:
return nil, errors.Errorf("unexpected type %T", result)
}
}
// LoggedIn is signal channel to notify about tg.UpdateLoginToken.
type LoggedIn <-chan struct{}
// OnLoginToken sets handler for given dispatcher and returns signal channel.
func OnLoginToken(d interface {
OnLoginToken(tg.LoginTokenHandler)
},
) LoggedIn {
loggedIn := make(chan struct{})
d.OnLoginToken(func(ctx context.Context, e tg.Entities, update *tg.UpdateLoginToken) error {
select {
case loggedIn <- struct{}{}:
return nil
default:
}
return nil
})
return loggedIn
}
// Auth generates new QR login token, shows it and awaits acceptation.
//
// NB: Show callback may be called more than once if QR expires.
func (q QR) Auth(
ctx context.Context,
loggedIn LoggedIn,
show func(ctx context.Context, token Token) error,
exceptIDs ...int64,
) (*tg.AuthAuthorization, error) {
until := func(token Token) time.Duration {
return token.Expires().Sub(q.clock.Now()).Truncate(time.Second)
}
token, err := q.Export(ctx, exceptIDs...)
if err != nil {
return nil, err
}
timer := q.clock.Timer(until(token))
defer clock.StopTimer(timer)
for {
if err := show(ctx, token); err != nil {
return nil, errors.Wrap(err, "show")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C():
t, err := q.Export(ctx, exceptIDs...)
if err != nil {
return nil, err
}
token = t
timer.Reset(until(token))
continue
case <-loggedIn:
}
return q.Import(ctx)
}
}
@@ -0,0 +1,187 @@
package qrlogin
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/gotd/neo"
"go.mau.fi/mautrix-telegram/pkg/gotd/constant"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func testQR(t *testing.T, migrate func(ctx context.Context, dcID int) error) (*tgmock.Mock, QR) {
mock := tgmock.New(t)
return mock, NewQR(tg.NewClient(mock), constant.TestAppID, constant.TestAppHash, Options{
Migrate: migrate,
})
}
var testToken = NewToken([]byte("token"), 0)
func TestQR_Export(t *testing.T) {
ctx := context.Background()
a := require.New(t)
mock, qr := testQR(t, nil)
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
ExceptIDs: []int64{0},
}).ThenResult(&tg.AuthLoginToken{
Expires: 0,
Token: testToken.token,
})
result, err := qr.Export(ctx, 0)
a.NoError(err)
a.Equal(Token{
token: testToken.token,
expires: time.Unix(0, 0),
}, result)
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginTokenMigrateTo{
Token: testToken.token,
})
_, err = qr.Export(ctx)
a.Error(err)
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenErr(testutil.TestError())
_, err = qr.Export(ctx)
a.ErrorIs(err, testutil.TestError())
}
func TestQR_Accept(t *testing.T) {
ctx := context.Background()
a := require.New(t)
mock, qr := testQR(t, nil)
auth := &tg.Authorization{
APIID: 1,
}
mock.ExpectCall(&tg.AuthAcceptLoginTokenRequest{
Token: testToken.token,
}).ThenResult(auth)
result, err := qr.Accept(ctx, testToken)
a.NoError(err)
a.Equal(auth, result)
mock.ExpectCall(&tg.AuthAcceptLoginTokenRequest{
Token: testToken.token,
}).ThenErr(testutil.TestError())
_, err = qr.Accept(ctx, testToken)
a.ErrorIs(err, testutil.TestError())
}
func TestQR_Import(t *testing.T) {
ctx := context.Background()
a := require.New(t)
mock, qr := testQR(t, nil)
auth := &tg.AuthAuthorization{
User: &tg.User{ID: 10},
}
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginTokenSuccess{
Authorization: auth,
})
result, err := qr.Import(ctx)
a.NoError(err)
a.Equal(auth, result)
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginTokenMigrateTo{
DCID: 1,
})
_, err = qr.Import(ctx)
var mig *MigrationNeededError
a.ErrorAs(err, &mig)
a.Equal(1, mig.MigrateTo.DCID)
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginToken{
Token: testToken.token,
})
_, err = qr.Import(ctx)
a.Error(err)
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenErr(testutil.TestError())
_, err = qr.Import(ctx)
a.ErrorIs(err, testutil.TestError())
}
func TestQR_Auth(t *testing.T) {
a := require.New(t)
mock := tgmock.New(t)
clock := neo.NewTime(time.Now())
auth := &tg.AuthAuthorization{
User: &tg.User{ID: 10},
}
mock.ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginToken{
Expires: int(clock.Now().Add(time.Minute).Unix()),
Token: testToken.token,
}).ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginToken{
Expires: int(clock.Now().Add(2 * time.Minute).Unix()),
Token: testToken.token,
}).ExpectCall(&tg.AuthExportLoginTokenRequest{
APIID: constant.TestAppID,
APIHash: constant.TestAppHash,
}).ThenResult(&tg.AuthLoginTokenSuccess{
Authorization: auth,
})
qr := NewQR(tg.NewClient(mock), constant.TestAppID, constant.TestAppHash, Options{
Clock: clock,
})
show := make(chan struct{})
done := make(chan error)
loggedIn := make(chan struct{})
go func() {
_, err := qr.Auth(context.Background(), loggedIn, func(ctx context.Context, token Token) error {
show <- struct{}{}
return nil
})
done <- err
}()
// Show QR first time.
<-show
// Skip 1 minute, token expires.
clock.Travel(time.Minute + 1)
// Show QR second time.
<-show
// Emulate update, auth done.
loggedIn <- struct{}{}
a.NoError(<-done)
}
+76
View File
@@ -0,0 +1,76 @@
package qrlogin
import (
"encoding/base64"
"image"
"net/url"
"time"
"github.com/go-faster/errors"
"rsc.io/qr"
)
// Token represents Telegram QR Login token.
type Token struct {
token []byte
expires time.Time
}
// ParseTokenURL creates Token from given URL.
func ParseTokenURL(u string) (Token, error) {
parsed, err := url.Parse(u)
if err != nil {
return Token{}, err
}
switch {
case parsed.Scheme != "tg":
return Token{}, errors.Errorf("unexpected scheme %q", parsed.Scheme)
case parsed.Host != "login":
return Token{}, errors.Errorf("wrong path %q", parsed.Host)
}
q := parsed.Query()
if q.Get("token") == "" {
return Token{}, errors.New("token is empty")
}
token, err := base64.URLEncoding.DecodeString(q.Get("token"))
if err != nil {
return Token{}, err
}
return NewToken(token, 0), nil
}
// NewToken creates new Token.
func NewToken(token []byte, expires int) Token {
return Token{
token: token,
expires: time.Unix(int64(expires), 0),
}
}
// Expires returns token expiration time.
func (t Token) Expires() time.Time {
return t.expires
}
// String implements fmt.Stringer.
func (t Token) String() string {
return base64.URLEncoding.EncodeToString(t.token)
}
// URL returns login URL.
//
// See https://core.telegram.org/api/qr-login#exporting-a-login-token.
func (t Token) URL() string {
return "tg://login?token=" + base64.URLEncoding.EncodeToString(t.token)
}
// Image returns QR image.
func (t Token) Image(level qr.Level) (image.Image, error) {
code, err := qr.Encode(t.URL(), level)
if err != nil {
return nil, errors.Wrap(err, "encode")
}
return code.Image(), nil
}
@@ -0,0 +1,52 @@
package qrlogin
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestParseTokenURL(t *testing.T) {
tests := []struct {
name string
u string
want Token
wantErr bool
}{
{
"Valid",
"tg://login?token=AQL0cY5hVg_D1OqESdYnJVg5845qbd8FiOLpUUeyvcb28g==",
Token{
token: []uint8{
0x1, 0x2, 0xf4, 0x71, 0x8e, 0x61,
0x56, 0xf, 0xc3, 0xd4, 0xea, 0x84,
0x49, 0xd6, 0x27, 0x25, 0x58, 0x39,
0xf3, 0x8e, 0x6a, 0x6d, 0xdf, 0x5,
0x88, 0xe2, 0xe9, 0x51, 0x47, 0xb2,
0xbd, 0xc6, 0xf6, 0xf2,
},
expires: time.Unix(0, 0),
},
false,
},
{"InvalidSchema", "vk://login", Token{}, true},
{"InvalidPath", "tg://aboba", Token{}, true},
{"NoToken", "tg://login", Token{}, true},
{"InvalidBase64", "tg://login?token=A", Token{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := require.New(t)
got, err := ParseTokenURL(tt.u)
if tt.wantErr {
a.Error(err)
} else {
a.Equal(tt.want, got)
a.NoError(err)
a.Equal(tt.want.URL(), tt.u)
}
})
}
}
+26
View File
@@ -0,0 +1,26 @@
package auth
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// self returns current user.
//
// You can use tg.User.Bot to check whether current user is bot.
func (c *Client) self(ctx context.Context) (*tg.User, error) {
users, err := c.api.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}})
if err != nil {
return nil, err
}
user, ok := tg.UserClassArray(users).FirstAsNotEmpty()
if !ok {
return nil, errors.Errorf("users response count: %v", users)
}
return user, nil
}
+42
View File
@@ -0,0 +1,42 @@
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func TestClient_self(t *testing.T) {
ctx := context.Background()
mockTest(func(a *require.Assertions, mock *tgmock.Mock, client *Client) {
mock.ExpectCall(&tg.UsersGetUsersRequest{
ID: []tg.InputUserClass{&tg.InputUserSelf{}},
}).ThenErr(testutil.TestError())
_, err := client.self(ctx)
a.Error(err)
mock.ExpectCall(&tg.UsersGetUsersRequest{
ID: []tg.InputUserClass{&tg.InputUserSelf{}},
}).ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{&tg.UserEmpty{
ID: 10,
}}})
_, err = client.self(ctx)
a.Error(err)
mock.ExpectCall(&tg.UsersGetUsersRequest{
ID: []tg.InputUserClass{&tg.InputUserSelf{}},
}).ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{&tg.User{
Self: true,
ID: 10,
AccessHash: 10,
}}})
r, err := client.self(ctx)
a.NoError(err)
a.Equal(int64(10), r.ID)
})(t)
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// SignUpRequired means that log in failed because corresponding account
// does not exist, so sign up is required.
type SignUpRequired struct {
TermsOfService tg.HelpTermsOfService
}
// Is returns true if err is SignUpRequired.
func (s *SignUpRequired) Is(err error) bool {
_, ok := err.(*SignUpRequired)
return ok
}
func (s *SignUpRequired) Error() string {
return "account with provided number does not exist (sign up required)"
}
// checkResult checks that `a` is *tg.AuthAuthorization and returns authorization result or error.
func checkResult(a tg.AuthAuthorizationClass) (*tg.AuthAuthorization, error) {
switch a := a.(type) {
case *tg.AuthAuthorization:
return a, nil // ok
case *tg.AuthAuthorizationSignUpRequired:
return nil, &SignUpRequired{
TermsOfService: a.TermsOfService,
}
default:
return nil, errors.Errorf("got unexpected response %T", a)
}
}
+60
View File
@@ -0,0 +1,60 @@
package auth
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Status represents authorization status.
type Status struct {
// Authorized is true if client is authorized.
Authorized bool
// User is current User object.
User *tg.User
}
// Status gets authorization status of client.
func (c *Client) Status(ctx context.Context) (*Status, error) {
u, err := c.self(ctx)
if IsUnauthorized(err) {
return &Status{}, nil
}
if err != nil {
return nil, err
}
return &Status{
Authorized: true,
User: u,
}, nil
}
// IfNecessary runs given auth flow if current session is not authorized.
func (c *Client) IfNecessary(ctx context.Context, flow Flow) error {
auth, err := c.Status(ctx)
if err != nil {
return errors.Wrap(err, "get auth status")
}
if auth.Authorized {
return nil
}
if err := flow.Run(ctx, c); err != nil {
return errors.Wrap(err, "auth flow")
}
return nil
}
// Test creates and runs auth flow using Test authenticator
// if current session is not authorized.
func (c *Client) Test(ctx context.Context, dc int) error {
return c.IfNecessary(ctx, NewFlow(Test(c.rand, dc), SendCodeOptions{}))
}
// TestUser creates and runs auth flow using TestUser authenticator
// if current session is not authorized.
func (c *Client) TestUser(ctx context.Context, phone string, dc int) error {
return c.IfNecessary(ctx, NewFlow(TestUser(phone, dc), SendCodeOptions{}))
}
+107
View File
@@ -0,0 +1,107 @@
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func TestClient_Status(t *testing.T) {
ctx := context.Background()
t.Run("Authorized", func(t *testing.T) {
mock := tgmock.NewRequire(t)
user := &tg.User{
Username: "user",
}
mock.Expect().ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{user}})
status, err := testClient(mock).Status(ctx)
require.NoError(t, err)
require.True(t, status.Authorized)
require.Equal(t, user, status.User)
})
t.Run("Unauthorized", func(t *testing.T) {
mock := tgmock.NewRequire(t)
mock.Expect().ThenUnregistered()
status, err := testClient(mock).Status(ctx)
require.NoError(t, err)
require.False(t, status.Authorized)
})
t.Run("Error", func(t *testing.T) {
mock := tgmock.NewRequire(t)
mock.Expect().ThenRPCErr(&tgerr.Error{
Code: 500,
Message: "BRUH",
Type: "BRUH",
})
_, err := testClient(mock).Status(ctx)
require.Error(t, err)
})
}
func TestClient_IfNecessary(t *testing.T) {
ctx := context.Background()
t.Run("Authorized", func(t *testing.T) {
mock := tgmock.NewRequire(t)
testUser := &tg.User{
Username: "user",
}
mock.Expect().ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{testUser}})
// Pass empty AuthFlow because it should not be called anyway.
require.NoError(t, testClient(mock).IfNecessary(ctx, Flow{}))
})
t.Run("Error", func(t *testing.T) {
mock := tgmock.NewRequire(t)
mock.Expect().ThenRPCErr(&tgerr.Error{
Code: 500,
Message: "BRUH",
Type: "BRUH",
})
// Pass empty AuthFlow because it should not be called anyway.
require.Error(t, testClient(mock).IfNecessary(ctx, Flow{}))
})
}
func TestClient_Test(t *testing.T) {
ctx := context.Background()
t.Run("Authorized", func(t *testing.T) {
mock := tgmock.NewRequire(t)
testUser := &tg.User{
Username: "user",
}
mock.Expect().ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{testUser}})
// Pass empty AuthFlow because it should not be called anyway.
require.NoError(t, testClient(mock).Test(ctx, 2))
})
}
func TestClient_TestUser(t *testing.T) {
ctx := context.Background()
t.Run("Authorized", func(t *testing.T) {
mock := tgmock.NewRequire(t)
testUser := &tg.User{
Username: "user",
}
mock.Expect().ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{testUser}})
// Pass empty AuthFlow because it should not be called anyway.
require.NoError(t, testClient(mock).TestUser(ctx, "phone", 2))
})
}
+154
View File
@@ -0,0 +1,154 @@
package auth
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
)
// ErrPasswordInvalid means that password provided to Password is invalid.
//
// Note that telegram does not trim whitespace characters by default, check
// that provided password is expected and clean whitespaces if needed.
// You can use strings.TrimSpace(password) for this.
var ErrPasswordInvalid = errors.New("invalid password")
// Password performs login via secure remote password (aka 2FA).
//
// Method can be called after SignIn to provide password if requested.
func (c *Client) Password(ctx context.Context, password string) (*tg.AuthAuthorization, error) {
p, err := c.api.AccountGetPassword(ctx)
if err != nil {
return nil, errors.Wrap(err, "get SRP parameters")
}
a, err := PasswordHash([]byte(password), p.SRPID, p.SRPB, p.SecureRandom, p.CurrentAlgo)
if err != nil {
return nil, errors.Wrap(err, "compute password hash")
}
auth, err := c.api.AuthCheckPassword(ctx, &tg.InputCheckPasswordSRP{
SRPID: p.SRPID,
A: a.A,
M1: a.M1,
})
if tg.IsPasswordHashInvalid(err) {
return nil, ErrPasswordInvalid
}
if err != nil {
return nil, errors.Wrap(err, "check password")
}
result, err := checkResult(auth)
if err != nil {
return nil, errors.Wrap(err, "check")
}
return result, nil
}
// SendCodeOptions defines how to send auth code to user.
type SendCodeOptions struct {
// AllowFlashCall allows phone verification via phone calls.
AllowFlashCall bool
// Pass true if the phone number is used on the current device.
// Ignored if AllowFlashCall is not set.
CurrentNumber bool
// If a token that will be included in eventually sent SMSs is required:
// required in newer versions of android, to use the android SMS receiver APIs.
AllowAppHash bool
}
// SendCode requests code for provided phone number, returning code hash
// and error if any. Use AuthFlow to reduce boilerplate.
//
// This method should be called first in user authentication flow.
func (c *Client) SendCode(ctx context.Context, phone string, options SendCodeOptions) (tg.AuthSentCodeClass, error) {
var settings tg.CodeSettings
if options.AllowAppHash {
settings.SetAllowAppHash(true)
}
if options.AllowFlashCall {
settings.SetAllowFlashcall(true)
}
if options.CurrentNumber {
settings.SetCurrentNumber(true)
}
sentCode, err := c.api.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
PhoneNumber: phone,
APIID: c.appID,
APIHash: c.appHash,
Settings: settings,
})
if err != nil {
return nil, errors.Wrap(err, "send code")
}
return sentCode, nil
}
// ErrPasswordAuthNeeded means that 2FA auth is required.
//
// Call Client.Password to provide 2FA password.
var ErrPasswordAuthNeeded = errors.New("2FA required")
// SignIn performs sign in with provided user phone, code and code hash.
//
// If ErrPasswordAuthNeeded is returned, call Password to provide 2FA
// password.
//
// To obtain codeHash, use SendCode.
func (c *Client) SignIn(ctx context.Context, phone, code, codeHash string) (*tg.AuthAuthorization, error) {
auth, err := c.api.AuthSignIn(ctx, &tg.AuthSignInRequest{
PhoneNumber: phone,
PhoneCodeHash: codeHash,
PhoneCode: code,
})
if tgerr.Is(err, "SESSION_PASSWORD_NEEDED") {
return nil, ErrPasswordAuthNeeded
}
if err != nil {
return nil, errors.Wrap(err, "sign in")
}
result, err := checkResult(auth)
if err != nil {
return nil, errors.Wrap(err, "check")
}
return result, nil
}
// AcceptTOS accepts version of Terms Of Service.
func (c *Client) AcceptTOS(ctx context.Context, id tg.DataJSON) error {
_, err := c.api.HelpAcceptTermsOfService(ctx, id)
return err
}
// SignUp wraps parameters for SignUp.
type SignUp struct {
PhoneNumber string
PhoneCodeHash string
FirstName string
LastName string
}
// SignUp registers a validated phone number in the system.
//
// To obtain codeHash, use SendCode.
// Use AuthFlow helper to handle authentication flow.
func (c *Client) SignUp(ctx context.Context, s SignUp) (*tg.AuthAuthorization, error) {
auth, err := c.api.AuthSignUp(ctx, &tg.AuthSignUpRequest{
LastName: s.LastName,
PhoneCodeHash: s.PhoneCodeHash,
PhoneNumber: s.PhoneNumber,
FirstName: s.FirstName,
})
if err != nil {
return nil, errors.Wrap(err, "request")
}
result, err := checkResult(auth)
if err != nil {
return nil, errors.Wrap(err, "check")
}
return result, nil
}
+254
View File
@@ -0,0 +1,254 @@
package auth
import (
"context"
"encoding/hex"
"math/rand"
"strconv"
"strings"
"testing"
"github.com/go-faster/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func getHex(t testing.TB, in string) []byte {
res, err := hex.DecodeString(in)
if err != nil {
t.Fatal("failed to get hex", err)
}
return res
}
func TestClient_AuthSignIn(t *testing.T) {
const (
phone = "123123"
code = "1010"
password = "secret"
codeHash = "hash"
)
ctx := context.Background()
testUser := &tg.User{ID: 1}
invoker := tgmock.Invoker(func(body bin.Encoder) (bin.Encoder, error) {
switch req := body.(type) {
case *tg.UsersGetUsersRequest:
return nil, &tgerr.Error{
Code: 401,
Message: "AUTH_KEY_UNREGISTERED",
Type: "AUTH_KEY_UNREGISTERED",
}
case *tg.AuthSendCodeRequest:
settings := tg.CodeSettings{}
settings.SetCurrentNumber(true)
assert.Equal(t, &tg.AuthSendCodeRequest{
PhoneNumber: phone,
APIHash: testAppHash,
APIID: testAppID,
Settings: settings,
}, req)
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{},
PhoneCodeHash: codeHash,
}, nil
case *tg.AuthSignInRequest:
assert.Equal(t, &tg.AuthSignInRequest{
PhoneNumber: phone,
PhoneCodeHash: codeHash,
PhoneCode: code,
}, req)
return nil, tgerr.New(401, "SESSION_PASSWORD_NEEDED")
case *tg.AccountGetPasswordRequest:
algo := &tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow{
Salt1: getHex(t, "4D11FB6BEC38F9D2546BB0F61E4F1C99A1BC0DB8F0D5F35B1291B37B213123D7ED48F3C6794D495B"),
Salt2: getHex(t, "A1B181AAFE88188680AE32860D60BB01"),
G: 3,
P: getHex(t, "C71CAEB9C6B1C9048E6C522F70F13F73980D40238E3E21C14934D037563D930F"+
"48198A0AA7C14058229493D22530F4DBFA336F6E0AC925139543AED44CCE7C37"+
"20FD51F69458705AC68CD4FE6B6B13ABDC9746512969328454F18FAF8C595F64"+
"2477FE96BB2A941D5BCD1D4AC8CC49880708FA9B378E3C4F3A9060BEE67CF9A4"+
"A4A695811051907E162753B56B0F6B410DBA74D8A84B2A14B3144E0EF1284754"+
"FD17ED950D5965B4B9DD46582DB1178D169C6BC465B0D6FF9CA3928FEF5B9AE4"+
"E418FC15E83EBEA0F87FA9FF5EED70050DED2849F47BF959D956850CE929851F"+
"0D8115F635B105EE2E4E15D04B2454BF6F4FADF034B10403119CD8E3B92FCC5B"),
}
pwd := &tg.AccountPassword{
NewAlgo: algo,
NewSecureAlgo: &tg.SecurePasswordKdfAlgoPBKDF2HMACSHA512iter100000{},
}
pwd.SetCurrentAlgo(algo)
return pwd, nil
case *tg.AuthCheckPasswordRequest:
// TODO(ernado): Check actual secure remote password here.
switch pwd := req.Password.(type) {
case *tg.InputCheckPasswordSRP:
assert.NotEmpty(t, pwd.A)
assert.NotEmpty(t, pwd.M1)
assert.NotEqual(t, pwd.SRPID, 0)
default:
t.Errorf("unexpectd pwd type %T", pwd)
}
return &tg.AuthAuthorization{
User: testUser,
}, nil
}
return nil, errors.New("unexpected")
})
t.Run("Manual", func(t *testing.T) {
// 1. Request code from server to device.
client := testClient(invoker)
sentCode, err := client.SendCode(ctx, phone, SendCodeOptions{CurrentNumber: true})
require.NoError(t, err)
h := sentCode.(*tg.AuthSentCode).PhoneCodeHash
require.Equal(t, codeHash, h)
// 2. Send code from device to server.
// Server is responding with 2FA password prompt.
_, signInErr := client.SignIn(ctx, phone, code, h)
require.ErrorIs(t, signInErr, ErrPasswordAuthNeeded)
// 3. Provide 2FA password.
result, err := client.Password(ctx, password)
require.NoError(t, err)
require.Equal(t, testUser, result.User)
})
flow := NewFlow(
Constant(phone, password, CodeAuthenticatorFunc(
func(ctx context.Context, _ *tg.AuthSentCode) (string, error) {
return code, nil
},
)),
SendCodeOptions{CurrentNumber: true},
)
t.Run("AuthFlow", func(t *testing.T) {
require.NoError(t, flow.Run(ctx, testClient(invoker)))
})
t.Run("IfNecessary", func(t *testing.T) {
require.NoError(t, testClient(invoker).IfNecessary(ctx, flow))
})
}
func TestClientTestAuth(t *testing.T) {
const (
codeHash = "hash"
dcID = 2
)
ctx := context.Background()
invoker := tgmock.Invoker(func(body bin.Encoder) (bin.Encoder, error) {
switch req := body.(type) {
case *tg.AuthSendCodeRequest:
assert.Equal(t, &tg.AuthSendCodeRequest{
PhoneNumber: req.PhoneNumber,
APIHash: testAppHash,
APIID: testAppID,
Settings: tg.CodeSettings{},
}, req)
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{
Length: 6,
},
PhoneCodeHash: codeHash,
}, nil
case *tg.AuthSignInRequest:
if !strings.HasPrefix(req.PhoneNumber, "99966") {
t.Fatalf("unexpected phone number %s", req.PhoneNumber)
}
dcPart := req.PhoneNumber[5:6]
assert.Equal(t, strconv.Itoa(dcID), dcPart, "dc part of phone number")
assert.Equal(t, &tg.AuthSignInRequest{
PhoneNumber: req.PhoneNumber,
PhoneCodeHash: codeHash,
PhoneCode: strings.Repeat(dcPart, 6),
}, req)
return &tg.AuthAuthorization{
User: &tg.User{ID: 1},
}, nil
}
return nil, errors.New("unexpected")
})
require.NoError(t, NewFlow(
Test(rand.New(rand.NewSource(1)), dcID),
SendCodeOptions{},
).Run(ctx, testClient(invoker)))
}
func TestClientTestSignUp(t *testing.T) {
const (
dcID = 2
codeHash = "hash"
tosID = "foo"
)
ctx := context.Background()
invoker := tgmock.Invoker(func(body bin.Encoder) (bin.Encoder, error) {
switch req := body.(type) {
case *tg.AuthSendCodeRequest:
assert.Equal(t, &tg.AuthSendCodeRequest{
PhoneNumber: req.PhoneNumber,
APIHash: testAppHash,
APIID: testAppID,
Settings: tg.CodeSettings{},
}, req)
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{
Length: 6,
},
PhoneCodeHash: codeHash,
}, nil
case *tg.AuthSignUpRequest:
assert.Equal(t, &tg.AuthSignUpRequest{
PhoneNumber: req.PhoneNumber,
PhoneCodeHash: codeHash,
FirstName: "Test",
LastName: "User",
}, req)
return &tg.AuthAuthorization{
User: &tg.User{ID: 1},
}, nil
case *tg.HelpAcceptTermsOfServiceRequest:
return &tg.BoolTrue{}, nil
case *tg.AuthSignInRequest:
if !strings.HasPrefix(req.PhoneNumber, "99966") {
t.Fatalf("unexpected phone number %s", req.PhoneNumber)
}
dcPart := req.PhoneNumber[5:6]
assert.Equal(t, strconv.Itoa(dcID), dcPart, "dc part of phone number")
assert.Equal(t, &tg.AuthSignInRequest{
PhoneNumber: req.PhoneNumber,
PhoneCodeHash: codeHash,
PhoneCode: strings.Repeat(dcPart, 6),
}, req)
res := &tg.AuthAuthorizationSignUpRequired{}
res.SetTermsOfService(tg.HelpTermsOfService{ID: tg.DataJSON{Data: tosID}})
return res, nil
}
return nil, errors.New("unexpected")
})
require.NoError(t, NewFlow(
Test(rand.New(rand.NewSource(1)), dcID),
SendCodeOptions{},
).Run(ctx, testClient(invoker)))
}
func TestClient_AcceptTOS(t *testing.T) {
ctx := context.Background()
mockTest(func(a *require.Assertions, mock *tgmock.Mock, client *Client) {
mock.Expect().ThenUnregistered()
a.Error(client.AcceptTOS(ctx, tg.DataJSON{
Data: `{"data":"data"}`,
}))
mock.Expect().ThenTrue()
a.NoError(client.AcceptTOS(ctx, tg.DataJSON{
Data: `{"data":"data"}`,
}))
})(t)
}