internal/store/postgres/authorization.go
Rama main · réplica de lectura del repositorio original.
package postgres
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AuthorizationStore implements store.AuthorizationStore with PostgreSQL.
type AuthorizationStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewAuthorizationStore creates an AuthorizationStore backed by a pgx pool (or transaction).
func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore {
return &AuthorizationStore{db: db, q: sqlcgen.New(db)}
}
func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error {
if a.Hash == 0 {
a.Hash = authorizationHash(a.AuthKeyID)
}
err := withAuthIdentityTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
return bindAuthorization(ctx, tx, a)
})
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
return nil
}
// bindAuthorization commits the auth_key→user binding and the device update baseline as one state boundary.
//
// The lock order is fixed: target user advisory/row → auth_keys parent row →
// user_update_watermarks → user_update_retention → target update_states. The order of the watermark
// and retention row locks matches pruneConfirmedUserPrefixTx, so the new authorization's
// observed baseline and retained floor cannot cross-commit into a silent hole. The parent-row
// lock also serializes concurrent logins/account switches on the same raw auth key while the
// first authorization does not yet exist. The user lock uses the same order as the account
// tombstone; therefore Bind either commits first and is undone by a subsequent delete
// transaction, or waits for the delete to commit and then sees the tombstone and rejects it. It
// cannot rebind the account after the delete transaction has enumerated authorizations.
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
keyID := authKeyIDToInt64(a.AuthKeyID)
tx, ok := db.(pgx.Tx)
if !ok {
return fmt.Errorf("bind authorization requires a transaction")
}
if err := lockUsersForUpdate(ctx, tx, a.UserID); err != nil {
return fmt.Errorf("lock authorization user: %w", err)
}
var active bool
if err := db.QueryRow(ctx, `
SELECT deleted_at IS NULL
FROM users
WHERE id = $1
FOR UPDATE`, a.UserID).Scan(&active); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrUserNotFound
}
return fmt.Errorf("lock authorization user row: %w", err)
}
if !active {
return domain.ErrAccountDeleted
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{keyID}); err != nil {
return err
}
var (
lockedKeyID int64
expiresAt int
authLayer int
layerObservationID int64
)
if err := db.QueryRow(ctx, `
SELECT auth_key_id, expires_at, layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, keyID).Scan(&lockedKeyID, &expiresAt, &authLayer, &layerObservationID); err != nil {
return fmt.Errorf("lock auth key for authorization: %w", err)
}
if expiresAt != 0 {
return store.ErrAuthKeyNotPermanent
}
if authLayer < 0 || layerObservationID < 0 || (layerObservationID > 0 && authLayer == 0) {
return fmt.Errorf(
"authorization auth-key layer invariant violation: auth key %x has layer %d observation %d",
a.AuthKeyID, authLayer, layerObservationID,
)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES ($1, 0)
ON CONFLICT (user_id) DO NOTHING`, a.UserID); err != nil {
return fmt.Errorf("ensure authorization user update watermark: %w", err)
}
var currentPts int
if err := db.QueryRow(ctx, `
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE`, a.UserID).Scan(¤tPts); err != nil {
return fmt.Errorf("lock authorization user update watermark: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_retention (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING`, a.UserID); err != nil {
return fmt.Errorf("ensure authorization user update retention: %w", err)
}
var retainedFloor int
if err := db.QueryRow(ctx, `
SELECT retained_through_pts
FROM user_update_retention
WHERE user_id = $1
FOR UPDATE`, a.UserID).Scan(&retainedFloor); err != nil {
return fmt.Errorf("lock authorization user update retention: %w", err)
}
if retainedFloor > currentPts {
return fmt.Errorf(
"authorization update baseline invariant violation: user %d retained floor %d exceeds contiguous watermark %d",
a.UserID, retainedFloor, currentPts,
)
}
// Every Bind is an explicit login baseline: delivered pts advance to the locked account's contiguous watermark;
// observed advances only to the deleted retained floor, not disguising the live tail as client acknowledgement.
// A legacy state exceeding the account's contiguous watermark must fail fast; GREATEST must not
// be used to preserve an illegal future cursor. The WHERE also closes the "concurrent insert after pre-check" race.
tag, err := db.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, $3, 0, EXTRACT(EPOCH FROM now())::int, 0, $4)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = GREATEST(update_states.pts, EXCLUDED.pts),
qts = GREATEST(update_states.qts, EXCLUDED.qts),
date = GREATEST(update_states.date, EXCLUDED.date),
seq = GREATEST(update_states.seq, EXCLUDED.seq),
observed_pts = GREATEST(update_states.observed_pts, EXCLUDED.observed_pts),
updated_at = now()
WHERE update_states.pts >= 0
AND update_states.pts <= $3
AND update_states.observed_pts <= $3`, keyID, a.UserID, currentPts, retainedFloor)
if err != nil {
return fmt.Errorf("upsert authorization update baseline: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf(
"authorization update baseline invariant violation: auth key %x user %d has pts or observed_pts outside contiguous watermark %d",
a.AuthKeyID, a.UserID, currentPts,
)
}
if _, err := db.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id <> $2`, keyID, a.UserID); err != nil {
return fmt.Errorf("delete stale cross-user update states: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
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,
password_pending = EXCLUDED.password_pending,
created_at = now(),
active_at = now()`,
keyID, a.UserID, a.Hash, int32(authLayer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
); err != nil {
return fmt.Errorf("write authorization: %w", err)
}
return nil
}
func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations WHERE auth_key_id = $1`, authKeyIDToInt64(id))
a := domain.Authorization{AuthKeyID: id}
if err := row.Scan(
&a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err)
}
return a, true, nil
}
func (s *AuthorizationStore) UpdateClientInfo(ctx context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET
layer = CASE WHEN $2 > 0 THEN $2 ELSE layer END,
device_model = CASE WHEN $3 <> '' THEN $3 ELSE device_model END,
platform = CASE WHEN $4 <> '' THEN $4 ELSE platform END,
system_version = CASE WHEN $5 <> '' THEN $5 ELSE system_version END,
api_id = CASE WHEN $6 <> 0 THEN $6 ELSE api_id END,
app_version = CASE WHEN $7 <> '' THEN $7 ELSE app_version END,
ip = CASE WHEN $8 <> '' THEN $8 ELSE ip END,
active_at = now()
WHERE auth_key_id = $1`,
authKeyIDToInt64(id), int32(info.Layer), info.DeviceModel, info.Platform,
info.SystemVersion, int32(info.APIID), info.AppVersion, info.IP,
); err != nil {
return fmt.Errorf("update authorization client info: %w", err)
}
return nil
}
// MarkPasswordPassed atomically promotes only the pending identity whose
// password was just verified. A concurrent cross-user Bind must not let A's
// proof clear B's password_pending flag.
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte, expectedUserID int64) error {
tag, err := s.db.Exec(ctx, `
UPDATE authorizations SET password_pending = false, created_at = now(), active_at = now()
WHERE auth_key_id = $1 AND user_id = $2 AND password_pending`, authKeyIDToInt64(id), expectedUserID)
if err != nil {
return fmt.Errorf("mark authorization password passed: %w", err)
}
if tag.RowsAffected() != 1 {
return store.ErrAuthorizationStateChanged
}
return nil
}
func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) {
rows, err := s.q.ListAuthorizationsByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations by user: %w", err)
}
out := make([]domain.Authorization, 0, len(rows))
for _, row := range rows {
out = append(out, authorizationFromRow(row))
}
return out, nil
}
func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error {
if err := s.q.DeleteAuthorization(ctx, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("delete authorization: %w", err)
}
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
}
// RevokeByHash is the persistence entry point for remote device kick: it deletes the business authorization and
// the device update state, but keeps the permanent/temp protocol key and binding. This way a kicked client
// can still complete MTProto decryption after reconnecting and gets AUTH_KEY_UNREGISTERED from the RPC gate; if
// the protocol key were deleted first, the client would only get a transport -404 and could not reliably clear its local login state.
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
var (
a domain.Authorization
found bool
)
err := withAuthIdentityTx(ctx, s.db, "revoke authorization by hash", func(tx pgx.Tx) error {
var err error
a, found, err = revokeByHashTx(ctx, tx, userID, hash)
return err
})
if err != nil {
return domain.Authorization{}, false, err
}
return a, found, nil
}
// revokeByHashTx deliberately uses separate READ COMMITTED statements. The first
// lookup is only a candidate. Bind locks auth_keys before changing authorization
// ownership, so revocation must lock the same parent row and then re-read the
// owner/hash from a fresh statement snapshot. Otherwise an A->B re-login that
// commits while revoke waits can be deleted using A's stale target snapshot.
func revokeByHashTx(ctx context.Context, tx pgx.Tx, userID, hash int64) (domain.Authorization, bool, error) {
var candidate int64
if err := tx.QueryRow(ctx, `
SELECT auth_key_id
FROM authorizations
WHERE user_id = $1 AND hash = $2`, userID, hash).Scan(&candidate); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("select revoke candidate by hash: %w", err)
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
var locked int64
if err := tx.QueryRow(ctx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, candidate).Scan(&locked); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("lock revoke auth key by hash: %w", err)
}
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE auth_key_id = $1 AND user_id = $2 AND hash = $3
FOR UPDATE`, candidate, userID, hash))
if err != nil {
return domain.Authorization{}, false, fmt.Errorf("revalidate revoke authorization by hash: %w", err)
}
if !found {
return domain.Authorization{}, false, nil
}
if err := deleteRevokedAuthorizationStateTx(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
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
}
// RevokeByUserExcept bulk-deletes business authorizations, keeping the current device for keepAuthKeyID;
// the revoked devices' protocol key/binding is kept so they get an RPC 401 on reconnect and complete the client logout.
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
var out []domain.Authorization
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
var err error
out, err = revokeByUserExceptTx(ctx, tx, userID, authKeyIDToInt64(keepAuthKeyID))
return err
})
return out, err
}
func revokeByUserExceptTx(ctx context.Context, tx pgx.Tx, userID, keepAuthKeyID int64) ([]domain.Authorization, error) {
candidateRows, err := tx.Query(ctx, `
SELECT auth_key_id
FROM authorizations
WHERE user_id = $1 AND auth_key_id <> $2
ORDER BY auth_key_id`, userID, keepAuthKeyID)
if err != nil {
return nil, fmt.Errorf("select revoke candidates by user: %w", err)
}
candidates := make([]int64, 0)
for candidateRows.Next() {
var id int64
if err := candidateRows.Scan(&id); err != nil {
candidateRows.Close()
return nil, fmt.Errorf("scan revoke candidate by user: %w", err)
}
candidates = append(candidates, id)
}
if err := candidateRows.Err(); err != nil {
candidateRows.Close()
return nil, fmt.Errorf("iterate revoke candidates by user: %w", err)
}
candidateRows.Close()
if len(candidates) == 0 {
return []domain.Authorization{}, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, candidates); err != nil {
return nil, err
}
// Advisory keys are already all held in final int32-hash order. Parent rows
// are then locked by their real bigint IDs for deterministic batch behavior.
lockRows, err := tx.Query(ctx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])
ORDER BY auth_key_id
FOR UPDATE`, candidates)
if err != nil {
return nil, fmt.Errorf("lock revoke auth keys by user: %w", err)
}
for lockRows.Next() {
var ignored int64
if err := lockRows.Scan(&ignored); err != nil {
lockRows.Close()
return nil, fmt.Errorf("scan locked revoke auth key: %w", err)
}
}
if err := lockRows.Err(); err != nil {
lockRows.Close()
return nil, fmt.Errorf("iterate locked revoke auth keys: %w", err)
}
lockRows.Close()
// This is intentionally a new statement snapshot after all parent locks.
// Keys that changed owner while waiting are omitted and must remain intact.
rows, err := tx.Query(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1
AND auth_key_id <> $2
AND auth_key_id = ANY($3::bigint[])
ORDER BY created_at, auth_key_id
FOR UPDATE`, userID, keepAuthKeyID, candidates)
if err != nil {
return nil, fmt.Errorf("revalidate revoke authorizations by user: %w", err)
}
out := make([]domain.Authorization, 0)
for rows.Next() {
a, err := scanRevokedAuthorizationRow(rows)
if err != nil {
rows.Close()
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate revoked authorizations: %w", err)
}
rows.Close()
if len(out) == 0 {
return out, nil
}
targets := make([]int64, len(out))
for i := range out {
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
}
if err := deleteRevokedAuthorizationStateTx(ctx, tx, targets); err != nil {
return nil, err
}
return out, nil
}
func deleteRevokedAuthorizationStateTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM authorizations
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked authorizations: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked authorizations: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}
// deleteProtocolAuthIdentitiesTx permanently removes permanent identities and
// their derived temp keys. Remote account.resetAuthorization/
// auth.resetAuthorizations must not use this helper: those clients need the
// protocol key long enough to reconnect and receive an RPC-level 401.
func deleteProtocolAuthIdentitiesTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id IN (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = ANY($1::bigint[])
)`, authKeyIDs); err != nil {
return fmt.Errorf("delete temporary auth keys: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete protocol identity update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete permanent auth keys: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return 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,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
CreatedAt: row.CreatedAt.Time,
ActiveAt: row.ActiveAt.Time,
}
}
type authorizationScanner interface {
Scan(dest ...any) error
}
func scanRevokedAuthorization(row authorizationScanner) (domain.Authorization, bool, error) {
a, err := scanRevokedAuthorizationRow(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, err
}
return a, true, nil
}
func scanRevokedAuthorizationRow(row authorizationScanner) (domain.Authorization, error) {
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.PasswordPending, &a.CreatedAt, &a.ActiveAt,
); err != nil {
return domain.Authorization{}, err
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
return a, nil
}
func authorizationHash(authKeyID [8]byte) int64 {
sum := sha256.Sum256(authKeyID[:])
hash := int64(binary.LittleEndian.Uint64(sum[:8]))
if hash == 0 {
return 1
}
return hash
}