fix: sync temp auth key expiry boundaries

This commit is contained in:
A 2026-07-13 23:04:15 +08:00
parent 305e8a0008
commit 20a310f6ca
50 changed files with 3626 additions and 335 deletions

View file

@ -77,7 +77,12 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
r.tempKeyResolveCache.Delete(id)
}
if r.deps.Sessions != nil {
r.deps.Sessions.BindAuthKeyForSession(id, sessionID, authKeyIDFromInt64(req.PermAuthKeyID))
permID := authKeyIDFromInt64(req.PermAuthKeyID)
if all, ok := r.deps.Sessions.(RawAuthKeySessionBinder); ok {
all.BindAuthKeyForRawAuthKey(id, permID)
} else {
r.deps.Sessions.BindAuthKeyForSession(id, sessionID, permID)
}
}
r.invalidateAuthUserCache(id)
return true, nil

View file

@ -65,6 +65,20 @@ type SessionBinder interface {
PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error)
}
// RawAuthKeySessionBinder 在 auth.bindTempAuthKey 成功后,把同一 raw temporary key
// 已建立的所有 session 一次性切到 canonical permanent identity。只更新当前 session
// 会让并发启动的其它连接永久粘在 raw identity。
type RawAuthKeySessionBinder interface {
BindAuthKeyForRawAuthKey(rawAuthKeyID [8]byte, authKeyID [8]byte) int
}
// RawAuthKeyMetadataProvider 暴露握手时确定的 raw key protocol expiry。0 表示
// permanent key正值表示 temporary key。Router 只用它判断 cached==raw 是否可作为
// permanent 快路径,协议过期的实际拒绝仍由 mtprotoedge 完成。
type RawAuthKeyMetadataProvider interface {
AuthKeyExpiresAtForSession(rawAuthKeyID [8]byte, sessionID int64) (expiresAt int, found bool)
}
// ImmediateSessionPusher 是可选的登录前信号直推能力。
// 它绕过登录后 updates-ready 队列,只能用于会解锁登录流程本身的握手消息,
// 例如 updateLoginToken。

View file

@ -399,6 +399,8 @@ func signInErr(err error) error {
return tgerr.New(400, "PHONE_CODE_INVALID")
case errors.Is(err, auth.ErrCodeExpired):
return tgerr.New(400, "PHONE_CODE_EXPIRED")
case errors.Is(err, auth.ErrAuthKeyPermEmpty):
return tgerr.New(401, "AUTH_KEY_PERM_EMPTY")
case errors.Is(err, domain.ErrFirstNameInvalid):
return firstNameInvalidErr()
case errors.Is(err, domain.ErrSessionPasswordNeeded):
@ -455,8 +457,14 @@ func passwordErr(err error) error {
// bindTempAuthKeyErr 映射 PFS temp auth key 绑定错误。
func bindTempAuthKeyErr(err error) error {
switch {
case errors.Is(err, auth.ErrExpiresAtInvalid):
return tgerr.New(400, "EXPIRES_AT_INVALID")
case errors.Is(err, auth.ErrTempAuthKeyEmpty):
return tgerr.New(400, "TEMP_AUTH_KEY_EMPTY")
case errors.Is(err, auth.ErrEncryptedMessageInvalid):
return tgerr.New(400, "ENCRYPTED_MESSAGE_INVALID")
case errors.Is(err, auth.ErrTempAuthKeyAlreadyBound):
return tgerr.New(400, "TEMP_AUTH_KEY_ALREADY_BOUND")
default:
return internalErr()
}

View file

@ -5,6 +5,7 @@ import (
"github.com/gotd/td/tgerr"
"telesrv/internal/app/auth"
"telesrv/internal/domain"
)
@ -13,3 +14,22 @@ func TestPasswordErrMapsOccupiedLoginEmailToNotAllowed(t *testing.T) {
t.Fatalf("passwordErr(ErrEmailOccupied) = %v, want EMAIL_NOT_ALLOWED", err)
}
}
func TestBindTempAuthKeyErrPreservesRecoverableRotationErrors(t *testing.T) {
tests := []struct {
err error
want string
}{
{err: auth.ErrExpiresAtInvalid, want: "EXPIRES_AT_INVALID"},
{err: auth.ErrTempAuthKeyEmpty, want: "TEMP_AUTH_KEY_EMPTY"},
{err: auth.ErrEncryptedMessageInvalid, want: "ENCRYPTED_MESSAGE_INVALID"},
{err: auth.ErrTempAuthKeyAlreadyBound, want: "TEMP_AUTH_KEY_ALREADY_BOUND"},
}
for _, test := range tests {
t.Run(test.want, func(t *testing.T) {
if err := bindTempAuthKeyErr(test.err); !tgerr.Is(err, test.want) {
t.Fatalf("bindTempAuthKeyErr(%v) = %v, want %s", test.err, err, test.want)
}
})
}
}

View file

@ -324,9 +324,25 @@ func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, s
}
}
if hasCached {
if cached == rawAuthKeyID || r.deps.Auth == nil {
if r.deps.Auth == nil {
return cached, nil
}
if cached == rawAuthKeyID {
// raw==business is a permanent-key fast path only when the edge confirms
// the raw key has no protocol expiry. A temporary session may have cached
// raw before another concurrent session completes auth.bindTempAuthKey;
// it must keep resolving until the durable binding becomes visible.
metadata, ok := r.deps.Sessions.(RawAuthKeyMetadataProvider)
if ok {
expiresAt, found := metadata.AuthKeyExpiresAtForSession(rawAuthKeyID, sessionID)
if found && expiresAt == 0 {
return cached, nil
}
}
// Missing metadata and a session lookup miss both fail closed to the
// durable resolver. Treating either as proof of permanence recreates the
// raw-temp identity split when an alternate SessionBinder is installed.
}
// temp→perm 解析缓存PFS 连接每帧都要解析一次 temp keyResolveAuthKey 打 PG。TTL 内复用
// 上次解析、跳过 DB。仅当缓存的 perm 仍等于 session binder 当前 perm 才用rebind 会改 binder
// 且 onAuthBindTempAuthKey / 授权撤销都会显式 Delete 缓存,双保险防跨账号串号和被踢滞后)。

View file

@ -15,6 +15,7 @@ import (
appauth "telesrv/internal/app/auth"
appdialogs "telesrv/internal/app/dialogs"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
"testing"
"time"
@ -913,17 +914,25 @@ func TestDispatchUnknownReturnsError(t *testing.T) {
func TestDispatchResolvesBoundTempAuthKey(t *testing.T) {
var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55}
var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}
tempBindings := memory.NewTempAuthKeyBindingStore()
expiresAt := int(time.Now().Add(time.Hour).Unix())
authKeys := memory.NewAuthKeyStore()
if err := authKeys.Save(context.Background(), store.AuthKeyData{ID: tempAuthKeyID, ExpiresAt: expiresAt}); err != nil {
t.Fatalf("save temporary auth key: %v", err)
}
if err := authKeys.Save(context.Background(), store.AuthKeyData{ID: permAuthKeyID}); err != nil {
t.Fatalf("save permanent auth key: %v", err)
}
tempBindings := memory.NewTempAuthKeyBindingStore(authKeys)
if err := tempBindings.Save(context.Background(), domain.TempAuthKeyBinding{
TempAuthKeyID: tempAuthKeyID,
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])),
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
sessions := &captureSessions{}
r := New(Config{}, Deps{
Auth: appauth.NewService(nil, nil, nil, nil, tempBindings, "12345"),
Auth: appauth.NewService(nil, nil, nil, authKeys, tempBindings, "12345"),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
req := &tg.HelpGetConfigRequest{}

View file

@ -17,6 +17,19 @@ type revokeCaptureSessions struct {
closedRawAuthKeyIDs [][8]byte
}
type expiringCaptureSessions struct {
*captureSessions
expiresAt int
}
type metadataBlindSessions struct {
SessionBinder
}
func (s *expiringCaptureSessions) AuthKeyExpiresAtForSession([8]byte, int64) (int, bool) {
return s.expiresAt, true
}
func (s *revokeCaptureSessions) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int {
s.mu.Lock()
defer s.mu.Unlock()
@ -31,6 +44,76 @@ func (s *revokeCaptureSessions) CloseSessionsForRawAuthKeyExcept(authKeyID [8]by
return 1
}
func TestCachedRawTemporarySessionReResolvesDurableBinding(t *testing.T) {
tempAuthKeyID := [8]byte{0x76, 0x76, 0x76, 0x76, 0x76, 0x76, 0x76, 0x76}
permAuthKeyID := [8]byte{0x36, 0x36, 0x36, 0x36, 0x36, 0x36, 0x36, 0x36}
base := &captureSessions{}
base.BindAuthKeyForSession(tempAuthKeyID, 554, tempAuthKeyID)
sessions := &expiringCaptureSessions{
captureSessions: base,
expiresAt: int(time.Now().Add(time.Hour).Unix()),
}
auth := &captureAuthService{
resolvedAuthKeyID: permAuthKeyID,
hasResolved: true,
userID: 1000000001,
}
r := New(Config{TempKeyResolveCacheTTL: time.Minute}, Deps{
Auth: auth,
Files: &fakeFiles{},
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
if err := (&tg.UploadSaveFilePartRequest{FileID: 19, FilePart: 0, Bytes: []byte{1}}).Encode(&in); err != nil {
t.Fatalf("encode: %v", err)
}
if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 554, &in); err != nil {
t.Fatalf("dispatch: %v", err)
}
if auth.resolveCount != 1 {
t.Fatalf("ResolveAuthKey calls = %d, want 1 for cached raw temporary session", auth.resolveCount)
}
got := sessions.snapshot()
if got.authKeyID != permAuthKeyID || got.userID != 1000000001 {
t.Fatalf("session = auth %x user %d, want perm/user", got.authKeyID, got.userID)
}
}
func TestCachedRawSessionWithoutMetadataFailsClosedToDurableResolver(t *testing.T) {
tempAuthKeyID := [8]byte{0x75, 0x75, 0x75, 0x75, 0x75, 0x75, 0x75, 0x75}
permAuthKeyID := [8]byte{0x35, 0x35, 0x35, 0x35, 0x35, 0x35, 0x35, 0x35}
// captureSessions intentionally has no RawAuthKeyMetadataProvider capability.
// Missing metadata is not evidence that raw is permanent.
base := &captureSessions{}
base.BindAuthKeyForSession(tempAuthKeyID, 553, tempAuthKeyID)
sessions := &metadataBlindSessions{SessionBinder: base}
auth := &captureAuthService{
resolvedAuthKeyID: permAuthKeyID,
hasResolved: true,
userID: 1000000001,
}
r := New(Config{TempKeyResolveCacheTTL: time.Minute}, Deps{
Auth: auth,
Files: &fakeFiles{},
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
if err := (&tg.UploadSaveFilePartRequest{FileID: 18, FilePart: 0, Bytes: []byte{1}}).Encode(&in); err != nil {
t.Fatalf("encode: %v", err)
}
if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 553, &in); err != nil {
t.Fatalf("dispatch: %v", err)
}
if auth.resolveCount != 1 {
t.Fatalf("ResolveAuthKey calls = %d, want 1 without metadata proof", auth.resolveCount)
}
if got := base.snapshot(); got.authKeyID != permAuthKeyID || got.userID != 1000000001 {
t.Fatalf("session = auth %x user %d, want canonical perm/user", got.authKeyID, got.userID)
}
}
// TestTempKeyResolveCacheHitsWithinTTL 验证TempKeyResolveCacheTTL>0 时,同一 temp key 的连续
// 请求在 TTL 内只解析一次(首帧走 !hasCached 解析 1 次、次帧 hasCached 解析并填缓存 1 次,之后命中
// 缓存不再打 ResolveAuthKey。固化「缓存生效」语义与现有「TTL=0 每帧重校验」的安全测试互补。

View file

@ -101,6 +101,12 @@ func (s *captureSessions) AuthKeyIDForSession([8]byte, int64) ([8]byte, bool) {
return s.authKeyID, s.authKeyResolved
}
// captureSessions models an ordinary permanent-key connection unless a test
// wraps/overrides it with temporary-key metadata.
func (s *captureSessions) AuthKeyExpiresAtForSession([8]byte, int64) (int, bool) {
return 0, true
}
func (s *captureSessions) BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) {
s.mu.Lock()
defer s.mu.Unlock()