fix: sync harden auth, privacy, and PTS state
This commit is contained in:
parent
c1597696af
commit
2512eab51d
24 changed files with 817 additions and 154 deletions
|
|
@ -6,7 +6,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -36,18 +35,17 @@ SELECT
|
|||
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, login_email
|
||||
recovery_email, login_email
|
||||
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, &settings.LoginEmail,
|
||||
&settings.RecoveryEmail, &settings.LoginEmail,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PasswordSettings{}, false, nil
|
||||
|
|
@ -65,9 +63,6 @@ WHERE user_id = $1`, userID)
|
|||
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...)
|
||||
|
|
@ -102,19 +97,15 @@ func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.
|
|||
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, login_email
|
||||
recovery_email, login_email
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
has_recovery = EXCLUDED.has_recovery,
|
||||
has_secure_values = EXCLUDED.has_secure_values,
|
||||
|
|
@ -132,8 +123,6 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
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,
|
||||
login_email = EXCLUDED.login_email,
|
||||
updated_at = now()`,
|
||||
userID,
|
||||
|
|
@ -141,7 +130,7 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom),
|
||||
nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P),
|
||||
settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB),
|
||||
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail,
|
||||
settings.RecoveryEmail, settings.LoginEmail,
|
||||
)
|
||||
if err != nil {
|
||||
if isAccountPasswordLoginEmailUnique(err) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,88 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestReserveUserPtsRejectsZeroBeforeQuery(t *testing.T) {
|
||||
if _, err := reserveUserPts(context.Background(), nil, 0, 1); err == nil {
|
||||
t.Fatal("reserveUserPts user=0 succeeded, want fail-fast before DB access")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendAllocatedFirstPtsRangeAndRollback covers both branches of the
|
||||
// single-statement watermark upsert through a legal durable update. A rolled
|
||||
// back first allocation must leave no watermark; the committed retry must
|
||||
// create one range ending at pts=3 with pts_count=3.
|
||||
func TestAppendAllocatedFirstPtsRangeAndRollback(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
owner, err := NewUserStore(pool).Create(ctx, domain.User{
|
||||
AccessHash: 4,
|
||||
Phone: "+1555" + suffix + "01",
|
||||
FirstName: "FirstPtsRange",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
|
||||
event := domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDeleteMessages,
|
||||
PtsCount: 3,
|
||||
Date: 1700000003,
|
||||
MessageIDs: []int{101, 102, 103},
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin rollback allocation: %v", err)
|
||||
}
|
||||
allocated, err := NewUpdateEventStore(tx).AppendAllocated(ctx, owner.ID, event)
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("append allocated before rollback: %v", err)
|
||||
}
|
||||
if allocated.Pts != 3 || allocated.PtsCount != 3 {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("allocated before rollback = pts %d count %d, want 3/3", allocated.Pts, allocated.PtsCount)
|
||||
}
|
||||
if err := tx.Rollback(ctx); err != nil {
|
||||
t.Fatalf("rollback first allocation: %v", err)
|
||||
}
|
||||
|
||||
var rows int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM user_update_watermarks WHERE user_id=$1`, owner.ID).Scan(&rows); err != nil {
|
||||
t.Fatalf("count watermark after rollback: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Fatalf("watermark rows after rollback = %d, want 0", rows)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM user_update_events WHERE user_id=$1`, owner.ID).Scan(&rows); err != nil {
|
||||
t.Fatalf("count events after rollback: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Fatalf("event rows after rollback = %d, want 0", rows)
|
||||
}
|
||||
|
||||
allocated, err = NewUpdateEventStore(pool).AppendAllocated(ctx, owner.ID, event)
|
||||
if err != nil {
|
||||
t.Fatalf("append allocated after rollback: %v", err)
|
||||
}
|
||||
if allocated.Pts != 3 || allocated.PtsCount != 3 {
|
||||
t.Fatalf("committed allocation = pts %d count %d, want 3/3", allocated.Pts, allocated.PtsCount)
|
||||
}
|
||||
if pts, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, owner.ID); err != nil || pts != 3 {
|
||||
t.Fatalf("MaxContiguousPts = %d err=%v, want 3", pts, err)
|
||||
}
|
||||
events, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAfter: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Pts != 3 || events[0].PtsCount != 3 || len(events[0].MessageIDs) != 3 {
|
||||
t.Fatalf("events = %+v, want one delete range ending at 3", events)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendRejectsPtsHole 用真实 PG 验证显式 pts 写入不能制造空洞。
|
||||
func TestAppendRejectsPtsHole(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,16 @@ func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) {
|
|||
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
|
||||
})
|
||||
var initialWatermarks int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int
|
||||
FROM user_update_watermarks
|
||||
WHERE user_id = ANY($1::bigint[])`, ids).Scan(&initialWatermarks); err != nil {
|
||||
t.Fatalf("count initial watermarks: %v", err)
|
||||
}
|
||||
if initialWatermarks != 0 {
|
||||
t.Fatalf("initial watermarks = %d, want 0 so concurrency covers first upsert", initialWatermarks)
|
||||
}
|
||||
|
||||
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}))
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ type AccountPassword struct {
|
|||
SrpBSecret []byte
|
||||
SrpB []byte
|
||||
RecoveryEmail string
|
||||
RecoveryCode string
|
||||
RecoveryCodeExpiresAt pgtype.Timestamptz
|
||||
LoginEmail string
|
||||
PasswordChangedAt pgtype.Timestamptz
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,15 +30,16 @@ ON CONFLICT (user_id) DO NOTHING`, userID)
|
|||
|
||||
func reserveUserPts(ctx context.Context, db sqlcgen.DBTX, userID int64, count int) (int, error) {
|
||||
count = normalizePtsCount(count)
|
||||
if err := ensureUserUpdateWatermark(ctx, db, userID); err != nil {
|
||||
return 0, err
|
||||
if userID == 0 {
|
||||
return 0, fmt.Errorf("user pts: missing user id")
|
||||
}
|
||||
var pts int
|
||||
if err := db.QueryRow(ctx, `
|
||||
UPDATE user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + $2,
|
||||
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET contiguous_pts = user_update_watermarks.contiguous_pts + EXCLUDED.contiguous_pts,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1
|
||||
RETURNING contiguous_pts`, userID, count).Scan(&pts); err != nil {
|
||||
return 0, fmt.Errorf("reserve user pts: %w", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue