auth: complete account authorization flows

(cherry picked from commit 04f4527df32ad5c35720cccc41d27fe51549612f)
This commit is contained in:
A 2026-06-08 21:42:32 +08:00
parent af41d18478
commit 6dc42942c8
21 changed files with 1780 additions and 50 deletions

View file

@ -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.

View file

@ -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}
}

204
internal/app/account/srp.go Normal file
View file

@ -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
}

View file

@ -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.

View file

@ -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 {

View file

@ -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{}
}

View file

@ -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.

View file

@ -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_idstore 边界按小端转 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
}

View file

@ -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):

View file

@ -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)

View file

@ -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 {

View file

@ -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.

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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
}