diff --git a/moneymgr_mobile/lib/services/auth_state.dart b/moneymgr_mobile/lib/providers/auth_state.dart similarity index 63% rename from moneymgr_mobile/lib/services/auth_state.dart rename to moneymgr_mobile/lib/providers/auth_state.dart index e9c1347..4979bcf 100644 --- a/moneymgr_mobile/lib/services/auth_state.dart +++ b/moneymgr_mobile/lib/providers/auth_state.dart @@ -1,4 +1,6 @@ +import 'package:moneymgr_mobile/services/api/api_client.dart'; import 'package:moneymgr_mobile/services/api/api_token.dart'; +import 'package:moneymgr_mobile/services/api/auth_api.dart'; import 'package:moneymgr_mobile/services/router/routes_list.dart'; import 'package:moneymgr_mobile/services/storage/secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -14,25 +16,22 @@ class CurrentAuthState extends _$CurrentAuthState { @override AuthState build() { final secureStorage = ref.watch(secureStorageProvider).requireValue; - final token = secureStorage.get('token'); + final token = secureStorage.token(); return token != null ? AuthState.authenticated : AuthState.unauthenticated; } - /// Attempts to authenticate with [data] and saves the token and profile info to storage. - /// Will invalidate the state if success. - Future setAuthToken(ApiToken data) async { - // TODO : perform login - /*final secureStorage = ref.read(secureStorageProvider).requireValue; - final token = await ref.read(apiServiceProvider).login(data); + /// Attempts to authenticate with [token] and saves the token and profile info to storage. + /// Will invalidate the state if success and throw an exception in case of failure + Future setAuthToken(ApiToken token) async { + // Attempt to use provided token + await ApiClient(token: token).authInfo(); - // Save the new [token] and [profile] to secure storage. - secureStorage.set('token', token); + final secureStorage = ref.read(secureStorageProvider).requireValue; + secureStorage.setToken(token); ref - // Invalidate the state so the auth state will be updated to authenticated. - ..invalidateSelf() - // Invalidate the token provider so the API service will use the new token. - ..invalidate(tokenProvider);*/ + // Invalidate the state so the auth state will be updated to authenticated. + .invalidateSelf(); } /// Logs out, deletes the saved token and profile info from storage, and invalidates @@ -60,9 +59,17 @@ enum AuthState { redirectPath: authPage, allowedPaths: [authPage, manualAuthPage, settingsPage], ), - authenticated(redirectPath: homePage, allowedPaths: null); + authenticated( + redirectPath: homePage, + allowedPaths: null, + forbiddenPaths: [authPage, manualAuthPage], + ); - const AuthState({required this.redirectPath, required this.allowedPaths}); + const AuthState({ + required this.redirectPath, + required this.allowedPaths, + this.forbiddenPaths, + }); /// The target path to redirect when the current route is not allowed in this /// auth state. @@ -71,4 +78,8 @@ enum AuthState { /// List of paths allowed when the app is in this auth state. May be set to null if there is no /// restriction applicable final List? allowedPaths; + + /// List of paths not allowed when the app is in this auth state. May be set to null if there is no + /// restriction applicable + final List? forbiddenPaths; } diff --git a/moneymgr_mobile/lib/routes/login/manual_auth_screen.dart b/moneymgr_mobile/lib/routes/login/manual_auth_screen.dart index 8cd0100..23db5b9 100644 --- a/moneymgr_mobile/lib/routes/login/manual_auth_screen.dart +++ b/moneymgr_mobile/lib/routes/login/manual_auth_screen.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:moneymgr_mobile/routes/login/base_auth_page.dart'; import 'package:moneymgr_mobile/services/api/api_token.dart'; -import 'package:moneymgr_mobile/services/auth_state.dart'; +import 'package:moneymgr_mobile/providers/auth_state.dart'; import 'package:moneymgr_mobile/utils/extensions.dart'; import 'package:moneymgr_mobile/widgets/app_button.dart'; @@ -52,7 +52,7 @@ class ManualAuthScreen extends HookConsumerWidget { decoration: const InputDecoration( labelText: 'API URL', helperText: - "This URL has usually this format: http://moneymgr.corp.com/api", + "Format: http://moneymgr.corp.com/api", ), textInputAction: TextInputAction.next, ), @@ -68,7 +68,7 @@ class ManualAuthScreen extends HookConsumerWidget { ), Gutter(), TextField( - controller: tokenIdController, + controller: tokenValueController, keyboardType: TextInputType.text, decoration: const InputDecoration( labelText: 'Token value', diff --git a/moneymgr_mobile/lib/services/api/account_api.dart b/moneymgr_mobile/lib/services/api/account_api.dart deleted file mode 100644 index 39de474..0000000 --- a/moneymgr_mobile/lib/services/api/account_api.dart +++ /dev/null @@ -1,4 +0,0 @@ -/// Account API -class AccountAPI { - -} \ No newline at end of file diff --git a/moneymgr_mobile/lib/services/api/api_client.dart b/moneymgr_mobile/lib/services/api/api_client.dart index ba244f1..0362bb1 100644 --- a/moneymgr_mobile/lib/services/api/api_client.dart +++ b/moneymgr_mobile/lib/services/api/api_client.dart @@ -1,7 +1,72 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:dio/dio.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/services/api/api_token.dart'; +import 'package:moneymgr_mobile/utils/string_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -/// Client API -@riverpod -class ClientAPI { +part 'api_client.g.dart'; -} \ No newline at end of file +/// API token header +const apiTokenHeader = "X-Auth-Token"; + +/// Client API +class ApiClient { + final ApiToken token; + final Dio client; + + ApiClient({required this.token}) + : client = Dio(BaseOptions(baseUrl: token.apiUrl)); + + /// Get Dio instance + Future> execute(String uri, {String method = "GET"}) async { + return client.request( + uri, + options: Options( + method: method, + headers: {apiTokenHeader: _genJWT(method, uri)}, + ), + ); + } + + /// Generate authentication JWT + String _genJWT(String method, String uri) { + final jwt = JWT( + {"nonce": getRandomString(15), "met": method, "uri": uri}, + header: {"kid": token.tokenId.toString()}, + ); + + return jwt.sign( + SecretKey(token.tokenValue), + algorithm: JWTAlgorithm.HS256, + expiresIn: Duration(minutes: 15), + ); + } +} + +/// An API service that handles authentication and exposes an [ApiClient]. +/// +/// Every API call coming from UI should watch/read this provider instead of +/// instantiating the [ApiClient] itself. When being watched, it will force any +/// data provider (provider that fetches data) to refetch when the +/// authentication state changes. +/// +/// The API client is kept alive to follow dio's recommendation to use the same +/// client instance for the entire app. +@riverpod +ApiClient apiService(Ref ref) { + /*final token = ref.watch(currentAuthStateProvider); + + final ApiClient client; + + const mock = bool.fromEnvironment('MOCK_API', defaultValue: false); + client = switch (mock) { + true => + token != null ? MockedApiClient.withToken(token) : MockedApiClient(), + false => token != null ? ApiClient.withToken(token) : ApiClient(), + }; + ref.keepAlive(); + + return client;*/ + throw Exception("TODO"); // TODO +} diff --git a/moneymgr_mobile/lib/services/api/api_token.dart b/moneymgr_mobile/lib/services/api/api_token.dart index b8e388b..62e7b1a 100644 --- a/moneymgr_mobile/lib/services/api/api_token.dart +++ b/moneymgr_mobile/lib/services/api/api_token.dart @@ -8,7 +8,7 @@ abstract class ApiToken with _$ApiToken { const factory ApiToken({ required String apiUrl, required int tokenId, - required String tokenValue + required String tokenValue, }) = _ApiToken; factory ApiToken.fromJson(Map json) => diff --git a/moneymgr_mobile/lib/services/api/auth_api.dart b/moneymgr_mobile/lib/services/api/auth_api.dart new file mode 100644 index 0000000..cffa744 --- /dev/null +++ b/moneymgr_mobile/lib/services/api/auth_api.dart @@ -0,0 +1,30 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:moneymgr_mobile/services/api/api_client.dart'; + +part 'auth_api.g.dart'; +part 'auth_api.freezed.dart'; + +@freezed +abstract class AuthInfo with _$AuthInfo { + const factory AuthInfo({ + required int id, + required String mail, + required String name, + required int time_create, + required int time_update, + }) = _AuthInfo; + + factory AuthInfo.fromJson(Map json) => + _$AuthInfoFromJson(json); +} + +/// Auth API +extension AuthApi on ApiClient { + /// Get authentication information + Future authInfo() async { + final response = await execute("/auth/info", method: "GET"); + return AuthInfo.fromJson(response.data); + } +} diff --git a/moneymgr_mobile/lib/services/router/router.dart b/moneymgr_mobile/lib/services/router/router.dart index b8d0dbe..d25631f 100644 --- a/moneymgr_mobile/lib/services/router/router.dart +++ b/moneymgr_mobile/lib/services/router/router.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/providers/auth_state.dart'; import 'package:moneymgr_mobile/routes/login/login_screen.dart'; import 'package:moneymgr_mobile/routes/login/manual_auth_screen.dart'; import 'package:moneymgr_mobile/routes/settings/settings_screen.dart'; -import 'package:moneymgr_mobile/services/auth_state.dart'; import 'package:moneymgr_mobile/services/router/routes_list.dart'; import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; + part 'router.g.dart'; /// The router config for the app. @@ -94,7 +95,8 @@ GoRouter router(Ref ref) { // Check if the current path is allowed for the current auth state. If not, // redirect to the redirect target of the current auth state. - if (authState.allowedPaths?.contains(state.fullPath) == false) { + if (authState.allowedPaths?.contains(state.fullPath) == false || + authState.forbiddenPaths?.contains(state.fullPath) == true) { return authState.redirectPath; } diff --git a/moneymgr_mobile/lib/services/storage/secure_storage.dart b/moneymgr_mobile/lib/services/storage/secure_storage.dart index 4a83fa7..3430a7e 100644 --- a/moneymgr_mobile/lib/services/storage/secure_storage.dart +++ b/moneymgr_mobile/lib/services/storage/secure_storage.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/services/api/api_token.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'secure_storage.g.dart'; @@ -19,11 +22,13 @@ class SecureStorage { const flutterSecureStorage = FlutterSecureStorage(); final cache = {}; await keys - .map((key) => flutterSecureStorage.read(key: key).then((value) { - if (value != null) { - cache[key] = value; - } - })) + .map( + (key) => flutterSecureStorage.read(key: key).then((value) { + if (value != null) { + cache[key] = value; + } + }), + ) .wait; return SecureStorage._(flutterSecureStorage, cache); } @@ -39,4 +44,19 @@ class SecureStorage { _cache.remove(key); return _flutterSecureStorage.delete(key: key); } + + /// Get auth token + ApiToken? token() { + final tokenStr = get("token"); + if (tokenStr != null) { + return ApiToken.fromJson(jsonDecode(tokenStr)); + } + } + + /// Set auth token + Future setToken(ApiToken token) => + set("token", jsonEncode(token.toJson())); + + /// Remove auth token + Future removeToken() => remove("token"); } diff --git a/moneymgr_mobile/lib/utils/string_utils.dart b/moneymgr_mobile/lib/utils/string_utils.dart new file mode 100644 index 0000000..ab06ca3 --- /dev/null +++ b/moneymgr_mobile/lib/utils/string_utils.dart @@ -0,0 +1,12 @@ +import 'dart:math'; + +const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; +final _rnd = Random(); + +/// Generate random string +String getRandomString(int length) => String.fromCharCodes( + Iterable.generate( + length, + (_) => _chars.codeUnitAt(_rnd.nextInt(_chars.length)), + ), +); diff --git a/moneymgr_mobile/lib/utils/time_utils.dart b/moneymgr_mobile/lib/utils/time_utils.dart new file mode 100644 index 0000000..2cb08e6 --- /dev/null +++ b/moneymgr_mobile/lib/utils/time_utils.dart @@ -0,0 +1,3 @@ +int secondsSinceEpoch(DateTime time) { + return time.millisecondsSinceEpoch ~/ 1000; +} \ No newline at end of file diff --git a/moneymgr_mobile/pubspec.lock b/moneymgr_mobile/pubspec.lock index ddbee67..ed4b141 100644 --- a/moneymgr_mobile/pubspec.lock +++ b/moneymgr_mobile/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "82.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analyzer: dependency: transitive description: @@ -217,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0+7.4.5" + dart_jsonwebtoken: + dependency: "direct main" + description: + name: dart_jsonwebtoken + sha256: "21ce9f8a8712f741e8d6876a9c82c0f8a257fe928c4378a91d8527b92a3fd413" + url: "https://pub.dev" + source: hosted + version: "3.2.0" dart_style: dependency: transitive description: @@ -225,6 +241,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" fake_async: dependency: transitive description: @@ -664,6 +704,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: diff --git a/moneymgr_mobile/pubspec.yaml b/moneymgr_mobile/pubspec.yaml index 096d9f7..1ddb4c6 100644 --- a/moneymgr_mobile/pubspec.yaml +++ b/moneymgr_mobile/pubspec.yaml @@ -67,6 +67,12 @@ dependencies: # Logger logging: ^1.3.0 + # API authentication + dart_jsonwebtoken: ^3.2.0 + + # API requests + dio: ^5.8.0+1 + dev_dependencies: flutter_test: sdk: flutter