auth: complete account authorization flows
(cherry picked from commit 04f4527df32ad5c35720cccc41d27fe51549612f)
This commit is contained in:
parent
af41d18478
commit
6dc42942c8
21 changed files with 1780 additions and 50 deletions
|
|
@ -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.
|
||||
|
|
|
|||
185
internal/app/account/service_test.go
Normal file
185
internal/app/account/service_test.go
Normal 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
204
internal/app/account/srp.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue