From 6dc42942c895dad8aa698a14a2b9cd127b893e71 Mon Sep 17 00:00:00 2001 From: A Date: Mon, 8 Jun 2026 21:42:32 +0800 Subject: [PATCH] auth: complete account authorization flows (cherry picked from commit 04f4527df32ad5c35720cccc41d27fe51549612f) --- cmd/telesrv/main.go | 2 +- .../migrations/0070_account_auth_srp.down.sql | 14 + .../migrations/0070_account_auth_srp.up.sql | 21 ++ internal/app/account/service.go | 339 +++++++++++++++++- internal/app/account/service_test.go | 185 ++++++++++ internal/app/account/srp.go | 204 +++++++++++ internal/app/auth/service.go | 74 ++++ internal/app/auth/service_test.go | 39 ++ internal/compat/tdesktop/startup_stubs.go | 20 ++ internal/domain/account.go | 81 ++++- internal/domain/authorization.go | 5 + internal/rpc/account.go | 248 ++++++++++++- internal/rpc/auth.go | 141 +++++++- internal/rpc/convert.go | 84 ++++- internal/rpc/deps.go | 16 + internal/rpc/errors.go | 39 ++ internal/rpc/router_test.go | 50 +++ internal/store/authorization.go | 2 + internal/store/memory/memory.go | 63 +++- internal/store/postgres/account.go | 105 ++++-- internal/store/postgres/authorization.go | 98 ++++- 21 files changed, 1780 insertions(+), 50 deletions(-) create mode 100644 deploy/migrations/0070_account_auth_srp.down.sql create mode 100644 deploy/migrations/0070_account_auth_srp.up.sql create mode 100644 internal/app/account/service_test.go create mode 100644 internal/app/account/srp.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index f49216d4..d1a0f16b 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -185,7 +185,7 @@ func run(logger *zap.Logger) error { Port: port, OutboundPushTimeout: cfg.OutboundPushTimeout, }, rpc.Deps{ - Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)), + Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore)), Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)), Privacy: privacyService, Help: help.NewService(helpStore, helpStore), diff --git a/deploy/migrations/0070_account_auth_srp.down.sql b/deploy/migrations/0070_account_auth_srp.down.sql new file mode 100644 index 00000000..2ec5f984 --- /dev/null +++ b/deploy/migrations/0070_account_auth_srp.down.sql @@ -0,0 +1,14 @@ +DROP INDEX IF EXISTS authorizations_user_hash_idx; + +ALTER TABLE account_passwords + DROP COLUMN IF EXISTS recovery_code_expires_at, + DROP COLUMN IF EXISTS recovery_code, + DROP COLUMN IF EXISTS recovery_email, + DROP COLUMN IF EXISTS srp_b, + DROP COLUMN IF EXISTS srp_b_secret, + DROP COLUMN IF EXISTS srp_verifier, + DROP COLUMN IF EXISTS srp_id, + DROP COLUMN IF EXISTS current_algo_p, + DROP COLUMN IF EXISTS current_algo_g, + DROP COLUMN IF EXISTS current_algo_salt2, + DROP COLUMN IF EXISTS current_algo_salt1; diff --git a/deploy/migrations/0070_account_auth_srp.up.sql b/deploy/migrations/0070_account_auth_srp.up.sql new file mode 100644 index 00000000..25bc7aac --- /dev/null +++ b/deploy/migrations/0070_account_auth_srp.up.sql @@ -0,0 +1,21 @@ +-- 0070_account_auth_srp: durable SRP 2FA and authorization management fields. + +ALTER TABLE account_passwords + ADD COLUMN IF NOT EXISTS current_algo_salt1 BYTEA NOT NULL DEFAULT ''::bytea, + ADD COLUMN IF NOT EXISTS current_algo_salt2 BYTEA NOT NULL DEFAULT ''::bytea, + ADD COLUMN IF NOT EXISTS current_algo_g INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS current_algo_p BYTEA NOT NULL DEFAULT ''::bytea, + ADD COLUMN IF NOT EXISTS srp_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS srp_verifier BYTEA NOT NULL DEFAULT ''::bytea, + ADD COLUMN IF NOT EXISTS srp_b_secret BYTEA NOT NULL DEFAULT ''::bytea, + ADD COLUMN IF NOT EXISTS srp_b BYTEA NOT NULL DEFAULT ''::bytea, + ADD COLUMN IF NOT EXISTS recovery_email VARCHAR(256) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS recovery_code VARCHAR(32) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS recovery_code_expires_at TIMESTAMPTZ; + +UPDATE authorizations +SET hash = auth_key_id +WHERE hash = 0; + +CREATE UNIQUE INDEX IF NOT EXISTS authorizations_user_hash_idx + ON authorizations (user_id, hash); diff --git a/internal/app/account/service.go b/internal/app/account/service.go index e6940443..556aee14 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -2,6 +2,11 @@ package account import ( "context" + "crypto/rand" + "crypto/subtle" + "fmt" + "strings" + "time" "telesrv/internal/domain" "telesrv/internal/store" @@ -9,6 +14,23 @@ import ( var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") +const ( + passwordResetWait = 7 * 24 * time.Hour + passwordResetRetry = 24 * time.Hour +) + +// EmailUnconfirmedError reports the dev recovery-code length expected by TDesktop. +type EmailUnconfirmedError struct { + Length int +} + +func (e EmailUnconfirmedError) Error() string { + if e.Length <= 0 { + return "email unconfirmed" + } + return fmt.Sprintf("email unconfirmed: %d", e.Length) +} + // Service 提供账号安全配置查询。 type Service struct { passwords store.PasswordStore @@ -46,14 +68,325 @@ func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.Passwor if !found { return defaultPasswordSettings(), nil } - if len(settings.SecureRandom) == 0 { - settings.SecureRandom = append([]byte(nil), defaultSecureRandom...) + settings = normalizePasswordSettings(settings) + if settings.HasPassword { + secret, b, err := makeSRPChallenge(settings.SRPVerifier) + if err != nil { + return domain.PasswordSettings{}, err + } + settings.SRPBSecret = secret + settings.SRPB = b + if settings.SRPID == 0 { + settings.SRPID, err = randomInt64() + if err != nil { + return domain.PasswordSettings{}, err + } + } + if err := s.passwords.Save(ctx, userID, settings); err != nil { + return domain.PasswordSettings{}, err + } } return settings, nil } func defaultPasswordSettings() domain.PasswordSettings { - return domain.PasswordSettings{SecureRandom: append([]byte(nil), defaultSecureRandom...)} + return normalizePasswordSettings(domain.PasswordSettings{SecureRandom: append([]byte(nil), defaultSecureRandom...)}) +} + +func normalizePasswordSettings(settings domain.PasswordSettings) domain.PasswordSettings { + if len(settings.SecureRandom) == 0 { + settings.SecureRandom = append([]byte(nil), defaultSecureRandom...) + } + if len(settings.NewAlgo.P) == 0 { + settings.NewAlgo = defaultPasswordAlgo() + } + if settings.NewSecureAlgo.Kind == "" { + settings.NewSecureAlgo = defaultSecureAlgo() + } + if settings.HasPassword && settings.CurrentAlgo == nil { + algo := settings.NewAlgo + settings.CurrentAlgo = &algo + } + if settings.RecoveryEmail != "" { + settings.HasRecovery = true + } + return settings +} + +// CheckPassword validates the current account password check. +func (s *Service) CheckPassword(ctx context.Context, userID int64, check domain.PasswordCheck) error { + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + return checkSRP(settings, check) +} + +// GetPasswordWithoutRefresh returns persisted settings without rotating the SRP challenge. +func (s *Service) GetPasswordWithoutRefresh(ctx context.Context, userID int64) (domain.PasswordSettings, error) { + if s == nil || s.passwords == nil || userID == 0 { + return defaultPasswordSettings(), nil + } + settings, found, err := s.passwords.GetByUser(ctx, userID) + if err != nil { + return domain.PasswordSettings{}, err + } + if !found { + return defaultPasswordSettings(), nil + } + return normalizePasswordSettings(settings), nil +} + +// GetPasswordSettings validates the password and returns private 2FA settings. +func (s *Service) GetPasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck) (domain.PrivatePasswordSettings, error) { + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return domain.PrivatePasswordSettings{}, err + } + if err := checkSRP(settings, check); err != nil { + return domain.PrivatePasswordSettings{}, err + } + return domain.PrivatePasswordSettings{Email: settings.RecoveryEmail}, nil +} + +// UpdatePasswordSettings sets, changes, clears, or updates the recovery email for 2FA. +func (s *Service) UpdatePasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck, input domain.PasswordInputSettings) error { + if s == nil || s.passwords == nil || userID == 0 { + return nil + } + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + if err := checkSRP(settings, check); err != nil { + return err + } + if len(input.NewPasswordHash) == 0 && !input.HasEmail { + settings = defaultPasswordSettings() + settings.SecureRandom = randomBytesOrDefault(passwordHashSize, settings.SecureRandom) + return s.passwords.Save(ctx, userID, settings) + } + if len(input.NewPasswordHash) > 0 { + if err := validateNewPasswordSettings(input); err != nil { + return err + } + srpID, err := randomInt64() + if err != nil { + return err + } + algo := *input.NewAlgo + settings.CurrentAlgo = &algo + settings.NewAlgo = defaultPasswordAlgo() + settings.SRPVerifier = padToHash(input.NewPasswordHash) + secret, b, err := makeSRPChallenge(settings.SRPVerifier) + if err != nil { + return err + } + settings.SRPBSecret = secret + settings.SRPB = b + settings.SRPID = srpID + settings.HasPassword = true + if input.HasHint { + settings.Hint = input.Hint + } + } + if input.HasEmail { + email := strings.TrimSpace(input.Email) + if email != "" && !strings.Contains(email, "@") { + return domain.ErrEmailInvalid + } + settings.RecoveryEmail = email + settings.HasRecovery = email != "" + settings.LoginEmailPattern = emailPattern(email) + settings.EmailUnconfirmedPattern = "" + } + settings.SecureRandom = randomBytesOrDefault(passwordHashSize, settings.SecureRandom) + return s.passwords.Save(ctx, userID, normalizePasswordSettings(settings)) +} + +func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (string, error) { + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return "", err + } + if !settings.HasPassword || settings.RecoveryEmail == "" { + return "", domain.ErrPasswordRecoveryNA + } + settings.RecoveryCode = recoveryCode + settings.RecoveryCodeExpiresAt = time.Now().Unix() + recoveryCodeTTL + if s.passwords != nil { + if err := s.passwords.Save(ctx, userID, settings); err != nil { + return "", err + } + } + return emailPattern(settings.RecoveryEmail), nil +} + +func (s *Service) CheckRecoveryPassword(ctx context.Context, userID int64, code string) error { + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + return checkRecoveryCode(settings, code) +} + +func (s *Service) RecoverPassword(ctx context.Context, userID int64, code string, input *domain.PasswordInputSettings) error { + if s == nil || s.passwords == nil || userID == 0 { + return nil + } + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + if err := checkRecoveryCode(settings, code); err != nil { + return err + } + if input == nil || len(input.NewPasswordHash) == 0 { + settings = defaultPasswordSettings() + return s.passwords.Save(ctx, userID, settings) + } + if err := validateNewPasswordSettings(*input); err != nil { + return err + } + settings.CurrentAlgo = input.NewAlgo + settings.SRPVerifier = padToHash(input.NewPasswordHash) + settings.SRPID, err = randomInt64() + if err != nil { + return err + } + settings.SRPBSecret, settings.SRPB, err = makeSRPChallenge(settings.SRPVerifier) + if err != nil { + return err + } + settings.HasPassword = true + if input.HasHint { + settings.Hint = input.Hint + } + settings.RecoveryCode = "" + settings.RecoveryCodeExpiresAt = 0 + return s.passwords.Save(ctx, userID, normalizePasswordSettings(settings)) +} + +func (s *Service) ResetPassword(ctx context.Context, userID int64) (domain.PasswordResetResult, error) { + if s == nil || s.passwords == nil || userID == 0 { + return domain.PasswordResetResult{Kind: domain.PasswordResetFailedWait, RetryDate: int(time.Now().Add(passwordResetRetry).Unix())}, nil + } + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return domain.PasswordResetResult{}, err + } + if !settings.HasPassword { + return domain.PasswordResetResult{Kind: domain.PasswordResetOK}, nil + } + if settings.HasRecovery { + return domain.PasswordResetResult{}, domain.ErrPasswordRecoveryNA + } + now := time.Now() + if settings.PendingResetDate > 0 { + if now.Unix() >= int64(settings.PendingResetDate) { + next := defaultPasswordSettings() + next.SecureRandom = randomBytesOrDefault(passwordHashSize, settings.SecureRandom) + if err := s.passwords.Save(ctx, userID, next); err != nil { + return domain.PasswordResetResult{}, err + } + return domain.PasswordResetResult{Kind: domain.PasswordResetOK}, nil + } + return domain.PasswordResetResult{Kind: domain.PasswordResetRequestedWait, UntilDate: settings.PendingResetDate}, nil + } + settings.PendingResetDate = int(now.Add(passwordResetWait).Unix()) + if err := s.passwords.Save(ctx, userID, normalizePasswordSettings(settings)); err != nil { + return domain.PasswordResetResult{}, err + } + return domain.PasswordResetResult{Kind: domain.PasswordResetRequestedWait, UntilDate: settings.PendingResetDate}, nil +} + +func (s *Service) DeclinePasswordReset(ctx context.Context, userID int64) error { + if s == nil || s.passwords == nil || userID == 0 { + return nil + } + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + settings.PendingResetDate = 0 + return s.passwords.Save(ctx, userID, normalizePasswordSettings(settings)) +} + +func (s *Service) ConfirmPasswordEmail(ctx context.Context, userID int64, code string) error { + return s.CheckRecoveryPassword(ctx, userID, code) +} + +func (s *Service) ResendPasswordEmail(ctx context.Context, userID int64) error { + _, err := s.RequestPasswordRecovery(ctx, userID) + return err +} + +func (s *Service) CancelPasswordEmail(ctx context.Context, userID int64) error { + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + settings.EmailUnconfirmedPattern = "" + settings.RecoveryCode = "" + settings.RecoveryCodeExpiresAt = 0 + if s.passwords != nil { + return s.passwords.Save(ctx, userID, settings) + } + return nil +} + +func checkRecoveryCode(settings domain.PasswordSettings, code string) error { + if settings.RecoveryCode == "" { + if code == recoveryCode { + return nil + } + return domain.ErrPasswordRecoveryNA + } + if settings.RecoveryCodeExpiresAt > 0 && time.Now().Unix() > settings.RecoveryCodeExpiresAt { + return domain.ErrEmailCodeInvalid + } + if subtle.ConstantTimeCompare([]byte(settings.RecoveryCode), []byte(code)) != 1 { + return domain.ErrEmailCodeInvalid + } + return nil +} + +func randomBytesOrDefault(n int, fallback []byte) []byte { + out := make([]byte, n) + if _, err := rand.Read(out); err != nil { + return append([]byte(nil), fallback...) + } + return out +} + +func randomInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + out := int64(0) + for _, v := range b { + out = (out << 8) | int64(v) + } + if out == 0 { + out = 1 + } + if out < 0 { + out = -out + } + return out, nil +} + +func emailPattern(email string) string { + if email == "" { + return "" + } + at := strings.Index(email, "@") + if at <= 1 { + return email + } + name := email[:at] + return name[:1] + "***" + name[len(name)-1:] + email[at:] } // GetReactionSettings returns account-level reaction preferences. diff --git a/internal/app/account/service_test.go b/internal/app/account/service_test.go new file mode 100644 index 00000000..5fe9de12 --- /dev/null +++ b/internal/app/account/service_test.go @@ -0,0 +1,185 @@ +package account + +import ( + "bytes" + "context" + "errors" + "math/big" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestPasswordSRPRoundTrip(t *testing.T) { + ctx := context.Background() + const userID int64 = 1001 + svc := NewService(memory.NewPasswordStore()) + + initial, err := svc.GetPassword(ctx, userID) + if err != nil { + t.Fatalf("GetPassword initial: %v", err) + } + algo := initial.NewAlgo + algo.Salt1 = append(append([]byte(nil), algo.Salt1...), bytes.Repeat([]byte{0xA5}, 32)...) + input := domain.PasswordInputSettings{ + NewAlgo: &algo, + NewPasswordHash: verifierForPassword(algo, []byte("correct horse")), + Hint: "horse", + HasHint: true, + Email: "alice@example.com", + HasEmail: true, + } + if err := svc.UpdatePasswordSettings(ctx, userID, domain.PasswordCheck{Empty: true}, input); err != nil { + t.Fatalf("UpdatePasswordSettings set password: %v", err) + } + + challenge, err := svc.GetPassword(ctx, userID) + if err != nil { + t.Fatalf("GetPassword challenge: %v", err) + } + if !challenge.HasPassword || challenge.SRPID == 0 || len(challenge.SRPB) == 0 { + t.Fatalf("challenge = %+v, want srp password challenge", challenge) + } + check := clientPasswordCheck(t, challenge, []byte("correct horse")) + if err := svc.CheckPassword(ctx, userID, check); err != nil { + t.Fatalf("CheckPassword valid SRP: %v", err) + } + + private, err := svc.GetPasswordSettings(ctx, userID, check) + if err != nil { + t.Fatalf("GetPasswordSettings valid SRP: %v", err) + } + if private.Email != "alice@example.com" { + t.Fatalf("private email = %q, want alice@example.com", private.Email) + } + + bad := check + bad.M1 = append([]byte(nil), check.M1...) + bad.M1[0] ^= 0xFF + if err := svc.CheckPassword(ctx, userID, bad); !errors.Is(err, domain.ErrPasswordHashInvalid) { + t.Fatalf("CheckPassword bad M1 err = %v, want ErrPasswordHashInvalid", err) + } +} + +func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) { + ctx := context.Background() + const userID int64 = 1002 + svc := NewService(memory.NewPasswordStore()) + + initial, err := svc.GetPassword(ctx, userID) + if err != nil { + t.Fatalf("GetPassword initial: %v", err) + } + algo := initial.NewAlgo + algo.Salt1 = append(append([]byte(nil), algo.Salt1...), bytes.Repeat([]byte{0x5C}, 32)...) + if err := svc.UpdatePasswordSettings(ctx, userID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{ + NewAlgo: &algo, + NewPasswordHash: verifierForPassword(algo, []byte("old password")), + Email: "bob@example.com", + HasEmail: true, + }); err != nil { + t.Fatalf("UpdatePasswordSettings set password: %v", err) + } + + pattern, err := svc.RequestPasswordRecovery(ctx, userID) + if err != nil { + t.Fatalf("RequestPasswordRecovery: %v", err) + } + if pattern != "b***b@example.com" { + t.Fatalf("recovery pattern = %q, want masked email", pattern) + } + if err := svc.RecoverPassword(ctx, userID, recoveryCode, nil); err != nil { + t.Fatalf("RecoverPassword clear: %v", err) + } + cleared, err := svc.GetPassword(ctx, userID) + if err != nil { + t.Fatalf("GetPassword cleared: %v", err) + } + if cleared.HasPassword || cleared.HasRecovery { + t.Fatalf("cleared settings = %+v, want no password/recovery", cleared) + } +} + +func TestResetPasswordWaitAndDecline(t *testing.T) { + ctx := context.Background() + const userID int64 = 1003 + passwords := memory.NewPasswordStore() + svc := NewService(passwords) + + if err := passwords.Save(ctx, userID, domain.PasswordSettings{HasPassword: true}); err != nil { + t.Fatalf("save password settings: %v", err) + } + result, err := svc.ResetPassword(ctx, userID) + if err != nil { + t.Fatalf("ResetPassword request: %v", err) + } + if result.Kind != domain.PasswordResetRequestedWait || result.UntilDate <= int(time.Now().Unix()) { + t.Fatalf("reset result = %+v, want requested future wait", result) + } + pending, _, err := passwords.GetByUser(ctx, userID) + if err != nil || pending.PendingResetDate != result.UntilDate { + t.Fatalf("pending reset = %+v found err=%v, want until date", pending, err) + } + + if err := svc.DeclinePasswordReset(ctx, userID); err != nil { + t.Fatalf("DeclinePasswordReset: %v", err) + } + declined, _, err := passwords.GetByUser(ctx, userID) + if err != nil || declined.PendingResetDate != 0 { + t.Fatalf("declined settings = %+v err=%v, want no pending reset", declined, err) + } + + declined.PendingResetDate = int(time.Now().Add(-time.Second).Unix()) + if err := passwords.Save(ctx, userID, declined); err != nil { + t.Fatalf("save expired reset: %v", err) + } + result, err = svc.ResetPassword(ctx, userID) + if err != nil { + t.Fatalf("ResetPassword finalize: %v", err) + } + if result.Kind != domain.PasswordResetOK { + t.Fatalf("final reset result = %+v, want ok", result) + } + cleared, _, err := passwords.GetByUser(ctx, userID) + if err != nil || cleared.HasPassword || cleared.PendingResetDate != 0 { + t.Fatalf("cleared settings = %+v err=%v, want password cleared", cleared, err) + } +} + +func clientPasswordCheck(t *testing.T, settings domain.PasswordSettings, password []byte) domain.PasswordCheck { + t.Helper() + algo := settings.NewAlgo + if settings.CurrentAlgo != nil { + algo = *settings.CurrentAlgo + } + p := new(big.Int).SetBytes(algo.P) + g := big.NewInt(int64(algo.G)) + a := new(big.Int).SetBytes(bytes.Repeat([]byte{0x23}, passwordHashSize)) + A := new(big.Int).Exp(g, a, p) + aForHash := padToHash(A.Bytes()) + bForHash := padToHash(settings.SRPB) + x := new(big.Int).SetBytes(passwordDigest(algo, password)) + u := new(big.Int).SetBytes(hashBytes(aForHash, bForHash)) + k := new(big.Int).SetBytes(hashBytes(padToHash(algo.P), padToHash(g.Bytes()))) + gx := new(big.Int).Exp(g, x, p) + kgx := new(big.Int).Mul(k, gx) + kgx.Mod(kgx, p) + b := new(big.Int).SetBytes(settings.SRPB) + base := new(big.Int).Sub(b, kgx) + base.Mod(base, p) + exp := new(big.Int).Mul(u, x) + exp.Add(exp, a) + s := new(big.Int).Exp(base, exp, p) + kBytes := hashBytes(padToHash(s.Bytes())) + m1 := hashBytes( + xorBytes(hashBytes(padToHash(algo.P)), hashBytes(padToHash(g.Bytes()))), + hashBytes(algo.Salt1), + hashBytes(algo.Salt2), + aForHash, + bForHash, + kBytes, + ) + return domain.PasswordCheck{SRPID: settings.SRPID, A: aForHash, M1: m1} +} diff --git a/internal/app/account/srp.go b/internal/app/account/srp.go new file mode 100644 index 00000000..7999ebad --- /dev/null +++ b/internal/app/account/srp.go @@ -0,0 +1,204 @@ +package account + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "math/big" + + "golang.org/x/crypto/pbkdf2" + + "telesrv/internal/domain" +) + +const ( + passwordHashSize = 256 + recoveryCode = "12345" + recoveryCodeTTL = 15 * 60 +) + +var ( + baseSalt1 = []byte{0xEC, 0xF8, 0x73, 0x76, 0x65, 0xBC, 0x77, 0x5A} + baseSalt2 = []byte{0xBE, 0xDE, 0x48, 0x88, 0x8C, 0x0F, 0x42, 0xAC, 0x34, 0xFF, 0xD1, 0xD4, 0x93, 0x5D, 0x8B, 0x21} + baseP = mustDecodeHex("c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b") + baseG = 3 +) + +func defaultPasswordAlgo() domain.PasswordKDFAlgo { + return domain.PasswordKDFAlgo{ + Salt1: append([]byte(nil), baseSalt1...), + Salt2: append([]byte(nil), baseSalt2...), + G: baseG, + P: append([]byte(nil), baseP...), + } +} + +func defaultSecureAlgo() domain.SecurePasswordKDFAlgo { + return domain.SecurePasswordKDFAlgo{ + Kind: "pbkdf2_hmac_sha512_iter100000", + Salt: []byte{0x7D, 0x04, 0xB3, 0x4B, 0x94, 0x82, 0x8C, 0x3D}, + } +} + +func makeSRPChallenge(verifier []byte) (secret, b []byte, err error) { + secret = make([]byte, passwordHashSize) + if _, err := rand.Read(secret); err != nil { + return nil, nil, err + } + b, err = calcSRPB(secret, verifier) + if err != nil { + return nil, nil, err + } + return secret, b, nil +} + +func calcSRPB(secret, verifier []byte) ([]byte, error) { + p := new(big.Int).SetBytes(baseP) + g := big.NewInt(int64(baseG)) + v := new(big.Int).SetBytes(verifier) + b := new(big.Int).SetBytes(secret) + if v.Sign() <= 0 || v.Cmp(p) >= 0 { + return nil, domain.ErrPasswordHashInvalid + } + k := new(big.Int).SetBytes(hashBytes(padToHash(baseP), padToHash(g.Bytes()))) + kv := new(big.Int).Mul(k, v) + kv.Mod(kv, p) + gb := new(big.Int).Exp(g, b, p) + out := new(big.Int).Add(kv, gb) + out.Mod(out, p) + return padToHash(out.Bytes()), nil +} + +func checkSRP(settings domain.PasswordSettings, check domain.PasswordCheck) error { + if check.Empty { + if settings.HasPassword { + return domain.ErrPasswordHashInvalid + } + return nil + } + if !settings.HasPassword { + return domain.ErrPasswordHashInvalid + } + if settings.SRPID == 0 || settings.SRPID != check.SRPID { + return domain.ErrSRPIDInvalid + } + if len(settings.SRPVerifier) == 0 || len(settings.SRPBSecret) == 0 || len(settings.SRPB) == 0 { + return domain.ErrSRPPasswordChanged + } + got, err := calcSRPM1(settings, check.A) + if err != nil { + return err + } + if !bytes.Equal(got, check.M1) { + return domain.ErrPasswordHashInvalid + } + return nil +} + +func calcSRPM1(settings domain.PasswordSettings, aBytes []byte) ([]byte, error) { + p := new(big.Int).SetBytes(baseP) + g := big.NewInt(int64(baseG)) + a := new(big.Int).SetBytes(aBytes) + if !isGoodLarge(a, p) { + return nil, domain.ErrPasswordHashInvalid + } + v := new(big.Int).SetBytes(settings.SRPVerifier) + if !isGoodLarge(v, p) { + return nil, domain.ErrPasswordHashInvalid + } + b := new(big.Int).SetBytes(settings.SRPBSecret) + bForHash := padToHash(settings.SRPB) + aForHash := padToHash(aBytes) + u := new(big.Int).SetBytes(hashBytes(aForHash, bForHash)) + if u.Sign() <= 0 { + return nil, domain.ErrPasswordHashInvalid + } + vu := new(big.Int).Exp(v, u, p) + sBase := new(big.Int).Mul(a, vu) + sBase.Mod(sBase, p) + s := new(big.Int).Exp(sBase, b, p) + k := hashBytes(padToHash(s.Bytes())) + salt1 := settings.NewAlgo.Salt1 + if settings.CurrentAlgo != nil { + salt1 = settings.CurrentAlgo.Salt1 + } + return hashBytes( + xorBytes(hashBytes(padToHash(baseP)), hashBytes(padToHash(g.Bytes()))), + hashBytes(salt1), + hashBytes(baseSalt2), + aForHash, + bForHash, + k, + ), nil +} + +func validateNewPasswordSettings(in domain.PasswordInputSettings) error { + if in.NewAlgo == nil || len(in.NewPasswordHash) == 0 { + return domain.ErrNewSettingsInvalid + } + algo := in.NewAlgo + if algo.G != baseG || !bytes.Equal(algo.P, baseP) || !bytes.Equal(algo.Salt2, baseSalt2) { + return domain.ErrNewSaltInvalid + } + if len(algo.Salt1) != len(baseSalt1)+32 || !bytes.Equal(algo.Salt1[:len(baseSalt1)], baseSalt1) { + return domain.ErrNewSaltInvalid + } + v := new(big.Int).SetBytes(in.NewPasswordHash) + if !isGoodLarge(v, new(big.Int).SetBytes(baseP)) { + return domain.ErrPasswordHashInvalid + } + return nil +} + +func hashBytes(parts ...[]byte) []byte { + h := sha256.New() + for _, part := range parts { + _, _ = h.Write(part) + } + return h.Sum(nil) +} + +func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte { + hash1 := hashBytes(algo.Salt1, password, algo.Salt1) + hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2) + hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New) + return hashBytes(algo.Salt2, hash3, algo.Salt2) +} + +func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte { + p := new(big.Int).SetBytes(algo.P) + g := big.NewInt(int64(algo.G)) + x := new(big.Int).SetBytes(passwordDigest(algo, password)) + return padToHash(new(big.Int).Exp(g, x, p).Bytes()) +} + +func padToHash(in []byte) []byte { + if len(in) >= passwordHashSize { + return append([]byte(nil), in[len(in)-passwordHashSize:]...) + } + out := make([]byte, passwordHashSize) + copy(out[passwordHashSize-len(in):], in) + return out +} + +func xorBytes(a, b []byte) []byte { + out := make([]byte, len(a)) + for i := range a { + out[i] = a[i] ^ b[i] + } + return out +} + +func isGoodLarge(n, p *big.Int) bool { + return n.Sign() > 0 && new(big.Int).Sub(p, n).Sign() > 0 +} + +func mustDecodeHex(s string) []byte { + out, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return out +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index e0fa8e56..cf321a77 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -34,6 +34,7 @@ type Service struct { codes store.CodeStore authKeys store.AuthKeyStore tempKeys store.TempAuthKeyBindingStore + passwords store.PasswordStore messages store.MessageStore dialogs store.DialogStore fixedCode string @@ -51,6 +52,13 @@ func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) O } } +// 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} @@ -114,6 +122,39 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) { 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) { @@ -139,6 +180,10 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, 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 @@ -196,11 +241,40 @@ 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. diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go index a73a3ce5..b84c1f28 100644 --- a/internal/app/auth/service_test.go +++ b/internal/app/auth/service_test.go @@ -241,6 +241,45 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) { } } +func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) { + ctx := context.Background() + passwords := memory.NewPasswordStore() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPasswords(passwords)) + var key [8]byte + key[0] = 7 + + hash, err := svc.SendCode(ctx, "+15550004312") + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550004312", hash, "Two", "Factor") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil { + t.Fatalf("save password settings: %v", err) + } + + hash, err = svc.SendCode(ctx, "+15550004312") + if err != nil { + t.Fatalf("SendCode signin: %v", err) + } + got, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550004312", hash, "12345") + if !errors.Is(err, domain.ErrSessionPasswordNeeded) { + t.Fatalf("SignIn err = %v, want ErrSessionPasswordNeeded", err) + } + if needSignUp || got.ID != u.ID { + t.Fatalf("SignIn user=%+v needSignUp=%v, want existing 2FA user", got, needSignUp) + } + bound, found, err := svc.UserID(ctx, key) + if err != nil || !found || bound != u.ID { + t.Fatalf("UserID after password-needed = %d found=%v err=%v, want %d", bound, found, err, u.ID) + } +} + func testAuthKey(seed byte) mtcrypto.AuthKey { var raw mtcrypto.Key for i := range raw { diff --git a/internal/compat/tdesktop/startup_stubs.go b/internal/compat/tdesktop/startup_stubs.go index 338dd1e9..c0269ae3 100644 --- a/internal/compat/tdesktop/startup_stubs.go +++ b/internal/compat/tdesktop/startup_stubs.go @@ -53,6 +53,10 @@ func Authorizations() *tg.AccountAuthorizations { return &tg.AccountAuthorizations{Authorizations: []tg.Authorization{}} } +func WebAuthorizations() *tg.AccountWebAuthorizations { + return &tg.AccountWebAuthorizations{Authorizations: []tg.WebAuthorization{}, Users: []tg.UserClass{}} +} + func Passkeys() *tg.AccountPasskeys { return &tg.AccountPasskeys{Passkeys: []tg.Passkey{}} } @@ -69,6 +73,22 @@ func AccountThemes() tg.AccountThemesClass { return &tg.AccountThemesNotModified{} } +func AutoDownloadSettings() *tg.AccountAutoDownloadSettings { + settings := tg.AutoDownloadSettings{ + PhotoSizeMax: 10 * 1024 * 1024, + VideoSizeMax: 20 * 1024 * 1024, + FileSizeMax: 20 * 1024 * 1024, + VideoUploadMaxbitrate: 1000, + SmallQueueActiveOperationsMax: 2, + LargeQueueActiveOperationsMax: 1, + } + return &tg.AccountAutoDownloadSettings{ + Low: settings, + Medium: settings, + High: settings, + } +} + func DefaultEmojiStatuses() tg.AccountEmojiStatusesClass { return &tg.AccountEmojiStatusesNotModified{} } diff --git a/internal/domain/account.go b/internal/domain/account.go index 4217e0db..8de71e5c 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -1,14 +1,93 @@ package domain -// PasswordSettings 是账号 2FA/SRP 配置。第一阶段默认 HasPassword=false。 +import "errors" + +var ( + ErrPasswordHashInvalid = errors.New("password hash invalid") + ErrSRPIDInvalid = errors.New("srp id invalid") + ErrSRPPasswordChanged = errors.New("srp password changed") + ErrNewSettingsInvalid = errors.New("new password settings invalid") + ErrNewSaltInvalid = errors.New("new password salt invalid") + ErrPasswordRecoveryNA = errors.New("password recovery not available") + ErrEmailCodeInvalid = errors.New("email code invalid") + ErrEmailInvalid = errors.New("email invalid") + ErrSessionPasswordNeeded = errors.New("session password needed") +) + +// PasswordKDFAlgo 是业务层的 SRP KDF 算法描述,不依赖 tg.*。 +type PasswordKDFAlgo struct { + Salt1 []byte + Salt2 []byte + G int + P []byte +} + +// SecurePasswordKDFAlgo 是 Telegram Passport secure secret 的 KDF 算法描述。 +type SecurePasswordKDFAlgo struct { + Kind string + Salt []byte +} + +// PasswordCheck 是 inputCheckPasswordEmpty/inputCheckPasswordSRP 的业务层表达。 +type PasswordCheck struct { + Empty bool + SRPID int64 + A []byte + M1 []byte +} + +// PasswordInputSettings 是 account.passwordInputSettings 的业务层表达。 +type PasswordInputSettings struct { + NewAlgo *PasswordKDFAlgo + NewPasswordHash []byte + Hint string + HasHint bool + Email string + HasEmail bool +} + +// PrivatePasswordSettings 是 account.passwordSettings 的业务层表达。 +type PrivatePasswordSettings struct { + Email string +} + +type PasswordResetKind string + +const ( + PasswordResetOK PasswordResetKind = "ok" + PasswordResetRequestedWait PasswordResetKind = "requested_wait" + PasswordResetFailedWait PasswordResetKind = "failed_wait" +) + +type PasswordResetResult struct { + Kind PasswordResetKind + UntilDate int + RetryDate int +} + +// PasswordSettings 是账号 2FA/SRP 配置。默认 HasPassword=false。 type PasswordSettings struct { HasRecovery bool HasSecureValues bool HasPassword bool + CurrentAlgo *PasswordKDFAlgo + SRPB []byte + SRPID int64 Hint string EmailUnconfirmedPattern string + RecoveryEmail string LoginEmailPattern string + NewAlgo PasswordKDFAlgo + NewSecureAlgo SecurePasswordKDFAlgo SecureRandom []byte + PendingResetDate int + + // Server-only SRP fields. They are persisted but never exposed to rpc/tg conversion. + SRPVerifier []byte + SRPBSecret []byte + + RecoveryCode string + RecoveryCodeExpiresAt int64 } // ReactionNotifyFrom stores one account-level reaction notification scope. diff --git a/internal/domain/authorization.go b/internal/domain/authorization.go index 1c8b4c93..f74bc9d1 100644 --- a/internal/domain/authorization.go +++ b/internal/domain/authorization.go @@ -1,10 +1,13 @@ package domain +import "time" + // Authorization 是一条设备授权:auth_key 与 user 的绑定 + initConnection 设备信息。 // auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。 type Authorization struct { AuthKeyID [8]byte // 协议原生 auth_key_id;store 边界按小端转 int64 UserID int64 + Hash int64 Layer int DeviceModel string Platform string @@ -12,4 +15,6 @@ type Authorization struct { APIID int AppVersion string IP string + CreatedAt time.Time + ActiveAt time.Time } diff --git a/internal/rpc/account.go b/internal/rpc/account.go index 736be9de..4f3a6264 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -12,6 +12,12 @@ import ( // registerAccount 注册 account.* RPC handler。 func (r *Router) registerAccount(d *tg.ServerDispatcher) { + d.OnAccountRegisterDevice(func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (bool, error) { + return true, nil + }) + d.OnAccountUnregisterDevice(func(ctx context.Context, req *tg.AccountUnregisterDeviceRequest) (bool, error) { + return true, nil + }) d.OnAccountCheckUsername(r.onAccountCheckUsername) d.OnAccountUpdateProfile(r.onAccountUpdateProfile) d.OnAccountUpdateUsername(r.onAccountUpdateUsername) @@ -35,11 +41,18 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountUpdateNotifySettings(func(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) { return true, nil }) + d.OnAccountResetNotifySettings(func(ctx context.Context) (bool, error) { + return true, nil + }) d.OnAccountGetPrivacy(r.onAccountGetPrivacy) d.OnAccountSetPrivacy(r.onAccountSetPrivacy) - d.OnAccountGetAuthorizations(func(ctx context.Context) (*tg.AccountAuthorizations, error) { - return tdesktop.Authorizations(), nil - }) + d.OnAccountGetAuthorizations(r.onAccountGetAuthorizations) + d.OnAccountResetAuthorization(r.onAccountResetAuthorization) + d.OnAccountGetPasswordSettings(r.onAccountGetPasswordSettings) + d.OnAccountUpdatePasswordSettings(r.onAccountUpdatePasswordSettings) + d.OnAccountConfirmPasswordEmail(r.onAccountConfirmPasswordEmail) + d.OnAccountResendPasswordEmail(r.onAccountResendPasswordEmail) + d.OnAccountCancelPasswordEmail(r.onAccountCancelPasswordEmail) d.OnAccountGetDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { return tdesktop.DefaultEmojiStatuses(), nil }) @@ -57,18 +70,66 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountGetContactSignUpNotification(func(ctx context.Context) (bool, error) { return false, nil }) + d.OnAccountSetContactSignUpNotification(func(ctx context.Context, silent bool) (bool, error) { + return true, nil + }) d.OnAccountGetThemes(func(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) { return tdesktop.AccountThemes(), nil }) + d.OnAccountGetRecentEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}}, nil + }) + d.OnAccountClearRecentEmojiStatuses(func(ctx context.Context) (bool, error) { + return true, nil + }) + d.OnAccountUpdateEmojiStatus(func(ctx context.Context, emojistatus tg.EmojiStatusClass) (bool, error) { + return true, nil + }) + d.OnAccountGetDefaultProfilePhotoEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { + return tdesktop.DefaultGroupPhotoEmojis(), nil + }) + d.OnAccountGetDefaultBackgroundEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { + return tdesktop.DefaultGroupPhotoEmojis(), nil + }) + d.OnAccountGetChannelDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}}, nil + }) + d.OnAccountGetChannelRestrictedStatusEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { + return tdesktop.DefaultGroupPhotoEmojis(), nil + }) + d.OnAccountSetContentSettings(func(ctx context.Context, req *tg.AccountSetContentSettingsRequest) (bool, error) { + return true, nil + }) d.OnAccountGetContentSettings(func(ctx context.Context) (*tg.AccountContentSettings, error) { return tdesktop.ContentSettings(), nil }) d.OnAccountGetGlobalPrivacySettings(func(ctx context.Context) (*tg.GlobalPrivacySettings, error) { return tdesktop.GlobalPrivacySettings(), nil }) + d.OnAccountSetGlobalPrivacySettings(func(ctx context.Context, settings tg.GlobalPrivacySettings) (*tg.GlobalPrivacySettings, error) { + return &settings, nil + }) d.OnAccountGetPasskeys(func(ctx context.Context) (*tg.AccountPasskeys, error) { return tdesktop.Passkeys(), nil }) + d.OnAccountGetWebAuthorizations(func(ctx context.Context) (*tg.AccountWebAuthorizations, error) { + return tdesktop.WebAuthorizations(), nil + }) + d.OnAccountResetWebAuthorization(func(ctx context.Context, hash int64) (bool, error) { + return true, nil + }) + d.OnAccountResetWebAuthorizations(func(ctx context.Context) (bool, error) { + return true, nil + }) + d.OnAccountGetNotifyExceptions(func(ctx context.Context, req *tg.AccountGetNotifyExceptionsRequest) (tg.UpdatesClass, error) { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(r.clock.Now().Unix())}, nil + }) + d.OnAccountGetAutoDownloadSettings(func(ctx context.Context) (*tg.AccountAutoDownloadSettings, error) { + return tdesktop.AutoDownloadSettings(), nil + }) + d.OnAccountSaveAutoDownloadSettings(func(ctx context.Context, req *tg.AccountSaveAutoDownloadSettingsRequest) (bool, error) { + return true, nil + }) d.OnAccountGetSavedMusicIDs(func(ctx context.Context, hash int64) (tg.AccountSavedMusicIDsClass, error) { if _, _, err := r.currentUserID(ctx); err != nil { return nil, internalErr() @@ -76,9 +137,121 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { return &tg.AccountSavedMusicIDs{IDs: []int64{}}, nil }) d.OnAccountGetAccountTTL(r.onAccountGetAccountTTL) + d.OnAccountSetAccountTTL(func(ctx context.Context, ttl tg.AccountDaysTTL) (bool, error) { + return true, nil + }) + d.OnAccountSetAuthorizationTTL(func(ctx context.Context, authorizationttldays int) (bool, error) { + return true, nil + }) + d.OnAccountChangeAuthorizationSettings(func(ctx context.Context, req *tg.AccountChangeAuthorizationSettingsRequest) (bool, error) { + return true, nil + }) + d.OnAccountResetPassword(r.onAccountResetPassword) + d.OnAccountDeclinePasswordReset(r.onAccountDeclinePasswordReset) d.OnAccountUpdateStatus(r.onAccountUpdateStatus) } +func (r *Router) onAccountGetAuthorizations(ctx context.Context) (*tg.AccountAuthorizations, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Auth == nil { + return tdesktop.Authorizations(), nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + items, err := r.deps.Auth.ListAuthorizations(ctx, userID) + if err != nil { + return nil, internalErr() + } + out := &tg.AccountAuthorizations{Authorizations: make([]tg.Authorization, 0, len(items))} + for _, item := range items { + out.Authorizations = append(out.Authorizations, tgAuthorization(item, authKeyID, int(r.clock.Now().Unix()))) + } + return out, nil +} + +func (r *Router) onAccountResetAuthorization(ctx context.Context, hash int64) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Auth == nil { + return true, nil + } + deleted, found, err := r.deps.Auth.ResetAuthorization(ctx, userID, hash) + if err != nil { + return false, internalErr() + } + if !found { + return true, nil + } + r.invalidateAuthUserCache(deleted.AuthKeyID) + r.unbindAuthKey(deleted.AuthKeyID) + _ = r.clearAuthKeyState(ctx, deleted.AuthKeyID) + return true, nil +} + +func (r *Router) onAccountGetPasswordSettings(ctx context.Context, password tg.InputCheckPasswordSRPClass) (*tg.AccountPasswordSettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings, err := r.deps.Account.GetPasswordSettings(ctx, userID, domainPasswordCheck(password)) + if err != nil { + return nil, passwordErr(err) + } + return tgPasswordSettings(settings), nil +} + +func (r *Router) onAccountUpdatePasswordSettings(ctx context.Context, req *tg.AccountUpdatePasswordSettingsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + input, err := domainPasswordInputSettings(req.NewSettings) + if err != nil { + return false, err + } + if err := r.deps.Account.UpdatePasswordSettings(ctx, userID, domainPasswordCheck(req.Password), input); err != nil { + return false, passwordErr(err) + } + return true, nil +} + +func (r *Router) onAccountConfirmPasswordEmail(ctx context.Context, code string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if err := r.deps.Account.ConfirmPasswordEmail(ctx, userID, code); err != nil { + return false, passwordErr(err) + } + return true, nil +} + +func (r *Router) onAccountResendPasswordEmail(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if err := r.deps.Account.ResendPasswordEmail(ctx, userID); err != nil { + return false, passwordErr(err) + } + return true, nil +} + +func (r *Router) onAccountCancelPasswordEmail(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if err := r.deps.Account.CancelPasswordEmail(ctx, userID); err != nil { + return false, passwordErr(err) + } + return true, nil +} + func (r *Router) onAccountGetPrivacy(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -140,6 +313,35 @@ func (r *Router) onAccountGetAccountTTL(ctx context.Context) (*tg.AccountDaysTTL return &tg.AccountDaysTTL{Days: 365}, nil } +func (r *Router) onAccountResetPassword(ctx context.Context) (tg.AccountResetPasswordResultClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Account == nil { + return &tg.AccountResetPasswordFailedWait{RetryDate: int(r.clock.Now().Unix()) + 86400}, nil + } + result, err := r.deps.Account.ResetPassword(ctx, userID) + if err != nil { + return nil, passwordErr(err) + } + return tgPasswordResetResult(result), nil +} + +func (r *Router) onAccountDeclinePasswordReset(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Account == nil { + return true, nil + } + if err := r.deps.Account.DeclinePasswordReset(ctx, userID); err != nil { + return false, internalErr() + } + return true, nil +} + func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, error) { userID, authorized, err := r.currentUserID(ctx) if err != nil { @@ -153,6 +355,19 @@ func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, return true, nil } +func tgPasswordResetResult(result domain.PasswordResetResult) tg.AccountResetPasswordResultClass { + switch result.Kind { + case domain.PasswordResetOK: + return &tg.AccountResetPasswordOk{} + case domain.PasswordResetRequestedWait: + return &tg.AccountResetPasswordRequestedWait{UntilDate: result.UntilDate} + case domain.PasswordResetFailedWait: + return &tg.AccountResetPasswordFailedWait{RetryDate: result.RetryDate} + default: + return &tg.AccountResetPasswordFailedWait{} + } +} + func (r *Router) tgAccountPrivacyRules(ctx context.Context, viewerUserID int64, rules domain.PrivacyRules) (*tg.AccountPrivacyRules, error) { userIDs := privacyRuleUserIDs(rules.Rules) users := []domain.User{} @@ -532,6 +747,33 @@ func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { }) } +func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int) tg.Authorization { + created := int(a.CreatedAt.Unix()) + if created == 0 { + created = now + } + active := int(a.ActiveAt.Unix()) + if active == 0 { + active = created + } + return tg.Authorization{ + Current: a.AuthKeyID == currentAuthKeyID, + OfficialApp: true, + Hash: a.Hash, + DeviceModel: a.DeviceModel, + Platform: a.Platform, + SystemVersion: a.SystemVersion, + APIID: a.APIID, + AppName: "Telegram Desktop", + AppVersion: a.AppVersion, + DateCreated: created, + DateActive: active, + IP: a.IP, + Country: "Unknown", + Region: "Unknown", + } +} + func usernameErr(err error) error { switch { case errors.Is(err, domain.ErrUsernameInvalid): diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go index d69adb8e..5fccd2b4 100644 --- a/internal/rpc/auth.go +++ b/internal/rpc/auth.go @@ -3,6 +3,7 @@ package rpc import ( "context" "encoding/binary" + "errors" "fmt" "strings" "time" @@ -25,10 +26,28 @@ const loginMessagePushDelay = 2 * time.Second func (r *Router) registerAuth(d *tg.ServerDispatcher) { d.OnAuthBindTempAuthKey(r.onAuthBindTempAuthKey) d.OnAuthExportLoginToken(r.onAuthExportLoginToken) + d.OnAuthImportLoginToken(r.onAuthImportLoginToken) + d.OnAuthAcceptLoginToken(r.onAuthAcceptLoginToken) + d.OnAuthExportAuthorization(func(ctx context.Context, dcid int) (*tg.AuthExportedAuthorization, error) { + return nil, dcIDInvalidErr() + }) + d.OnAuthImportAuthorization(func(ctx context.Context, req *tg.AuthImportAuthorizationRequest) (tg.AuthAuthorizationClass, error) { + return nil, dcIDInvalidErr() + }) + d.OnAuthDropTempAuthKeys(func(ctx context.Context, exceptauthkeys []int64) (bool, error) { + return true, nil + }) d.OnAuthSendCode(r.onAuthSendCode) + d.OnAuthResendCode(r.onAuthResendCode) + d.OnAuthCancelCode(r.onAuthCancelCode) d.OnAuthSignIn(r.onAuthSignIn) d.OnAuthSignUp(r.onAuthSignUp) d.OnAuthLogOut(r.onAuthLogOut) + d.OnAuthResetAuthorizations(r.onAuthResetAuthorizations) + d.OnAuthCheckPassword(r.onAuthCheckPassword) + d.OnAuthRequestPasswordRecovery(r.onAuthRequestPasswordRecovery) + d.OnAuthRecoverPassword(r.onAuthRecoverPassword) + d.OnAuthCheckRecoveryPassword(r.onAuthCheckRecoveryPassword) } // onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。 @@ -69,22 +88,45 @@ func (r *Router) onAuthExportLoginToken(ctx context.Context, _ *tg.AuthExportLog return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil } +func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) { + id, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil +} + +func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) { + return nil, authTokenInvalidErr() +} + // onAuthSendCode 处理 auth.sendCode:生成 phone_code_hash 并返回 sentCode。 func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) { hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber) if err != nil { return nil, internalErr() } + return tgSentCode(hash), nil +} + +func tgSentCode(hash string) tg.AuthSentCodeClass { return &tg.AuthSentCode{ Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength}, PhoneCodeHash: hash, - }, nil + } } // onAuthSignIn 处理 auth.signIn:校验验证码;用户不存在时返回 SignUpRequired。 func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) { u, loginMessage, needSignUp, err := r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode) if err != nil { + if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 { + if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil { + return nil, internalErr() + } + if id, ok := AuthKeyIDFrom(ctx); ok { + r.setAuthUserCache(id, u.ID, true) + } + r.bindSessionUser(ctx, u.ID) + } return nil, signInErr(err) } if needSignUp { @@ -102,6 +144,103 @@ func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (t return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil } +func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeRequest) (tg.AuthSentCodeClass, error) { + hash, err := r.deps.Auth.ResendCode(ctx, req.PhoneNumber, req.PhoneCodeHash) + if err != nil { + return nil, signInErr(err) + } + return tgSentCode(hash), nil +} + +func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) { + if err := r.deps.Auth.CancelCode(ctx, req.PhoneNumber, req.PhoneCodeHash); err != nil { + return false, signInErr(err) + } + return true, nil +} + +func (r *Router) onAuthResetAuthorizations(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + authKeyID, _ := AuthKeyIDFrom(ctx) + deleted, err := r.deps.Auth.ResetAuthorizations(ctx, userID, authKeyID) + if err != nil { + return false, internalErr() + } + for _, a := range deleted { + r.invalidateAuthUserCache(a.AuthKeyID) + r.unbindAuthKey(a.AuthKeyID) + _ = r.clearAuthKeyState(ctx, a.AuthKeyID) + } + return true, nil +} + +func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheckPasswordSRPClass) (tg.AuthAuthorizationClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Account == nil { + return nil, passwordHashInvalidErr() + } + if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(password)); err != nil { + return nil, passwordErr(err) + } + u, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + +func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPasswordRecovery, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + pattern, err := r.deps.Account.RequestPasswordRecovery(ctx, userID) + if err != nil { + return nil, passwordErr(err) + } + return &tg.AuthPasswordRecovery{EmailPattern: pattern}, nil +} + +func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverPasswordRequest) (tg.AuthAuthorizationClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + var input *domain.PasswordInputSettings + if settings, ok := req.GetNewSettings(); ok { + converted, err := domainPasswordInputSettings(settings) + if err != nil { + return nil, err + } + input = &converted + } + if err := r.deps.Account.RecoverPassword(ctx, userID, req.Code, input); err != nil { + return nil, passwordErr(err) + } + u, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + +func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if err := r.deps.Account.CheckRecoveryPassword(ctx, userID, code); err != nil { + return false, passwordErr(err) + } + return true, nil +} + // onAuthSignUp 处理 auth.signUp:创建用户并绑定授权。 func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (tg.AuthAuthorizationClass, error) { u, loginMessage, err := r.deps.Auth.SignUp(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.FirstName, req.LastName) diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go index 1dec9d01..61107d19 100644 --- a/internal/rpc/convert.go +++ b/internal/rpc/convert.go @@ -1875,17 +1875,95 @@ func tgPassword(settings domain.PasswordSettings) *tg.AccountPassword { if len(settings.SecureRandom) == 0 { settings.SecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") } - return &tg.AccountPassword{ + out := &tg.AccountPassword{ HasRecovery: settings.HasRecovery, HasSecureValues: settings.HasSecureValues, HasPassword: settings.HasPassword, Hint: settings.Hint, EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, - NewAlgo: &tg.PasswordKdfAlgoUnknown{}, - NewSecureAlgo: &tg.SecurePasswordKdfAlgoUnknown{}, + NewAlgo: tgPasswordAlgo(settings.NewAlgo), + NewSecureAlgo: tgSecurePasswordAlgo(settings.NewSecureAlgo), SecureRandom: settings.SecureRandom, LoginEmailPattern: settings.LoginEmailPattern, } + if settings.HasPassword && settings.CurrentAlgo != nil { + out.CurrentAlgo = tgPasswordAlgo(*settings.CurrentAlgo) + out.SRPB = append([]byte(nil), settings.SRPB...) + out.SRPID = settings.SRPID + } + if settings.PendingResetDate != 0 { + out.PendingResetDate = settings.PendingResetDate + } + return out +} + +func tgPasswordAlgo(algo domain.PasswordKDFAlgo) tg.PasswordKdfAlgoClass { + if len(algo.P) == 0 || algo.G == 0 { + return &tg.PasswordKdfAlgoUnknown{} + } + return &tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow{ + Salt1: append([]byte(nil), algo.Salt1...), + Salt2: append([]byte(nil), algo.Salt2...), + G: algo.G, + P: append([]byte(nil), algo.P...), + } +} + +func domainPasswordAlgo(in tg.PasswordKdfAlgoClass) (*domain.PasswordKDFAlgo, bool) { + if algo, ok := in.(*tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow); ok { + return &domain.PasswordKDFAlgo{ + Salt1: append([]byte(nil), algo.Salt1...), + Salt2: append([]byte(nil), algo.Salt2...), + G: algo.G, + P: append([]byte(nil), algo.P...), + }, true + } + return nil, false +} + +func tgSecurePasswordAlgo(algo domain.SecurePasswordKDFAlgo) tg.SecurePasswordKdfAlgoClass { + if algo.Kind == "pbkdf2_hmac_sha512_iter100000" { + return &tg.SecurePasswordKdfAlgoPBKDF2HMACSHA512iter100000{Salt: append([]byte(nil), algo.Salt...)} + } + return &tg.SecurePasswordKdfAlgoUnknown{} +} + +func domainPasswordCheck(in tg.InputCheckPasswordSRPClass) domain.PasswordCheck { + if srp, ok := in.(*tg.InputCheckPasswordSRP); ok { + return domain.PasswordCheck{ + SRPID: srp.SRPID, + A: append([]byte(nil), srp.A...), + M1: append([]byte(nil), srp.M1...), + } + } + return domain.PasswordCheck{Empty: true} +} + +func domainPasswordInputSettings(in tg.AccountPasswordInputSettings) (domain.PasswordInputSettings, error) { + out := domain.PasswordInputSettings{} + if algo, ok := in.GetNewAlgo(); ok { + domainAlgo, ok := domainPasswordAlgo(algo) + if !ok { + return out, passwordHashInvalidErr() + } + out.NewAlgo = domainAlgo + out.NewPasswordHash = append([]byte(nil), in.NewPasswordHash...) + out.Hint = in.Hint + out.HasHint = true + } + if email, ok := in.GetEmail(); ok { + out.Email = email + out.HasEmail = true + } + return out, nil +} + +func tgPasswordSettings(settings domain.PrivatePasswordSettings) *tg.AccountPasswordSettings { + out := &tg.AccountPasswordSettings{} + if settings.Email != "" { + out.Email = settings.Email + } + return out } func tgCountriesList(list domain.CountriesList) tg.HelpCountriesListClass { diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 4dc54a40..457bac04 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -20,9 +20,14 @@ type AuthService interface { ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) SendCode(ctx context.Context, phone string) (string, error) + ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) + CancelCode(ctx context.Context, phone, phoneCodeHash string) error SignIn(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) SignUp(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) LogOut(ctx context.Context, authKeyID [8]byte) error + ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) + ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) + ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) } // SessionBinder 抽象登录后 session 与 user 的在线绑定。 @@ -99,6 +104,17 @@ type UserIdentityService interface { // AccountService 抽象账号设置查询。 type AccountService interface { GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) + GetPasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck) (domain.PrivatePasswordSettings, error) + UpdatePasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck, input domain.PasswordInputSettings) error + CheckPassword(ctx context.Context, userID int64, check domain.PasswordCheck) error + RequestPasswordRecovery(ctx context.Context, userID int64) (string, error) + CheckRecoveryPassword(ctx context.Context, userID int64, code string) error + RecoverPassword(ctx context.Context, userID int64, code string, input *domain.PasswordInputSettings) error + ConfirmPasswordEmail(ctx context.Context, userID int64, code string) error + ResendPasswordEmail(ctx context.Context, userID int64) error + CancelPasswordEmail(ctx context.Context, userID int64) error + ResetPassword(ctx context.Context, userID int64) (domain.PasswordResetResult, error) + DeclinePasswordReset(ctx context.Context, userID int64) error } // PrivacyService owns account privacy rule storage/evaluation. diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 6a1f1320..e575ab05 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -118,6 +118,10 @@ func usernameNotModifiedErr() error { return tgerr.New(400, "USERNAME_NOT_MODIFI func phoneNotOccupiedErr() error { return tgerr.New(400, "PHONE_NOT_OCCUPIED") } +func dcIDInvalidErr() error { return tgerr.New(400, "DC_ID_INVALID") } + +func authTokenInvalidErr() error { return tgerr.New(400, "AUTH_TOKEN_INVALID") } + func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") } func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") } @@ -152,6 +156,16 @@ func messageDeleteForbiddenErr() error { return tgerr.New(403, "DELETE_MESSAGES_ func messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") } +func sessionPasswordNeededErr() error { return tgerr.New(401, "SESSION_PASSWORD_NEEDED") } +func passwordHashInvalidErr() error { return tgerr.New(400, "PASSWORD_HASH_INVALID") } +func srpIDInvalidErr() error { return tgerr.New(400, "SRP_ID_INVALID") } +func srpPasswordChangedErr() error { return tgerr.New(400, "SRP_PASSWORD_CHANGED") } +func newSettingsInvalidErr() error { return tgerr.New(400, "NEW_SETTINGS_INVALID") } +func newSaltInvalidErr() error { return tgerr.New(400, "NEW_SALT_INVALID") } +func emailInvalidErr() error { return tgerr.New(400, "EMAIL_INVALID") } +func emailCodeInvalidErr() error { return tgerr.New(400, "CODE_INVALID") } +func passwordRecoveryNAErr() error { return tgerr.New(400, "PASSWORD_RECOVERY_NA") } + func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") } func chatForwardsRestrictedErr() error { return tgerr.New(400, "CHAT_FORWARDS_RESTRICTED") } @@ -188,6 +202,31 @@ func signInErr(err error) error { return tgerr.New(400, "PHONE_CODE_EXPIRED") case errors.Is(err, domain.ErrFirstNameInvalid): return firstNameInvalidErr() + case errors.Is(err, domain.ErrSessionPasswordNeeded): + return sessionPasswordNeededErr() + default: + return internalErr() + } +} + +func passwordErr(err error) error { + switch { + case errors.Is(err, domain.ErrPasswordHashInvalid): + return passwordHashInvalidErr() + case errors.Is(err, domain.ErrSRPIDInvalid): + return srpIDInvalidErr() + case errors.Is(err, domain.ErrSRPPasswordChanged): + return srpPasswordChangedErr() + case errors.Is(err, domain.ErrNewSettingsInvalid): + return newSettingsInvalidErr() + case errors.Is(err, domain.ErrNewSaltInvalid): + return newSaltInvalidErr() + case errors.Is(err, domain.ErrEmailInvalid): + return emailInvalidErr() + case errors.Is(err, domain.ErrEmailCodeInvalid): + return emailCodeInvalidErr() + case errors.Is(err, domain.ErrPasswordRecoveryNA): + return passwordRecoveryNAErr() default: return internalErr() } diff --git a/internal/rpc/router_test.go b/internal/rpc/router_test.go index 069cfcc2..8fde75ed 100644 --- a/internal/rpc/router_test.go +++ b/internal/rpc/router_test.go @@ -403,11 +403,19 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) { {name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}}, {name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}}, {name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}}, + {name: "account.resetNotifySettings", req: &tg.AccountResetNotifySettingsRequest{}}, {name: "account.getPrivacy", req: &tg.AccountGetPrivacyRequest{Key: &tg.InputPrivacyKeyStatusTimestamp{}}}, {name: "account.getAuthorizations", req: &tg.AccountGetAuthorizationsRequest{}}, + {name: "account.getWebAuthorizations", req: &tg.AccountGetWebAuthorizationsRequest{}}, + {name: "account.getNotifyExceptions", req: &tg.AccountGetNotifyExceptionsRequest{}}, {name: "account.getDefaultEmojiStatuses", req: &tg.AccountGetDefaultEmojiStatusesRequest{}}, + {name: "account.getRecentEmojiStatuses", req: &tg.AccountGetRecentEmojiStatusesRequest{}}, {name: "account.getCollectibleEmojiStatuses", req: &tg.AccountGetCollectibleEmojiStatusesRequest{}}, + {name: "account.getDefaultProfilePhotoEmojis", req: &tg.AccountGetDefaultProfilePhotoEmojisRequest{}}, {name: "account.getDefaultGroupPhotoEmojis", req: &tg.AccountGetDefaultGroupPhotoEmojisRequest{}}, + {name: "account.getDefaultBackgroundEmojis", req: &tg.AccountGetDefaultBackgroundEmojisRequest{}}, + {name: "account.getChannelDefaultEmojiStatuses", req: &tg.AccountGetChannelDefaultEmojiStatusesRequest{}}, + {name: "account.getChannelRestrictedStatusEmojis", req: &tg.AccountGetChannelRestrictedStatusEmojisRequest{}}, {name: "account.getConnectedBots", req: &tg.AccountGetConnectedBotsRequest{}}, {name: "account.getReactionsNotifySettings", req: &tg.AccountGetReactionsNotifySettingsRequest{}}, {name: "account.getContactSignUpNotification", req: &tg.AccountGetContactSignUpNotificationRequest{}}, @@ -415,7 +423,9 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) { {name: "account.getContentSettings", req: &tg.AccountGetContentSettingsRequest{}}, {name: "account.getGlobalPrivacySettings", req: &tg.AccountGetGlobalPrivacySettingsRequest{}}, {name: "account.getPasskeys", req: &tg.AccountGetPasskeysRequest{}}, + {name: "account.getAutoDownloadSettings", req: &tg.AccountGetAutoDownloadSettingsRequest{}}, {name: "account.getSavedMusicIds", req: &tg.AccountGetSavedMusicIDsRequest{}}, + {name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}}, {name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}}, {name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}}, {name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}}, @@ -9401,6 +9411,14 @@ func (s *blockingUserAuthService) SendCode(context.Context, string) (string, err return "", nil } +func (s *blockingUserAuthService) ResendCode(context.Context, string, string) (string, error) { + return "", nil +} + +func (s *blockingUserAuthService) CancelCode(context.Context, string, string) error { + return nil +} + func (s *blockingUserAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { return domain.User{}, domain.Message{}, false, nil } @@ -9413,6 +9431,18 @@ func (s *blockingUserAuthService) LogOut(context.Context, [8]byte) error { return nil } +func (s *blockingUserAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { + return nil, nil +} + +func (s *blockingUserAuthService) ResetAuthorization(context.Context, int64, int64) (domain.Authorization, bool, error) { + return domain.Authorization{}, false, nil +} + +func (s *blockingUserAuthService) ResetAuthorizations(context.Context, int64, [8]byte) ([]domain.Authorization, error) { + return nil, nil +} + func (s *captureAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { return nil } @@ -9431,6 +9461,14 @@ func (s *captureAuthService) SendCode(context.Context, string) (string, error) { return "", nil } +func (s *captureAuthService) ResendCode(context.Context, string, string) (string, error) { + return "", nil +} + +func (s *captureAuthService) CancelCode(context.Context, string, string) error { + return nil +} + func (s *captureAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { if s.signInUser.ID != 0 { return s.signInUser, domain.Message{}, false, nil @@ -9447,6 +9485,18 @@ func (s *captureAuthService) LogOut(_ context.Context, authKeyID [8]byte) error return nil } +func (s *captureAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { + return nil, nil +} + +func (s *captureAuthService) ResetAuthorization(context.Context, int64, int64) (domain.Authorization, bool, error) { + return domain.Authorization{}, false, nil +} + +func (s *captureAuthService) ResetAuthorizations(context.Context, int64, [8]byte) ([]domain.Authorization, error) { + return nil, nil +} + func (s staticUsersService) Self(context.Context, int64) (domain.User, error) { return s.user, nil } diff --git a/internal/store/authorization.go b/internal/store/authorization.go index e14f1f7f..46c009c9 100644 --- a/internal/store/authorization.go +++ b/internal/store/authorization.go @@ -12,4 +12,6 @@ type AuthorizationStore interface { ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) Delete(ctx context.Context, authKeyID [8]byte) error + DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) + DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) } diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go index 6f66a016..fd7fa63e 100644 --- a/internal/store/memory/memory.go +++ b/internal/store/memory/memory.go @@ -2742,18 +2742,36 @@ func (s *PasswordStore) GetByUser(_ context.Context, userID int64) (domain.Passw s.mu.RLock() settings, ok := s.m[userID] s.mu.RUnlock() - settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) - return settings, ok, nil + return clonePasswordSettings(settings), ok, nil } func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error { - settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) s.mu.Lock() - s.m[userID] = settings + s.m[userID] = clonePasswordSettings(settings) s.mu.Unlock() return nil } +func clonePasswordSettings(in domain.PasswordSettings) domain.PasswordSettings { + out := in + if in.CurrentAlgo != nil { + algo := *in.CurrentAlgo + algo.Salt1 = append([]byte(nil), algo.Salt1...) + algo.Salt2 = append([]byte(nil), algo.Salt2...) + algo.P = append([]byte(nil), algo.P...) + out.CurrentAlgo = &algo + } + out.SRPB = append([]byte(nil), in.SRPB...) + out.NewAlgo.Salt1 = append([]byte(nil), in.NewAlgo.Salt1...) + out.NewAlgo.Salt2 = append([]byte(nil), in.NewAlgo.Salt2...) + out.NewAlgo.P = append([]byte(nil), in.NewAlgo.P...) + out.NewSecureAlgo.Salt = append([]byte(nil), in.NewSecureAlgo.Salt...) + out.SecureRandom = append([]byte(nil), in.SecureRandom...) + out.SRPVerifier = append([]byte(nil), in.SRPVerifier...) + out.SRPBSecret = append([]byte(nil), in.SRPBSecret...) + return out +} + func (s *PasswordStore) GetReactionSettings(_ context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { s.mu.RLock() settings, ok := s.reactions[userID] @@ -3047,7 +3065,18 @@ func NewAuthorizationStore() *AuthorizationStore { } func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error { + now := time.Now() + if a.Hash == 0 { + a.Hash = int64(binary.LittleEndian.Uint64(a.AuthKeyID[:])) + } + if a.CreatedAt.IsZero() { + a.CreatedAt = now + } + a.ActiveAt = now s.mu.Lock() + if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() { + a.CreatedAt = existing.CreatedAt + } s.m[a.AuthKeyID] = a s.mu.Unlock() return nil @@ -3079,6 +3108,32 @@ func (s *AuthorizationStore) Delete(_ context.Context, id [8]byte) error { return nil } +func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64) (domain.Authorization, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + for id, a := range s.m { + if a.UserID == userID && a.Hash == hash { + delete(s.m, id) + return a, true, nil + } + } + return domain.Authorization{}, false, nil +} + +func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.Authorization, 0) + for id, a := range s.m { + if a.UserID != userID || id == keepAuthKeyID { + continue + } + delete(s.m, id) + out = append(out, a) + } + return out, nil +} + // CodeStore 是 store.CodeStore 的内存实现(带 TTL)。 type CodeStore struct { mu sync.Mutex diff --git a/internal/store/postgres/account.go b/internal/store/postgres/account.go index 33b51784..0a34930d 100644 --- a/internal/store/postgres/account.go +++ b/internal/store/postgres/account.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/jackc/pgx/v5" @@ -24,35 +25,97 @@ func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore { } func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) { - row, err := s.q.GetPasswordByUser(ctx, userID) - if err != nil { + row := s.db.QueryRow(ctx, ` +SELECT + has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random, + current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, + srp_id, srp_verifier, srp_b_secret, srp_b, + recovery_email, recovery_code, recovery_code_expires_at +FROM account_passwords +WHERE user_id = $1`, userID) + var settings domain.PasswordSettings + var salt1, salt2, p []byte + var recoveryExpires sql.NullTime + if err := row.Scan( + &settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint, + &settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom, + &salt1, &salt2, &settings.NewAlgo.G, &p, + &settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB, + &settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, + ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PasswordSettings{}, false, nil } return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err) } - return domain.PasswordSettings{ - HasRecovery: row.HasRecovery, - HasSecureValues: row.HasSecureValues, - HasPassword: row.HasPassword, - Hint: row.Hint, - EmailUnconfirmedPattern: row.EmailUnconfirmedPattern, - LoginEmailPattern: row.LoginEmailPattern, - SecureRandom: append([]byte(nil), row.SecureRandom...), - }, true, nil + if len(salt1) > 0 || len(salt2) > 0 || len(p) > 0 || settings.NewAlgo.G != 0 { + settings.CurrentAlgo = &domain.PasswordKDFAlgo{ + Salt1: append([]byte(nil), salt1...), + Salt2: append([]byte(nil), salt2...), + G: settings.NewAlgo.G, + P: append([]byte(nil), p...), + } + } + settings.NewAlgo.Salt1 = append([]byte(nil), salt1...) + settings.NewAlgo.Salt2 = append([]byte(nil), salt2...) + settings.NewAlgo.P = append([]byte(nil), p...) + if recoveryExpires.Valid { + settings.RecoveryCodeExpiresAt = recoveryExpires.Time.Unix() + } + settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) + settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...) + settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...) + settings.SRPB = append([]byte(nil), settings.SRPB...) + return settings, true, nil } func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error { - if err := s.q.UpsertPassword(ctx, sqlcgen.UpsertPasswordParams{ - UserID: userID, - HasRecovery: settings.HasRecovery, - HasSecureValues: settings.HasSecureValues, - HasPassword: settings.HasPassword, - Hint: settings.Hint, - EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, - LoginEmailPattern: settings.LoginEmailPattern, - SecureRandom: settings.SecureRandom, - }); err != nil { + algo := settings.NewAlgo + if settings.CurrentAlgo != nil { + algo = *settings.CurrentAlgo + } + var recoveryExpires any + if settings.RecoveryCodeExpiresAt > 0 { + recoveryExpires = time.Unix(settings.RecoveryCodeExpiresAt, 0) + } + _, err := s.db.Exec(ctx, ` +INSERT INTO account_passwords ( + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random, + current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, + srp_id, srp_verifier, srp_b_secret, srp_b, + recovery_email, recovery_code, recovery_code_expires_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) +ON CONFLICT (user_id) DO UPDATE SET + has_recovery = EXCLUDED.has_recovery, + has_secure_values = EXCLUDED.has_secure_values, + has_password = EXCLUDED.has_password, + hint = EXCLUDED.hint, + email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern, + login_email_pattern = EXCLUDED.login_email_pattern, + secure_random = EXCLUDED.secure_random, + current_algo_salt1 = EXCLUDED.current_algo_salt1, + current_algo_salt2 = EXCLUDED.current_algo_salt2, + current_algo_g = EXCLUDED.current_algo_g, + current_algo_p = EXCLUDED.current_algo_p, + srp_id = EXCLUDED.srp_id, + srp_verifier = EXCLUDED.srp_verifier, + srp_b_secret = EXCLUDED.srp_b_secret, + srp_b = EXCLUDED.srp_b, + recovery_email = EXCLUDED.recovery_email, + recovery_code = EXCLUDED.recovery_code, + recovery_code_expires_at = EXCLUDED.recovery_code_expires_at, + updated_at = now()`, + userID, + settings.HasRecovery, settings.HasSecureValues, settings.HasPassword, settings.Hint, + settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, settings.SecureRandom, + algo.Salt1, algo.Salt2, algo.G, algo.P, + settings.SRPID, settings.SRPVerifier, settings.SRPBSecret, settings.SRPB, + settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, + ) + if err != nil { return fmt.Errorf("upsert account password: %w", err) } return nil diff --git a/internal/store/postgres/authorization.go b/internal/store/postgres/authorization.go index d6284799..55973a50 100644 --- a/internal/store/postgres/authorization.go +++ b/internal/store/postgres/authorization.go @@ -2,6 +2,8 @@ package postgres import ( "context" + "crypto/sha256" + "encoding/binary" "errors" "fmt" @@ -13,26 +15,36 @@ import ( // AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。 type AuthorizationStore struct { - q *sqlcgen.Queries + db sqlcgen.DBTX + q *sqlcgen.Queries } // NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。 func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore { - return &AuthorizationStore{q: sqlcgen.New(db)} + return &AuthorizationStore{db: db, q: sqlcgen.New(db)} } func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error { - if err := s.q.UpsertAuthorization(ctx, sqlcgen.UpsertAuthorizationParams{ - AuthKeyID: authKeyIDToInt64(a.AuthKeyID), - UserID: a.UserID, - Layer: int32(a.Layer), - DeviceModel: a.DeviceModel, - Platform: a.Platform, - SystemVersion: a.SystemVersion, - ApiID: int32(a.APIID), - AppVersion: a.AppVersion, - Ip: a.IP, - }); err != nil { + if a.Hash == 0 { + a.Hash = authorizationHash(a.AuthKeyID) + } + _, err := s.db.Exec(ctx, ` +INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +ON CONFLICT (auth_key_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + hash = EXCLUDED.hash, + layer = EXCLUDED.layer, + device_model = EXCLUDED.device_model, + platform = EXCLUDED.platform, + system_version = EXCLUDED.system_version, + api_id = EXCLUDED.api_id, + app_version = EXCLUDED.app_version, + ip = EXCLUDED.ip, + active_at = now()`, + authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, + ) + if err != nil { return fmt.Errorf("upsert authorization: %w", err) } return nil @@ -78,10 +90,59 @@ func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error { return nil } +func (s *AuthorizationStore) DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) { + row := s.db.QueryRow(ctx, ` +DELETE FROM authorizations +WHERE user_id = $1 AND hash = $2 +RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at`, userID, hash) + var a domain.Authorization + var authKeyID int64 + if err := row.Scan( + &authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, + &a.APIID, &a.AppVersion, &a.IP, &a.CreatedAt, &a.ActiveAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Authorization{}, false, nil + } + return domain.Authorization{}, false, fmt.Errorf("delete authorization by hash: %w", err) + } + a.AuthKeyID = authKeyIDFromInt64(authKeyID) + return a, true, nil +} + +func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { + rows, err := s.db.Query(ctx, ` +DELETE FROM authorizations +WHERE user_id = $1 AND auth_key_id <> $2 +RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at`, userID, authKeyIDToInt64(keepAuthKeyID)) + if err != nil { + return nil, fmt.Errorf("delete authorizations by user: %w", err) + } + defer rows.Close() + out := make([]domain.Authorization, 0) + for rows.Next() { + var a domain.Authorization + var authKeyID int64 + if err := rows.Scan( + &authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, + &a.APIID, &a.AppVersion, &a.IP, &a.CreatedAt, &a.ActiveAt, + ); err != nil { + return nil, fmt.Errorf("scan deleted authorization: %w", err) + } + a.AuthKeyID = authKeyIDFromInt64(authKeyID) + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate deleted authorizations: %w", err) + } + return out, nil +} + func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization { return domain.Authorization{ AuthKeyID: authKeyIDFromInt64(row.AuthKeyID), UserID: row.UserID, + Hash: row.Hash, Layer: int(row.Layer), DeviceModel: row.DeviceModel, Platform: row.Platform, @@ -89,5 +150,16 @@ func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization { APIID: int(row.ApiID), AppVersion: row.AppVersion, IP: row.Ip, + CreatedAt: row.CreatedAt.Time, + ActiveAt: row.ActiveAt.Time, } } + +func authorizationHash(authKeyID [8]byte) int64 { + sum := sha256.Sum256(authKeyID[:]) + hash := int64(binary.LittleEndian.Uint64(sum[:8])) + if hash == 0 { + return 1 + } + return hash +}