force unset realtime connections auth state

This commit is contained in:
Gani Georgiev
2026-05-13 22:34:50 +03:00
parent a8c236a54d
commit 3b98059a8a
3 changed files with 283 additions and 12 deletions
+86 -8
View File
@@ -219,8 +219,13 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
})
}
// updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord.
//
// Realtime connections has short lifetime by design, but to minimize abuse
// if the new record has a different tokenKey (e.g. in case of password reset)
// the auth state of the related realtime connections is also cleared
// (aka. they remain active but unauthenticated, allowing to reauthenicate with the next subscription).
func realtimeUpdateClientsAuth(app core.App, authRecord *core.Record) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
@@ -230,9 +235,13 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil &&
clientAuth.Id == newAuthRecord.Id &&
clientAuth.Collection().Name == newAuthRecord.Collection().Name {
client.Set(RealtimeClientAuthKey, newAuthRecord)
clientAuth.Id == authRecord.Id &&
clientAuth.Collection().Name == authRecord.Collection().Name {
if clientAuth.TokenKey() != authRecord.TokenKey() {
client.Unset(RealtimeClientAuthKey)
} else {
client.Set(RealtimeClientAuthKey, authRecord)
}
}
}
@@ -243,8 +252,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
return group.Wait()
}
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
@@ -267,7 +276,76 @@ func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
return group.Wait()
}
// realtimeUnsetClientsAuthByCollection unsets the auth state of all authenticated clients related to the collection.
func realtimeUnsetClientsAuthByCollection(app core.App, collection *core.Collection) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
for _, chunk := range chunks {
group.Go(func() error {
for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && clientAuth.Collection().Name == collection.Name {
client.Unset(RealtimeClientAuthKey)
}
}
return nil
})
}
return group.Wait()
}
func bindRealtimeEvents(app core.App) {
// reset the clients auth on collection secret change
// (@todo with the future tracking of old collections data consider replacing with *AfterUpdateSuccess to account for transaction rollback)
app.OnCollectionUpdate().Bind(&hook.Handler[*core.CollectionEvent]{
Func: func(e *core.CollectionEvent) error {
if !e.Collection.IsAuth() {
return e.Next()
}
cached, _ := e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
if err := e.Next(); err != nil {
return err
}
if cached != nil && cached.AuthToken.Secret != e.Collection.AuthToken.Secret {
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the changed auth collection",
slog.String("collectionName", e.Collection.Name),
slog.String("error", err.Error()),
)
}
}
return nil
},
Priority: -99,
})
// unset the clients auth on auth collection delete
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
Func: func(e *core.CollectionEvent) error {
if e.Collection.IsAuth() {
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth collection",
slog.String("collectionName", e.Collection.Name),
slog.String("error", err.Error()),
)
}
}
return e.Next()
},
Priority: -99,
})
// update the clients that has auth record association
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
@@ -294,7 +372,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error {
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil && collection.IsAuth() {
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
if err := realtimeUnsetClientsAuthByRecordModelOrProxy(e.App, e.Model); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth model",
slog.Any("id", e.Model.PK()),
+193 -3
View File
@@ -460,7 +460,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
e.Context = context.Background()
e.Model = authRecord1
testApp.OnModelAfterDeleteSuccess().Trigger(e)
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
@@ -502,14 +505,17 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
}
authRecord2.SetEmail("new@example.com")
// mock update event
// mock update event without actually saving to avoid triggering the tokenKey change
e := new(core.ModelEvent)
e.App = testApp
e.Type = core.ModelEventTypeUpdate
e.Context = context.Background()
e.Model = authRecord2
testApp.OnModelAfterUpdateSuccess().Trigger(e)
err = testApp.OnModelAfterUpdateSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
@@ -517,6 +523,190 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
}
}
func TestRealtimeAuthRecordUnsetOnTokenKeyRefresh(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord and refresh its tokenKey
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
authRecord2.RefreshTokenKey()
err = testApp.Save(authRecord2)
if err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if clientAuthRecord != nil {
t.Fatalf("Expected authRecord to be unset, got %q", clientAuthRecord.Email())
}
}
func TestRealtimeAuthRecordUnsetOnCollectionSecretChange(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
usersCollection, err := testApp.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
clientMocks := map[*core.Record]subscriptions.Client{
authRecord1: client1,
authRecord2: client2,
authRecord3: client3,
}
for _, client := range clientMocks {
testApp.SubscriptionsBroker().Register(client)
}
// change the secret of the users collection (should trigger unset)
usersCollection.AuthToken.Secret = strings.Repeat("a", 30)
err = testApp.Save(usersCollection)
if err != nil {
t.Fatal(err)
}
// change something else of the clients collection (shouldn't trigger unset)
clientsCollection.ListRule = nil
err = testApp.Save(clientsCollection)
if err != nil {
t.Fatal(err)
}
expectations := map[*core.Record]bool{
// record -> unset
authRecord1: true,
authRecord2: true,
authRecord3: false,
}
for record, expectedUnset := range expectations {
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
unset := clientAuthRecord == nil
if unset != expectedUnset {
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
}
}
}
func TestRealtimeAuthRecordUnsetOnCollectionDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
usersCollection, err := testApp.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
clientMocks := map[*core.Record]subscriptions.Client{
authRecord1: client1,
authRecord2: client2,
authRecord3: client3,
}
for _, client := range clientMocks {
testApp.SubscriptionsBroker().Register(client)
}
// mock users collection delete event to avoid triggering constraints check
e := new(core.ModelEvent)
e.App = testApp
e.Type = core.ModelEventTypeDelete
e.Context = context.Background()
e.Model = usersCollection
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
expectations := map[*core.Record]bool{
// record -> unset
authRecord1: true,
authRecord2: true,
authRecord3: false,
}
for record, expectedUnset := range expectations {
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
unset := clientAuthRecord == nil
if unset != expectedUnset {
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
}
}
}
// Custom auth record model struct
// -------------------------------------------------------------------
var _ core.Model = (*CustomUser)(nil)