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/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/router/routes_list.dart';
|
||||||
import 'package:moneymgr_mobile/services/storage/secure_storage.dart';
|
import 'package:moneymgr_mobile/services/storage/secure_storage.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@ -14,25 +16,22 @@ class CurrentAuthState extends _$CurrentAuthState {
|
|||||||
@override
|
@override
|
||||||
AuthState build() {
|
AuthState build() {
|
||||||
final secureStorage = ref.watch(secureStorageProvider).requireValue;
|
final secureStorage = ref.watch(secureStorageProvider).requireValue;
|
||||||
final token = secureStorage.get('token');
|
final token = secureStorage.token();
|
||||||
return token != null ? AuthState.authenticated : AuthState.unauthenticated;
|
return token != null ? AuthState.authenticated : AuthState.unauthenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to authenticate with [data] and saves the token and profile info to storage.
|
/// Attempts to authenticate with [token] and saves the token and profile info to storage.
|
||||||
/// Will invalidate the state if success.
|
/// Will invalidate the state if success and throw an exception in case of failure
|
||||||
Future<void> setAuthToken(ApiToken data) async {
|
Future<void> setAuthToken(ApiToken token) async {
|
||||||
// TODO : perform login
|
// Attempt to use provided token
|
||||||
/*final secureStorage = ref.read(secureStorageProvider).requireValue;
|
await ApiClient(token: token).authInfo();
|
||||||
final token = await ref.read(apiServiceProvider).login(data);
|
|
||||||
|
|
||||||
// Save the new [token] and [profile] to secure storage.
|
final secureStorage = ref.read(secureStorageProvider).requireValue;
|
||||||
secureStorage.set('token', token);
|
secureStorage.setToken(token);
|
||||||
|
|
||||||
ref
|
ref
|
||||||
// Invalidate the state so the auth state will be updated to authenticated.
|
// Invalidate the state so the auth state will be updated to authenticated.
|
||||||
..invalidateSelf()
|
.invalidateSelf();
|
||||||
// Invalidate the token provider so the API service will use the new token.
|
|
||||||
..invalidate(tokenProvider);*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Logs out, deletes the saved token and profile info from storage, and invalidates
|
/// Logs out, deletes the saved token and profile info from storage, and invalidates
|
||||||
@ -60,9 +59,17 @@ enum AuthState {
|
|||||||
redirectPath: authPage,
|
redirectPath: authPage,
|
||||||
allowedPaths: [authPage, manualAuthPage, settingsPage],
|
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
|
/// The target path to redirect when the current route is not allowed in this
|
||||||
/// auth state.
|
/// 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
|
/// List of paths allowed when the app is in this auth state. May be set to null if there is no
|
||||||
/// restriction applicable
|
/// restriction applicable
|
||||||
final List<String>? allowedPaths;
|
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:logging/logging.dart';
|
||||||
import 'package:moneymgr_mobile/routes/login/base_auth_page.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/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/utils/extensions.dart';
|
||||||
import 'package:moneymgr_mobile/widgets/app_button.dart';
|
import 'package:moneymgr_mobile/widgets/app_button.dart';
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ class ManualAuthScreen extends HookConsumerWidget {
|
|||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'API URL',
|
labelText: 'API URL',
|
||||||
helperText:
|
helperText:
|
||||||
"This URL has usually this format: http://moneymgr.corp.com/api",
|
"Format: http://moneymgr.corp.com/api",
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
),
|
),
|
||||||
@ -68,7 +68,7 @@ class ManualAuthScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Gutter(),
|
Gutter(),
|
||||||
TextField(
|
TextField(
|
||||||
controller: tokenIdController,
|
controller: tokenValueController,
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Token value',
|
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';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
/// Client API
|
part 'api_client.g.dart';
|
||||||
@riverpod
|
|
||||||
class ClientAPI {
|
|
||||||
|
|
||||||
|
/// 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({
|
const factory ApiToken({
|
||||||
required String apiUrl,
|
required String apiUrl,
|
||||||
required int tokenId,
|
required int tokenId,
|
||||||
required String tokenValue
|
required String tokenValue,
|
||||||
}) = _ApiToken;
|
}) = _ApiToken;
|
||||||
|
|
||||||
factory ApiToken.fromJson(Map<String, dynamic> json) =>
|
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:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/login_screen.dart';
|
||||||
import 'package:moneymgr_mobile/routes/login/manual_auth_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/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/services/router/routes_list.dart';
|
||||||
import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart';
|
import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'router.g.dart';
|
part 'router.g.dart';
|
||||||
|
|
||||||
/// The router config for the app.
|
/// 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,
|
// Check if the current path is allowed for the current auth state. If not,
|
||||||
// redirect to the redirect target of the current auth state.
|
// 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;
|
return authState.redirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:moneymgr_mobile/services/api/api_token.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'secure_storage.g.dart';
|
part 'secure_storage.g.dart';
|
||||||
@ -19,11 +22,13 @@ class SecureStorage {
|
|||||||
const flutterSecureStorage = FlutterSecureStorage();
|
const flutterSecureStorage = FlutterSecureStorage();
|
||||||
final cache = <String, String>{};
|
final cache = <String, String>{};
|
||||||
await keys
|
await keys
|
||||||
.map((key) => flutterSecureStorage.read(key: key).then((value) {
|
.map(
|
||||||
if (value != null) {
|
(key) => flutterSecureStorage.read(key: key).then((value) {
|
||||||
cache[key] = value;
|
if (value != null) {
|
||||||
}
|
cache[key] = value;
|
||||||
}))
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
.wait;
|
.wait;
|
||||||
return SecureStorage._(flutterSecureStorage, cache);
|
return SecureStorage._(flutterSecureStorage, cache);
|
||||||
}
|
}
|
||||||
@ -39,4 +44,19 @@ class SecureStorage {
|
|||||||
_cache.remove(key);
|
_cache.remove(key);
|
||||||
return _flutterSecureStorage.delete(key: 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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "82.0.0"
|
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:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -217,6 +225,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0+7.4.5"
|
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:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -225,6 +241,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -664,6 +704,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -67,6 +67,12 @@ dependencies:
|
|||||||
# Logger
|
# Logger
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
|
|
||||||
|
# API authentication
|
||||||
|
dart_jsonwebtoken: ^3.2.0
|
||||||
|
|
||||||
|
# API requests
|
||||||
|
dio: ^5.8.0+1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
Reference in New Issue
Block a user