How to Prevent Simultaneous Login of the Same User on Multiple Devices Using Firebase and Flutter

flutter

firebase

For the application of one of our clients, we had to find and implement a solution to limit the simultaneous screen count in use from each user account. We have an iOS and android application and a website developed with flutter, and we want the solution to apply to these three targets. As the app is subscription-based, we only want a user to be able to use it on two devices simultaneously.

What we are trying to achieve here is, if a user already uses the application on 2 devices: 

  • prevent the user from logging in on a new device;

  • disconnect him if he opens the app on a device he’s already connected to.

I decided to present the solution we chose and the implementation steps in this article.

The app on which we implemented this solution already used Firebase authentication and Firestore Database, so using Realtime Database was an obvious choice for us.

ezgif.com-gif-maker (3)

How to configure Firebase

Create a Firebase Realtime Database

Firstly, you need to create a Realtime Database on your Firebase console. You can start by putting your rules in test mode, which allows anyone with the URL to read and write data but don’t worry, we’ll change that later. 

For now, the goal is to connect our app to this newly created database. You can do this easily with Flutterfire CLI

  • in the terminal, go to the root of your flutter app;

  • use the flutterfire configure command, and follow the instructions.

This generates a firebase_options.dart file which you will use in your main function when initializing Firebase :

WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(  
options: DefaultFirebaseOptions.currentPlatform,
);

To do this, you must add the firebase_database dependency to your pubspec.yaml file.

We thought of the following solution about how we will organize the data in this database: 

Note 9 mars 2022

Users will write under logged_in_users the number of screens connected to the user account next to the user's unique id.

Set the rules of your database

We will use the rules tab to implement the logic behind the solution. We want the user not to be able to add a connected device if the value is above 2. This is ensured by the following rules. The user will only be able to write on the database if the value he sent is under or equals 2, meaning they can only have 2 simultaneous connections

{
"rules": {
".read": false,
".write": false,
"logged_in_users":{
"$uid":{
".validate": "newData.isNumber() && newData.val()<=2 && newData.val()>=0",
".read": true,
".write": true
}
}
}
}

Create the Bloc in charge of the logic

Our app state management is handled with Bloc, so our approach for this solution is to create a Bloc named ScreenLimitBloc that will be in charge of the logic behind this. 

Define the states of the bloc

Our app can be in 3 different states that we are going to define in a screen_limit_state.dart file: 

  • a correct device count state: DeviceCountCorrect when the user has the permission to log in;

  • an over-limit device count state: DeviceCountOverLimit when the user already has the maximum count of devices simultaneously connected;

  • and an initial state: ScreenLimitInitial when the user is not logged in.

Given these explanations, you should have the following file now:

part of 'screen_limit_bloc.dart';

@immutable
abstract class ScreenLimitState {}

class ScreenLimitInitial extends ScreenLimitState {}

class DeviceCountCorrect extends ScreenLimitState {}

class DeviceCountOverLimit extends ScreenLimitState {}

Define the events of the bloc

Now, to put the Bloc in the correct or over limit state, we should have an event called CheckDeviceCount that will call a function to check whether the user can log in. We will implement that function later. You should have a screen_limit_event.dart file looking like this : 

part of 'screen_limit_bloc.dart';

@immutable
abstract class ScreenLimitEvent {}

class CheckDeviceCount extends ScreenLimitEvent {}

Define the bloc

Now let’s move on to the important part: creating the Bloc. In a new file called screen_limit_bloc.dart, you can create a Bloc called ScreenLimitBloc:

​​class ScreenLimitBloc extends Bloc<ScreenLimitEvent, ScreenLimitState> {
ScreenLimitBloc({}) : super(ScreenlimitInitial()) {
on<CheckDeviceCount>(_onCheckDeviceCount);
}
}

The function _onCheckDeviceCount is called each time you add the CheckDeviceCount event to the Bloc. Basically, it sets the Firebase Database reference to tell the function where to read and write on the realtime database, and then check if the user can login. To get the reference, we must recover the actual user’s id.
To read and write data on the database, we must get the DatabaseReference first, and we can do so with the following command:

final userId = _authenticationRepository.currentUser.id;
ref = FirebaseDatabase.instance.ref('logged_in_users/$userId/');

This allows us to use the get() method to recover the database value and the set(value) method to set a new value on the database.

To do that, we add the authentication repository as a dependency of the Bloc:

class ScreenLimitBloc extends Bloc<ScreenLimitEvent, ScreenLimitState> {
ScreenLimitBloc({
required AuthenticationRepository authenticationRepository,
}) : _authenticationRepository = authenticationRepository,
super(ScreenLimitInitial()) {
on<CheckDeviceCount>(_onCheckDeviceCount);
}
}

So we can now write our function :

Future<void> _onCheckDeviceCount(
CheckDeviceCount event,
Emitter<ScreenLimitState> emit,
) async {
if (state is ScreenLimitInitial) {
final userId = _authenticationRepository.currentUser.id;
ref = FirebaseDatabase.instance.ref('logged_in_users/$userId/');

deviceCountExceeded = false;

await ref
.set(ServerValue.increment(1))
.onError<FirebaseException>((error, _) async {
if (error.code == 'permission-denied') {
deviceCountExceeded = true;
return _onScreenNumberExceeded(emit, ref);
}
});

if (!deviceCountExceeded) {
emit(DeviceCountCorrect());
}
}
}

This works well with the realtime database rules that we defined earlier (the validate line). The trick was to use the .set(ServerValue.increment(1)) so that the front did not handle all the logic. The Bloc has no idea of the server value. Still, if the request is rejected by the database (meaning that the value is above 2), the user already has his maximum simultaneous connections. This is great because if we try to connect 3 devices simultaneously, the request will always be rejected on one of them. Whereas if we were reading the value first and then doing all the logic in the front and not by rules, we could have issues when we launch the application at the same time on multiple devices.

The next thing to handle is decreasing the server value by 1 when the user logs out. We are going to add an event to the screen_limit_event.dart file :

class DecreaseDeviceCount extends ScreenLimitEvent {}

Let’s add a function to the Bloc that will be called when this event is added:

on<DecreaseDeviceCount>(_onDecreaseDeviceCount);

...

Future<void> _onDecreaseDeviceCount(
DecreaseDeviceCount event,
Emitter<ScreenLimitState> emit,
) async {
if (state is ScreenLimitCorrect) {
await ref.set(ServerValue.increment(-1));
emit(ScreenLimitInitial());
}
}

This function will do the trick if everything goes well, but what if the app crashes or the user kills the app without disconnecting himself? Those are cases you want to handle, and that’s where the onDisconnect method comes on stage. According to Google’s documentation : the OnDisconnect class is used to manage operations that will be run on the server when this client disconnects. It can be used to add or remove data based on a client's connection status. It is very useful in applications looking for 'presence' functionality. That’s exactly what we are trying to achieve here.

To implement this, just add this line in the _onCheckDeviceCount function, before emitting the DeviceCountCorrect state:

await ref.onDisconnect().set(ServerValue.increment(-1));

⚠️ You also want to add a ref.onDisconnect().cancel() when the user disconnects, otherwise if he kills the app right after being disconnected, his screen count would decrease by 2.

Disconnect the user if he use too much simultaneous screens

We’re almost done here, there are just 2 more functions to write. The first one is to handle the case where the Bloc goes into DeviceCountOverLimit state, and that’s the function we called earlier _onDeviceCountExceeded:

Future<void> _onDeviceCountExceeded(
Emitter<ScreenLimitState> emit,
DatabaseReference ref,
) async {
emit(DeviceCountOverLimit());
emit(ScreenLimitInitial());
}

We will later listen for those state changes to react to it to force the user’s disconnection. This way, we don’t handle the disconnection logic in our Bloc.

The last thing to do is to add events to this Bloc depending on the authentication repository changes:

  • if the user changes, we want to add the CheckDeviceCount event;

  • if the user goes empty, meaning he is disconnected, we want to add the DecreaseDeviceCount event.

To do so, we will add the authentication_repository.user subscription to our ScreenLimitBloc :

late StreamSubscription userSubscription;

void listenToUserChanges() {
userSubscription = _authenticationRepository.user.listen((user) {
if (user == User.empty) {
add(DecreaseDeviceCount());
} else {
add(CheckDeviceCount());
}
});
}

You have to call this listenToUserChanges function when you initialize your Bloc (just below the on<DecreaseDeviceCount>(_onDecreaseDeviceCount); line).

There you go, all you have to do is add your ScreenLimitBloc with BlocProvider and wrap your app with a ScreenLimitListener in your app.dart file :

class ScreenLimitListener extends StatelessWidget {
const ScreenLimitListener({Key? key, required this.child}) : super(key: key);

final Widget child;

@override
Widget build(BuildContext context) {
return BlocListener<ScreenLimitBloc, ScreenLimitState>(
listener: (context, state) {
if (state is DeviceCountOverLimit) {
context.read<AppBloc>().add(AppLogoutRequested());
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Ooops, too many simultaneous screens'),
),
);
}
},
child: child,
);
}
}
BlocProvider(
create: (_) => ScreenLimitBloc(
authenticationRepository: _authenticationRepository,
),
)

...

class AppView extends StatelessWidget {
const AppView({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
theme: theme,
home: ScreenLimitListener(
child: FlowBuilder<AppStatus>(
state: context.select((AppBloc bloc) => bloc.state.status),
onGeneratePages: onGenerateAppViewPages,
),
),
);
}
}

The ScreenLimitListener is responsible for disconnecting the user if the state of the ScreenLimitBloc is DeviceCountOverLimit, and for showing a little snack bar that indicates to the user what was the reason he was disconnected.

If you want to see the concepts we applied in this article, feel free to look at this repository, and, more specifically, at this commit where we added the whole screen limit logic.

Conclusion

This method allowed us to prevent simultaneous login of the same user on multiple devices in a relatively simple way. Indeed, if you check the commit where we added all this logic from a basic authentication app, you will see that it’s not even 200 lines of code. That’s ridiculously low for such an important feature!
You can check this little experiment of the solution in real conditions:

ezgif.com-gif-maker (1)

Finally, although I use Firebase authentication in this solution in the listenToUserChanges function to detect any user changes (via the firebaseAuth.authStateChanges method), any method like oauth or even a personal authentication method should do the trick if you adapt the listenToUserChanges to listen to your particular state.