Skip to content

Commit e0f25da

Browse files
Compass App: Basic auth (#2385)
This PR introduces basic auth implementation between the app and the server as part of the architectural example. This PR is a big bigger than the previous ones so I hope this explanation helps: ### Server implementation The server introduces a new endpoint `/login` to perform login requests, which accepts login requests defined in the `LoginRequest` data class, with an email and password. The login process "simulates" checking on the email and password and responds with a "token" and user ID, defined by the `LoginResponse` data class. This is a simple hard-coded check and in any way a guide on how to implement authentication, just a way to demonstrate an architectural example. The server also implements a middleware in `server/lib/middleware/auth.dart`. This checks that the requests between the app and the server carry a valid authorization token in the headers, responding with an unauthorized error otherwise. ### App implementation The app introduces the following new parts: - `AuthTokenRepository`: In charge of storing the auth token. - `AuthLoginComponent`: In charge of performing login. - `AuthLogoutComponent`: In charge of performing logout. - `LoginScreen` with `LoginViewModel`: Displays the login screen. - `LogoutButton` with `LogoutViewModel`: Displays a logout button. The `AuthTokenRepository` acts as the source of truth to decide if the user is logged in or not. If the repository contains a token, it means the user is logged in, otherwise if the token is null, it means that the user is logged out. This repository is also a `ChangeNotifier`, which allows listening to change in it. The `GoRouter` has been modified so it listens to changes in the `AuthTokenRepository` using the `refreshListenable` property. It also implements a `redirect`, so if the token is set to `null` in the repository, the router will redirect users automatically to the login screen. This follows the example found in https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart On app start, `GoRouter` checks the `AuthTokenRepository`, if a token exists the user stays in `/`, if not, the user is redirected to `/login`. The `ApiClient` has also been modified, so it reads the stored token from the repository when performing network calls, and adds it to the auth headers. The two new components implement basic login and logout functionality. The `AuthLoginComponent` will send the request using the `ApiClient`, and then store the token from the response. The `AuthLogoutComponent` clears the stored token from the repository, and as well clears any existing itinerary configuration, effectively cleaning the app state. Performing logout redirects the user to the login screen, as explained. The `LoginScreen` uses the `AuthLoginComponent` internally, it displays two text fields and a login button, plus the application logo on top. A successful login redirects the user to `/`. The `LogoutButton` replaces the home button at the `/`, and on tap it will perform logout using the `AuthLogoutComponent`. **Development target app** The development target app works slightly different compared to the staging build. In this case, the `AuthTokenRepository` always contains a fake token, so the app believes it is always logged in. Auth is only used in the staging build when the server is involved. ## Screenshots <details> <summary>Screenshots</summary> The logout button in the top right corner: ![Screenshot from 2024-08-14 15-28-54](https://github.com/user-attachments/assets/1c5a37dc-9fa1-4950-917e-0c7272896780) The login screen: ![Screenshot from 2024-08-14 15-28-12](https://github.com/user-attachments/assets/3c26ccc2-8e3b-42d2-a230-d31048af6960) </details> ## Pre-launch Checklist - [x] I read the [Flutter Style Guide] _recently_, and have followed its advice. - [x] I signed the [CLA]. - [x] I read the [Contributors Guide]. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-devrel channel on [Discord]. <!-- Links --> [Flutter Style Guide]: https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md [CLA]: https://cla.developers.google.com/ [Discord]: https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md [Contributors Guide]: https://github.com/flutter/samples/blob/main/CONTRIBUTING.md
1 parent 0c88289 commit e0f25da

File tree

47 files changed

+1607
-89
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1607
-89
lines changed

compass_app/app/assets/logo.svg

Lines changed: 10 additions & 0 deletions
Loading

compass_app/app/lib/config/dependencies.dart

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import 'package:provider/single_child_widget.dart';
22
import 'package:provider/provider.dart';
33

4+
import '../domain/components/auth/auth_login_component.dart';
5+
import '../domain/components/auth/auth_logout_component.dart';
46
import '../data/repositories/activity/activity_repository.dart';
57
import '../data/repositories/activity/activity_repository_local.dart';
68
import '../data/repositories/activity/activity_repository_remote.dart';
9+
import '../data/repositories/auth/auth_token_repository.dart';
10+
import '../data/repositories/auth/auth_token_repository_dev.dart';
11+
import '../data/repositories/auth/auth_token_repository_shared_prefs.dart';
712
import '../data/repositories/continent/continent_repository.dart';
813
import '../data/repositories/continent/continent_repository_local.dart';
914
import '../data/repositories/continent/continent_repository_remote.dart';
@@ -13,8 +18,8 @@ import '../data/repositories/destination/destination_repository_remote.dart';
1318
import '../data/repositories/itinerary_config/itinerary_config_repository.dart';
1419
import '../data/repositories/itinerary_config/itinerary_config_repository_memory.dart';
1520
import '../data/services/api_client.dart';
16-
import '../ui/booking/components/booking_create_component.dart';
17-
import '../ui/booking/components/booking_share_component.dart';
21+
import '../domain/components/booking/booking_create_component.dart';
22+
import '../domain/components/booking/booking_share_component.dart';
1823

1924
/// Shared providers for all configurations.
2025
List<SingleChildWidget> _sharedProviders = [
@@ -29,27 +34,44 @@ List<SingleChildWidget> _sharedProviders = [
2934
lazy: true,
3035
create: (context) => BookingShareComponent.withSharePlus(),
3136
),
37+
Provider(
38+
lazy: true,
39+
create: (context) => AuthLogoutComponent(
40+
authTokenRepository: context.read(),
41+
itineraryConfigRepository: context.read(),
42+
),
43+
),
3244
];
3345

3446
/// Configure dependencies for remote data.
3547
/// This dependency list uses repositories that connect to a remote server.
3648
List<SingleChildWidget> get providersRemote {
37-
final apiClient = ApiClient();
38-
3949
return [
40-
Provider.value(
41-
value: DestinationRepositoryRemote(
42-
apiClient: apiClient,
50+
ChangeNotifierProvider.value(
51+
value: AuthTokenRepositorySharedPrefs() as AuthTokenRepository,
52+
),
53+
Provider(
54+
create: (context) => ApiClient(authTokenRepository: context.read()),
55+
),
56+
Provider(
57+
create: (context) => AuthLoginComponent(
58+
authTokenRepository: context.read(),
59+
apiClient: context.read(),
60+
),
61+
),
62+
Provider(
63+
create: (context) => DestinationRepositoryRemote(
64+
apiClient: context.read(),
4365
) as DestinationRepository,
4466
),
45-
Provider.value(
46-
value: ContinentRepositoryRemote(
47-
apiClient: apiClient,
67+
Provider(
68+
create: (context) => ContinentRepositoryRemote(
69+
apiClient: context.read(),
4870
) as ContinentRepository,
4971
),
50-
Provider.value(
51-
value: ActivityRepositoryRemote(
52-
apiClient: apiClient,
72+
Provider(
73+
create: (context) => ActivityRepositoryRemote(
74+
apiClient: context.read(),
5375
) as ActivityRepository,
5476
),
5577
Provider.value(
@@ -61,8 +83,12 @@ List<SingleChildWidget> get providersRemote {
6183

6284
/// Configure dependencies for local data.
6385
/// This dependency list uses repositories that provide local data.
86+
/// The user is always logged in.
6487
List<SingleChildWidget> get providersLocal {
6588
return [
89+
ChangeNotifierProvider.value(
90+
value: AuthTokenRepositoryDev() as AuthTokenRepository,
91+
),
6692
Provider.value(
6793
value: DestinationRepositoryLocal() as DestinationRepository,
6894
),
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
import '../../../utils/result.dart';
4+
5+
/// Repository to save and get auth token.
6+
/// Notifies listeners when the token changes e.g. user logs out.
7+
abstract class AuthTokenRepository extends ChangeNotifier {
8+
/// Get the token.
9+
/// If the value is null, usually means that the user is logged out.
10+
Future<Result<String?>> getToken();
11+
12+
/// Store the token.
13+
/// Will notifiy listeners.
14+
Future<Result<void>> saveToken(String? token);
15+
16+
/// Returns true when the token exists, otherwise false.
17+
Future<bool> hasToken() async {
18+
final result = await getToken();
19+
return result is Ok<String?> && result.value != null;
20+
}
21+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import '../../../utils/result.dart';
2+
import 'auth_token_repository.dart';
3+
4+
/// Development [AuthTokenRepository] that always returns a fake token
5+
class AuthTokenRepositoryDev extends AuthTokenRepository {
6+
@override
7+
Future<Result<String?>> getToken() async {
8+
return Result.ok('token');
9+
}
10+
11+
@override
12+
Future<Result<void>> saveToken(String? token) async {
13+
notifyListeners();
14+
return Result.ok(null);
15+
}
16+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import 'package:shared_preferences/shared_preferences.dart';
2+
3+
import '../../../utils/result.dart';
4+
import 'auth_token_repository.dart';
5+
6+
/// [AuthTokenRepository] that stores the token in Shared Preferences.
7+
/// Provided for demo purposes, consider using a secure store instead.
8+
class AuthTokenRepositorySharedPrefs extends AuthTokenRepository {
9+
static const _tokenKey = 'TOKEN';
10+
String? cachedToken;
11+
12+
@override
13+
Future<Result<String?>> getToken() async {
14+
if (cachedToken != null) return Result.ok(cachedToken);
15+
16+
try {
17+
final sharedPreferences = await SharedPreferences.getInstance();
18+
final token = sharedPreferences.getString(_tokenKey);
19+
return Result.ok(token);
20+
} on Exception catch (e) {
21+
return Result.error(e);
22+
}
23+
}
24+
25+
@override
26+
Future<Result<void>> saveToken(String? token) async {
27+
try {
28+
final sharedPreferences = await SharedPreferences.getInstance();
29+
if (token == null) {
30+
await sharedPreferences.remove(_tokenKey);
31+
} else {
32+
await sharedPreferences.setString(_tokenKey, token);
33+
}
34+
cachedToken = token;
35+
notifyListeners();
36+
return Result.ok(null);
37+
} on Exception catch (e) {
38+
return Result.error(e);
39+
}
40+
}
41+
}

compass_app/app/lib/data/services/api_client.dart

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,52 @@ import 'dart:io';
33
import 'package:compass_model/model.dart';
44

55
import '../../utils/result.dart';
6+
import '../repositories/auth/auth_token_repository.dart';
7+
8+
typedef AuthTokenProvider = Future<String?> Function();
69

7-
// TODO: Basic auth request
810
// TODO: Configurable baseurl/host/port
911
class ApiClient {
12+
ApiClient({
13+
required AuthTokenRepository authTokenRepository,
14+
}) : _authTokenRepository = authTokenRepository;
15+
16+
/// Provides the auth token to be used in the request
17+
final AuthTokenRepository _authTokenRepository;
18+
19+
Future<void> _authHeader(HttpHeaders headers) async {
20+
final result = await _authTokenRepository.getToken();
21+
if (result is Ok<String?>) {
22+
if (result.value != null) {
23+
headers.add(HttpHeaders.authorizationHeader, 'Bearer ${result.value}');
24+
}
25+
}
26+
}
27+
28+
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
29+
final client = HttpClient();
30+
try {
31+
final request = await client.post('localhost', 8080, '/login');
32+
request.write(jsonEncode(loginRequest));
33+
final response = await request.close();
34+
if (response.statusCode == 200) {
35+
final stringData = await response.transform(utf8.decoder).join();
36+
return Result.ok(LoginResponse.fromJson(jsonDecode(stringData)));
37+
} else {
38+
return Result.error(const HttpException("Login error"));
39+
}
40+
} on Exception catch (error) {
41+
return Result.error(error);
42+
} finally {
43+
client.close();
44+
}
45+
}
46+
1047
Future<Result<List<Continent>>> getContinents() async {
1148
final client = HttpClient();
1249
try {
1350
final request = await client.get('localhost', 8080, '/continent');
51+
await _authHeader(request.headers);
1452
final response = await request.close();
1553
if (response.statusCode == 200) {
1654
final stringData = await response.transform(utf8.decoder).join();
@@ -31,6 +69,7 @@ class ApiClient {
3169
final client = HttpClient();
3270
try {
3371
final request = await client.get('localhost', 8080, '/destination');
72+
await _authHeader(request.headers);
3473
final response = await request.close();
3574
if (response.statusCode == 200) {
3675
final stringData = await response.transform(utf8.decoder).join();
@@ -52,6 +91,7 @@ class ApiClient {
5291
try {
5392
final request =
5493
await client.get('localhost', 8080, '/destination/$ref/activity');
94+
await _authHeader(request.headers);
5595
final response = await request.close();
5696
if (response.statusCode == 200) {
5797
final stringData = await response.transform(utf8.decoder).join();
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import 'package:compass_model/model.dart';
2+
import 'package:logging/logging.dart';
3+
4+
import '../../../utils/result.dart';
5+
import '../../../data/repositories/auth/auth_token_repository.dart';
6+
import '../../../data/services/api_client.dart';
7+
8+
/// Performs user login.
9+
class AuthLoginComponent {
10+
AuthLoginComponent({
11+
required AuthTokenRepository authTokenRepository,
12+
required ApiClient apiClient,
13+
}) : _authTokenRepository = authTokenRepository,
14+
_apiClient = apiClient;
15+
16+
final AuthTokenRepository _authTokenRepository;
17+
final ApiClient _apiClient;
18+
final _log = Logger('AuthLoginComponent');
19+
20+
/// Login with username and password.
21+
/// Performs login with the server and stores the obtained auth token.
22+
Future<Result<void>> login({
23+
required String email,
24+
required String password,
25+
}) async {
26+
final result = await _apiClient.login(
27+
LoginRequest(
28+
email: email,
29+
password: password,
30+
),
31+
);
32+
switch (result) {
33+
case Ok<LoginResponse>():
34+
_log.info('User logged int');
35+
return await _authTokenRepository.saveToken(result.value.token);
36+
case Error<LoginResponse>():
37+
_log.warning('Error logging in: ${result.error}');
38+
return Result.error(result.error);
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)