Performed first authentication
This commit is contained in:
		@@ -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<void> 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<void> 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);*/
 | 
			
		||||
        .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<String>? 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<String>? forbiddenPaths;
 | 
			
		||||
}
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
/// Account API
 | 
			
		||||
class AccountAPI {
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -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';
 | 
			
		||||
 | 
			
		||||
/// 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<Response<T>> execute<T>(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
 | 
			
		||||
}
 | 
			
		||||
@@ -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<String, dynamic> json) =>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								moneymgr_mobile/lib/services/api/auth_api.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								moneymgr_mobile/lib/services/api/auth_api.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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<String, dynamic> json) =>
 | 
			
		||||
      _$AuthInfoFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Auth API
 | 
			
		||||
extension AuthApi on ApiClient {
 | 
			
		||||
  /// Get authentication information
 | 
			
		||||
  Future<AuthInfo> authInfo() async {
 | 
			
		||||
    final response = await execute("/auth/info", method: "GET");
 | 
			
		||||
    return AuthInfo.fromJson(response.data);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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 = <String, String>{};
 | 
			
		||||
    await keys
 | 
			
		||||
        .map((key) => flutterSecureStorage.read(key: key).then((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<void> setToken(ApiToken token) =>
 | 
			
		||||
      set("token", jsonEncode(token.toJson()));
 | 
			
		||||
 | 
			
		||||
  /// Remove auth token
 | 
			
		||||
  Future<void> removeToken() => remove("token");
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								moneymgr_mobile/lib/utils/string_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								moneymgr_mobile/lib/utils/string_utils.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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)),
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										3
									
								
								moneymgr_mobile/lib/utils/time_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								moneymgr_mobile/lib/utils/time_utils.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
int secondsSinceEpoch(DateTime time) {
 | 
			
		||||
  return time.millisecondsSinceEpoch ~/ 1000;
 | 
			
		||||
}
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user