diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e19c1c..c1361324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ - Silenced the superuser IPs confirmation if there is no change. -- Updated UI extensions APIs to allow top-level `await` in the initialization script. +- Updated the _experimental_ UI extensions APIs to allow top-level `await` in the initialization script. + +- Force unset the auth state of existing realtime connections on user password, collection secret, etc. changes. + _This is not strictly necessery because the realtime connections has short-lived idle timeout by design but nonetheless it was implemented to minimize the abuse vectors._ ## v0.38.0 diff --git a/apis/realtime.go b/apis/realtime.go index 72b830bf..85a45eff 100644 --- a/apis/realtime.go +++ b/apis/realtime.go @@ -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()), diff --git a/apis/realtime_test.go b/apis/realtime_test.go index 781ea18d..305d3c1f 100644 --- a/apis/realtime_test.go +++ b/apis/realtime_test.go @@ -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)