force unset realtime connections auth state
This commit is contained in:
+4
-1
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
- Silenced the superuser IPs confirmation if there is no change.
|
- 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
|
## v0.38.0
|
||||||
|
|||||||
+86
-8
@@ -219,8 +219,13 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
|
// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord.
|
||||||
func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
//
|
||||||
|
// 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)
|
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||||
|
|
||||||
group := new(errgroup.Group)
|
group := new(errgroup.Group)
|
||||||
@@ -230,9 +235,13 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
|||||||
for _, client := range chunk {
|
for _, client := range chunk {
|
||||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||||
if clientAuth != nil &&
|
if clientAuth != nil &&
|
||||||
clientAuth.Id == newAuthRecord.Id &&
|
clientAuth.Id == authRecord.Id &&
|
||||||
clientAuth.Collection().Name == newAuthRecord.Collection().Name {
|
clientAuth.Collection().Name == authRecord.Collection().Name {
|
||||||
client.Set(RealtimeClientAuthKey, newAuthRecord)
|
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()
|
return group.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
|
// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model.
|
||||||
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
|
func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error {
|
||||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||||
|
|
||||||
group := new(errgroup.Group)
|
group := new(errgroup.Group)
|
||||||
@@ -267,7 +276,76 @@ func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
|
|||||||
return group.Wait()
|
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) {
|
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
|
// update the clients that has auth record association
|
||||||
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||||
Func: func(e *core.ModelEvent) error {
|
Func: func(e *core.ModelEvent) error {
|
||||||
@@ -294,7 +372,7 @@ func bindRealtimeEvents(app core.App) {
|
|||||||
Func: func(e *core.ModelEvent) error {
|
Func: func(e *core.ModelEvent) error {
|
||||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||||
if collection != nil && collection.IsAuth() {
|
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(
|
app.Logger().Warn(
|
||||||
"Failed to remove client(s) associated to the deleted auth model",
|
"Failed to remove client(s) associated to the deleted auth model",
|
||||||
slog.Any("id", e.Model.PK()),
|
slog.Any("id", e.Model.PK()),
|
||||||
|
|||||||
+193
-3
@@ -460,7 +460,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
|||||||
e.Context = context.Background()
|
e.Context = context.Background()
|
||||||
e.Model = authRecord1
|
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 {
|
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||||
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
||||||
@@ -502,14 +505,17 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
authRecord2.SetEmail("new@example.com")
|
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 := new(core.ModelEvent)
|
||||||
e.App = testApp
|
e.App = testApp
|
||||||
e.Type = core.ModelEventTypeUpdate
|
e.Type = core.ModelEventTypeUpdate
|
||||||
e.Context = context.Background()
|
e.Context = context.Background()
|
||||||
e.Model = authRecord2
|
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)
|
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
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
|
// Custom auth record model struct
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
var _ core.Model = (*CustomUser)(nil)
|
var _ core.Model = (*CustomUser)(nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user