Last active
July 11, 2024 23:11
-
-
Save PlugFox/538d7cfdd7149770925fe4f434fc2e84 to your computer and use it in GitHub Desktop.
Authentication management example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Authentication management example | |
* HttpClient <--> AuthenticationController <--> Navigator or LoginScreen | |
* | |
* https://gist.github.com/PlugFox/538d7cfdd7149770925fe4f434fc2e84 | |
* https://dartpad.dev?id=538d7cfdd7149770925fe4f434fc2e84 | |
* Mike Matiunin <plugfox@gmail.com>, 12 July 2024 | |
*/ | |
import 'dart:async'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:meta/meta.dart'; | |
import 'package:provider/provider.dart'; | |
import 'package:shared_preferences/shared_preferences.dart'; | |
/// User JWT | |
typedef Token = String; | |
/// Represents the current user state. | |
@immutable | |
sealed class User { | |
const User(); | |
const factory User.authenticated(Token token) = AuthenticatedUser; | |
@literal | |
const factory User.unauthenticated() = UnauthenticatedUser; | |
T map<T>({ | |
required T Function(AuthenticatedUser user) authenticated, | |
required T Function() unauthenticated, | |
}); | |
} | |
/// Represents an authenticated user. | |
final class AuthenticatedUser extends User { | |
const AuthenticatedUser(this.token); | |
/// The user's token. | |
final Token token; | |
@override | |
T map<T>({ | |
required T Function(AuthenticatedUser user) authenticated, | |
required T Function() unauthenticated, | |
}) => | |
authenticated(this); | |
@override | |
int get hashCode => token.hashCode; | |
@override | |
bool operator ==(Object other) => | |
identical(this, other) || | |
other is AuthenticatedUser && token == other.token; | |
} | |
/// Represents an unauthenticated user. | |
final class UnauthenticatedUser extends User { | |
@literal | |
const UnauthenticatedUser(); | |
@override | |
T map<T>({ | |
required T Function(AuthenticatedUser user) authenticated, | |
required T Function() unauthenticated, | |
}) => | |
unauthenticated(); | |
@override | |
int get hashCode => -1; | |
@override | |
bool operator ==(Object other) => | |
identical(this, other) || other is UnauthenticatedUser; | |
} | |
/// An interceptor that can be used to intercept requests and responses. | |
abstract interface class Interceptor { | |
/// Called before the request is sent | |
/// with the Request object. | |
Future<void> onRequest(String url); | |
/// Called before the response is returned | |
/// with the Request and Response objecs. | |
Future<void> onResponse(String url, int statusCode); | |
} | |
/// A simulated HTTP client that can be used to make requests. | |
class HttpClient { | |
HttpClient() : interceptors = <Interceptor>[]; | |
/// The interceptors that will be called before/after each request. | |
final List<Interceptor> interceptors; | |
int _counter = 1; | |
/// Make a request to the specified URL. | |
Future<int> request(String url) async { | |
for (final interceptor in interceptors) { | |
await interceptor.onRequest(url); | |
} | |
// Emulate a network request and status code response. | |
// If that is not a "/login" request | |
// - we can get here a 401 error with low chance. | |
final int statusCode; | |
if (url.endsWith('/login')) { | |
_counter = 1; | |
statusCode = 200; | |
} else { | |
_counter++; | |
statusCode = _counter % 5 == 0 ? 401 : 200; | |
} | |
for (final interceptor in interceptors) { | |
await interceptor.onResponse(url, statusCode); | |
} | |
return statusCode; | |
} | |
} | |
/// An interceptor that adds authentication headers to the request. | |
/// It also refreshes the token if it's expired. | |
/// If the token is expired or invalid, it will call the `expired` callback. | |
class AuthenticationInterceptor implements Interceptor { | |
AuthenticationInterceptor({ | |
required this.getToken, | |
required this.refreshToken, | |
required this.expired, | |
}); | |
/// Get the current token. | |
final String Function() getToken; | |
/// Set or refresh the token. | |
final void Function(String token) refreshToken; | |
/// Called when the token is expired or invalid. | |
final void Function() expired; | |
@override | |
Future<void> onRequest(String url) async { | |
if (url.endsWith('/refresh') || url.endsWith('/login')) { | |
// Skip the interceptor for the refresh and login endpoints. | |
return; | |
} | |
final token = getToken(); | |
if (token.isEmpty) expired(); | |
// Add the token to the request headers. | |
} | |
@override | |
Future<void> onResponse(String url, int statusCode) async { | |
switch (statusCode) { | |
case 401 || 403: | |
expired(); | |
case 200 when url.endsWith('/refresh') || url.endsWith('/login'): | |
refreshToken('JWT' | |
'#${(DateTime.now().copyWith(year: 1970).millisecondsSinceEpoch ~/ 1000).toRadixString(36)}'); | |
default: | |
return; | |
} | |
} | |
} | |
/// Controller that manages the authentication state. | |
/// Of course real controller should be much more complex | |
/// and contain states like idle, loading, error, etc. | |
/// You can create it based on `ChangeNotifier` or `BLoC`. | |
extension type AuthenticationController._(ValueNotifier<User> _notifier) | |
implements ValueListenable<User> { | |
factory AuthenticationController(User user) => | |
AuthenticationController._(ValueNotifier<User>((user))); | |
/// The current user state. | |
User get user => _notifier.value; | |
/// Sets the current user state. | |
void setUser(User user) => _notifier.value = user; | |
/// Logout the current user. | |
void logout() => setUser(const UnauthenticatedUser()); | |
} | |
/// Dependencies required by the application. | |
/// You can add more dependencies and make another implementation if needed. | |
/// E.g. `class DependenciesFake implements Dependencies {...}`. | |
class Dependencies { | |
Dependencies(); | |
/// Get the dependencies from the current context. | |
factory Dependencies.of(BuildContext context) => | |
_InheritedDependencies.of(context); | |
/// The authentication controller. | |
late final AuthenticationController authenticationController; | |
/// The HTTP client. | |
late final HttpClient httpClient; | |
} | |
/// Initializes the dependencies required by the application. | |
Future<Dependencies> $initializeDependencies() async { | |
// Initialize the shared preferences with the mock values. | |
// ignore: invalid_use_of_visible_for_testing_member | |
SharedPreferences.setMockInitialValues(<String, Object>{}); | |
final sharedPrefs = await SharedPreferences.getInstance(); | |
// Create the authentication controller with the initial user state. | |
final authenticationController = AuthenticationController(() { | |
try { | |
switch (sharedPrefs.getString('token')) { | |
case String token when token.isNotEmpty: | |
return User.authenticated(token); | |
default: | |
return const User.unauthenticated(); | |
} | |
} on Object { | |
return const User.unauthenticated(); | |
} | |
}()); | |
// Save the token to the shared preferences when the user changes. | |
// In real application you should implement that part | |
// in the `AuthenticationController` itself with: | |
// ```dart | |
// AuthenticationRepositoryImpl( | |
// local: AuthenticationLocalProviderSharedPrefsImpl(sharedPrefs), | |
// network: AuthenticationNetworkProviderHttpImpl(httpClient), | |
// ) | |
// ``` | |
authenticationController.addListener(() { | |
switch (authenticationController.user) { | |
case AuthenticatedUser user: | |
sharedPrefs.setString('token', user.token); | |
case UnauthenticatedUser _: | |
sharedPrefs.remove('token'); | |
} | |
}); | |
// Create the HTTP client with the authentication interceptor. | |
final httpClient = HttpClient() | |
..interceptors.addAll([ | |
// Add the authentication interceptor to the HTTP client. | |
// It will add the token to the request headers and refresh it if needed. | |
// If the token is expired or invalid, it will call the `expired` callback | |
// and logout the user. | |
AuthenticationInterceptor( | |
getToken: () => switch (authenticationController.value) { | |
AuthenticatedUser user => user.token, | |
UnauthenticatedUser _ => throw Exception('User is not authenticated'), | |
}, | |
refreshToken: (token) => | |
authenticationController.setUser(User.authenticated(token)), | |
expired: () => | |
authenticationController.setUser(const User.unauthenticated()), | |
), | |
]); | |
return Dependencies() | |
..authenticationController = authenticationController | |
..httpClient = httpClient; | |
} | |
/// The main function that initializes the application. | |
void main() => runZonedGuarded<void>( | |
() async { | |
final binding = WidgetsFlutterBinding.ensureInitialized() | |
..deferFirstFrame(); | |
final dependencies = await $initializeDependencies(); | |
runApp( | |
_InheritedDependencies( | |
dependencies: dependencies, | |
child: const App(), | |
), | |
); | |
binding.allowFirstFrame(); | |
}, | |
(error, stackTrace) => print('Error: $error'), // ignore: avoid_print | |
); | |
class _InheritedDependencies extends InheritedWidget { | |
const _InheritedDependencies({ | |
required this.dependencies, | |
required super.child, | |
super.key, // ignore: unused_element | |
}); | |
final Dependencies dependencies; | |
static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( | |
'Out of scope, not found inherited widget ' | |
'a _InheritedDependencies of the exact type', | |
'out_of_scope', | |
); | |
/// The state from the closest instance of this class | |
/// that encloses the given context. | |
/// e.g. `Dependencies.of(context)` | |
static Dependencies of(BuildContext context) => | |
context | |
.getInheritedWidgetOfExactType<_InheritedDependencies>() | |
?.dependencies ?? | |
_notFoundInheritedWidgetOfExactType(); | |
@override | |
bool updateShouldNotify(covariant _InheritedDependencies oldWidget) => false; | |
} | |
/// The main application widget. | |
class App extends StatelessWidget { | |
const App({super.key}); | |
@override | |
Widget build(BuildContext context) => MaterialApp( | |
title: 'Basic authentication management example', | |
home: const HomeScreen(), | |
debugShowCheckedModeBanner: false, | |
builder: (context, navigator) => ValueListenableBuilder<User>( | |
valueListenable: Dependencies.of(context).authenticationController, | |
builder: (context, user, child) => user.map<Widget>( | |
// Show the navigator and main application flow | |
// if the user is authenticated. | |
// Also provide the authenticated user to the underlying widgets. | |
authenticated: (user) => Provider<AuthenticatedUser>.value( | |
value: user, | |
child: navigator, | |
), | |
// Show the login screen if the user is not authenticated. | |
unauthenticated: () => const LoginScreen(), | |
), | |
child: navigator, | |
), | |
); | |
} | |
/// The login screen widget. | |
class LoginScreen extends StatelessWidget { | |
const LoginScreen({super.key}); | |
/// Login the user. | |
static void login(BuildContext context) => Dependencies.of(context) | |
.httpClient | |
.request('https://example.com/api/login'); | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
appBar: AppBar( | |
title: const Text('Login Screen'), | |
), | |
body: SafeArea( | |
child: Center( | |
child: ElevatedButton.icon( | |
onPressed: () => login(context), | |
label: const Text('Login'), | |
icon: const Icon(Icons.login), | |
), | |
), | |
), | |
); | |
} | |
/// The home screen widget. | |
class HomeScreen extends StatelessWidget { | |
const HomeScreen({super.key}); | |
/// Make a request to the API. | |
static void request(BuildContext context) async { | |
final client = Dependencies.of(context).httpClient; | |
final statusCode = await client.request('https://example.com/api/method'); | |
if (!context.mounted) return; | |
final messenger = ScaffoldMessenger.maybeOf(context)?..clearSnackBars(); | |
if (messenger == null) return; | |
switch (statusCode) { | |
case 200: | |
messenger.showSnackBar( | |
const SnackBar( | |
content: Text('Request successful'), | |
), | |
); | |
case 401 || 403: | |
messenger.showSnackBar( | |
const SnackBar( | |
content: Text('Unauthorized request'), | |
backgroundColor: Colors.red, | |
), | |
); | |
case int code when code >= 400 && code < 600: | |
messenger.showSnackBar( | |
const SnackBar( | |
content: Text('Request failed'), | |
backgroundColor: Colors.red, | |
), | |
); | |
} | |
} | |
/// Logout the current user. | |
static void logout(BuildContext context) => | |
Dependencies.of(context).authenticationController.logout(); | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
appBar: AppBar( | |
title: const Text('Home Screen'), | |
actions: <Widget>[ | |
IconButton( | |
onPressed: () => logout(context), | |
icon: const Icon(Icons.logout), | |
), | |
], | |
), | |
body: SafeArea( | |
child: Center( | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
mainAxisAlignment: MainAxisAlignment.center, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: <Widget>[ | |
Text( | |
'Current user: ' | |
'${context.watch<AuthenticatedUser>().token}', | |
), | |
const SizedBox(height: 16), | |
ElevatedButton.icon( | |
onPressed: () => request(context), | |
label: const Text('Make request'), | |
icon: const Icon(Icons.send), | |
), | |
], | |
), | |
), | |
), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment