From 7cf67d898fd3f7405d2c0ebbc2aa2444e00f0dde Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Mon, 17 Apr 2023 08:51:21 +0200 Subject: [PATCH 1/3] migrate to appPassword --- lib/src/blocs/login/login_bloc.dart | 23 ++--- lib/src/blocs/login/login_event.dart | 4 +- lib/src/models/app_authentication.dart | 42 +++------ lib/src/models/app_authentication.g.dart | 4 +- lib/src/screens/form/login_form.dart | 5 +- lib/src/services/api_provider.dart | 2 +- lib/src/services/authentication_provider.dart | 93 ++----------------- lib/src/services/services.dart | 1 - lib/src/services/user_repository.dart | 30 ++---- lib/src/widget/user_image.dart | 36 +++---- pubspec.yaml | 2 - test/models/app_authentication_test.dart | 12 +-- 12 files changed, 60 insertions(+), 194 deletions(-) diff --git a/lib/src/blocs/login/login_bloc.dart b/lib/src/blocs/login/login_bloc.dart index eb64c913..85d1e27a 100644 --- a/lib/src/blocs/login/login_bloc.dart +++ b/lib/src/blocs/login/login_bloc.dart @@ -1,7 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; @@ -24,24 +23,14 @@ class LoginBloc extends Bloc { emit(LoginState(status: LoginStatus.loading)); try { - AppAuthentication appAuthentication; assert(URLUtils.isSanitized(event.serverURL)); - if (!event.isAppPassword) { - appAuthentication = await userRepository.authenticate( - event.serverURL, - event.username, - event.originalBasicAuth, - isSelfSignedCertificate: event.isSelfSignedCertificate, - ); - } else { - appAuthentication = await userRepository.authenticateAppPassword( - event.serverURL, - event.username, - event.originalBasicAuth, - isSelfSignedCertificate: event.isSelfSignedCertificate, - ); - } + final appAuthentication = await userRepository.authenticateAppPassword( + event.serverURL, + event.username, + event.appPassword, + isSelfSignedCertificate: event.isSelfSignedCertificate, + ); authenticationBloc.add(LoggedIn(appAuthentication: appAuthentication)); emit(LoginState()); diff --git a/lib/src/blocs/login/login_event.dart b/lib/src/blocs/login/login_event.dart index 6d3db364..e9de6afc 100644 --- a/lib/src/blocs/login/login_event.dart +++ b/lib/src/blocs/login/login_event.dart @@ -11,13 +11,13 @@ class LoginButtonPressed extends LoginEvent { const LoginButtonPressed({ required this.serverURL, required this.username, - required this.originalBasicAuth, + required this.appPassword, required this.isAppPassword, required this.isSelfSignedCertificate, }); final String serverURL; final String username; - final String originalBasicAuth; + final String appPassword; final bool isAppPassword; final bool isSelfSignedCertificate; diff --git a/lib/src/models/app_authentication.dart b/lib/src/models/app_authentication.dart index bae84a47..a5d24f49 100644 --- a/lib/src/models/app_authentication.dart +++ b/lib/src/models/app_authentication.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -9,26 +7,12 @@ part 'app_authentication.g.dart'; @JsonSerializable() class AppAuthentication extends Equatable { - AppAuthentication({ + const AppAuthentication({ required this.server, required this.loginName, - required this.basicAuth, + required this.appPassword, required this.isSelfSignedCertificate, - }) { - authenticatedClient.options - ..headers['authorization'] = basicAuth - ..headers['User-Agent'] = 'Cookbook App' - ..responseType = ResponseType.plain; - - if (isSelfSignedCertificate) { - authenticatedClient.httpClientAdapter = IOHttpClientAdapter( - onHttpClientCreate: (client) { - client.badCertificateCallback = (cert, host, port) => true; - return client; - }, - ); - } - } + }); factory AppAuthentication.fromJsonString(String jsonString) => AppAuthentication.fromJson( @@ -40,10 +24,9 @@ class AppAuthentication extends Equatable { return _$AppAuthenticationFromJson(jsonData); // ignore: avoid_catching_errors } on TypeError { - final basicAuth = parseBasicAuth( - jsonData['loginName'] as String, - jsonData['appPassword'] as String, - ); + final basicAuth = jsonData['basicAuth'] as String?; + final appPassword = + jsonData['appPassword'] as String? ?? parseBasicAuth(basicAuth!); final selfSignedCertificate = jsonData['isSelfSignedCertificate'] as bool? ?? false; @@ -51,30 +34,27 @@ class AppAuthentication extends Equatable { return AppAuthentication( server: jsonData['server'] as String, loginName: jsonData['loginName'] as String, - basicAuth: basicAuth, + appPassword: appPassword, isSelfSignedCertificate: selfSignedCertificate, ); } } final String server; final String loginName; - final String basicAuth; + final String appPassword; final bool isSelfSignedCertificate; - final Dio authenticatedClient = Dio(); - String toJsonString() => json.encode(toJson()); Map toJson() => _$AppAuthenticationToJson(this); - String get password { + static String parseBasicAuth(String basicAuth) { final base64 = basicAuth.substring(6); final string = utf8.decode(base64Decode(base64)); final auth = string.split(':'); return auth[1]; } - static String parseBasicAuth(String loginName, String appPassword) => - 'Basic ${base64Encode(utf8.encode('$loginName:$appPassword'))}'; + static String parsePassword(String loginName, String appPassword) => 'Basic ${base64Encode(utf8.encode('$loginName:$appPassword'))}'; @override String toString() => @@ -84,7 +64,7 @@ class AppAuthentication extends Equatable { List get props => [ server, loginName, - basicAuth, + appPassword, isSelfSignedCertificate, ]; } diff --git a/lib/src/models/app_authentication.g.dart b/lib/src/models/app_authentication.g.dart index 2edf4cbd..ed6c504f 100644 --- a/lib/src/models/app_authentication.g.dart +++ b/lib/src/models/app_authentication.g.dart @@ -10,7 +10,7 @@ AppAuthentication _$AppAuthenticationFromJson(Map json) => AppAuthentication( server: json['server'] as String, loginName: json['loginName'] as String, - basicAuth: json['basicAuth'] as String, + appPassword: json['appPassword'] as String, isSelfSignedCertificate: json['isSelfSignedCertificate'] as bool, ); @@ -18,6 +18,6 @@ Map _$AppAuthenticationToJson(AppAuthentication instance) => { 'server': instance.server, 'loginName': instance.loginName, - 'basicAuth': instance.basicAuth, + 'appPassword': instance.appPassword, 'isSelfSignedCertificate': instance.isSelfSignedCertificate, }; diff --git a/lib/src/screens/form/login_form.dart b/lib/src/screens/form/login_form.dart index 722c9569..7b0e8b31 100644 --- a/lib/src/screens/form/login_form.dart +++ b/lib/src/screens/form/login_form.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/login/login_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; @@ -68,14 +67,12 @@ class _LoginFormState extends State with WidgetsBindingObserver { final serverUrl = URLUtils.sanitizeUrl(_serverUrl.text); final username = _username.text.trim(); final password = _password.text.trim(); - final originalBasicAuth = - AppAuthentication.parseBasicAuth(username, password); BlocProvider.of(context).add( LoginButtonPressed( serverURL: serverUrl, username: username, - originalBasicAuth: originalBasicAuth, + appPassword: password, isAppPassword: advancedIsAppPassword, isSelfSignedCertificate: advancedIsSelfSignedCertificate, ), diff --git a/lib/src/services/api_provider.dart b/lib/src/services/api_provider.dart index 6c8705ed..3e771805 100644 --- a/lib/src/services/api_provider.dart +++ b/lib/src/services/api_provider.dart @@ -30,7 +30,7 @@ class ApiProvider { ncCookbookApi.setBasicAuth( 'app_password', auth.loginName, - auth.password, + auth.appPassword, ); recipeApi = ncCookbookApi.getRecipesApi(); categoryApi = ncCookbookApi.getCategoriesApi(); diff --git a/lib/src/services/authentication_provider.dart b/lib/src/services/authentication_provider.dart index 4f7c47cf..c19438c5 100644 --- a/lib/src/services/authentication_provider.dart +++ b/lib/src/services/authentication_provider.dart @@ -6,91 +6,6 @@ class AuthenticationProvider { AppAuthentication? currentAppAuthentication; dio.CancelToken? _cancelToken; - Future authenticate({ - required String serverUrl, - required String username, - required String originalBasicAuth, - required bool isSelfSignedCertificate, - }) async { - assert(URLUtils.isSanitized(serverUrl)); - - final urlInitialCall = '$serverUrl/ocs/v2.php/core/getapppassword'; - - dio.Response response; - try { - final client = dio.Dio(); - if (isSelfSignedCertificate) { - client.httpClientAdapter = IOHttpClientAdapter( - onHttpClientCreate: (client) { - client.badCertificateCallback = (cert, host, port) => true; - return client; - }, - ); - } - - response = await client.get( - urlInitialCall, - options: dio.Options( - headers: { - 'OCS-APIREQUEST': 'true', - 'User-Agent': 'Cookbook App', - 'authorization': originalBasicAuth - }, - validateStatus: (status) => status! < 500, - ), - cancelToken: _cancelToken, - ); - } on dio.DioError catch (e) { - if (e.message?.contains('SocketException') ?? false) { - throw translate( - 'login.errors.not_reachable', - args: {'server_url': serverUrl, 'error_msg': e}, - ); - } else if (e.message?.contains('CERTIFICATE_VERIFY_FAILED') ?? false) { - throw translate( - 'login.errors.certificate_failed', - args: {'server_url': serverUrl, 'error_msg': e}, - ); - } - throw translate('login.errors.request_failed', args: {'error_msg': e}); - } - _cancelToken = null; - - if (response.statusCode == 200) { - String appPassword; - try { - appPassword = XmlDocument.parse(response.data as String) - .findAllElements('apppassword') - .first - .text; - } on XmlParserException catch (e) { - throw translate('login.errors.parse_failed', args: {'error_msg': e}); - // ignore: avoid_catching_errors - } on StateError catch (e) { - throw translate('login.errors.parse_missing', args: {'error_msg': e}); - } - - final basicAuth = AppAuthentication.parseBasicAuth(username, appPassword); - - return AppAuthentication( - server: serverUrl, - loginName: username, - basicAuth: basicAuth, - isSelfSignedCertificate: isSelfSignedCertificate, - ); - } else if (response.statusCode == 401) { - throw translate('login.errors.auth_failed'); - } else { - throw translate( - 'login.errors.failure', - args: { - 'status_code': response.statusCode, - 'status_message': response.statusMessage, - }, - ); - } - } - Future authenticateAppPassword({ required String serverUrl, required String username, @@ -125,7 +40,7 @@ class AuthenticationProvider { return AppAuthentication( server: serverUrl, loginName: username, - basicAuth: basicAuth, + appPassword: basicAuth, isSelfSignedCertificate: isSelfSignedCertificate, ); } else { @@ -221,10 +136,14 @@ class AuthenticationProvider { Future deleteAppAuthentication() async { dio.Response? response; try { - response = await currentAppAuthentication?.authenticatedClient.delete( + response = await Dio().delete( '${currentAppAuthentication!.server}/ocs/v2.php/core/apppassword', options: dio.Options( headers: { + 'Authorization': AppAuthentication.parsePassword( + currentAppAuthentication!.loginName, + currentAppAuthentication!.appPassword, + ), 'OCS-APIREQUEST': 'true', }, ), diff --git a/lib/src/services/services.dart b/lib/src/services/services.dart index 82830441..74a19d4b 100644 --- a/lib/src/services/services.dart +++ b/lib/src/services/services.dart @@ -19,7 +19,6 @@ import 'package:nextcloud_cookbook_flutter/src/screens/recipe_import_screen.dart import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; import 'package:timezone/data/latest_10y.dart' as tz; import 'package:timezone/timezone.dart' as tz; -import 'package:xml/xml.dart'; part 'api_provider.dart'; part 'authentication_provider.dart'; diff --git a/lib/src/services/user_repository.dart b/lib/src/services/user_repository.dart index c2158533..84bd5c94 100644 --- a/lib/src/services/user_repository.dart +++ b/lib/src/services/user_repository.dart @@ -9,19 +9,6 @@ class UserRepository { AuthenticationProvider authenticationProvider = AuthenticationProvider(); - Future authenticate( - String serverUrl, - String username, - String originalBasicAuth, { - required bool isSelfSignedCertificate, - }) async => - authenticationProvider.authenticate( - serverUrl: serverUrl, - username: username, - originalBasicAuth: originalBasicAuth, - isSelfSignedCertificate: isSelfSignedCertificate, - ); - Future authenticateAppPassword( String serverUrl, String username, @@ -42,21 +29,16 @@ class UserRepository { AppAuthentication get currentAppAuthentication => authenticationProvider.currentAppAuthentication!; - Dio get authenticatedClient => currentAppAuthentication.authenticatedClient; - - Future hasAppAuthentication() async => - authenticationProvider.hasAppAuthentication(); + Future hasAppAuthentication() async => authenticationProvider.hasAppAuthentication(); Future loadAppAuthentication() async => authenticationProvider.loadAppAuthentication(); - Future checkAppAuthentication() async => - authenticationProvider.checkAppAuthentication( - currentAppAuthentication.server, - currentAppAuthentication.basicAuth, - isSelfSignedCertificate: - currentAppAuthentication.isSelfSignedCertificate, - ); + Future checkAppAuthentication() async => authenticationProvider.checkAppAuthentication( + currentAppAuthentication.server, + currentAppAuthentication.appPassword, + isSelfSignedCertificate: currentAppAuthentication.isSelfSignedCertificate, + ); Future persistAppAuthentication( AppAuthentication appAuthentication, diff --git a/lib/src/widget/user_image.dart b/lib/src/widget/user_image.dart index b88a7f95..78846705 100644 --- a/lib/src/widget/user_image.dart +++ b/lib/src/widget/user_image.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; import 'package:nextcloud_cookbook_flutter/src/util/custom_cache_manager.dart'; @@ -11,26 +12,27 @@ class UserImage extends StatelessWidget { @override Widget build(BuildContext context) { final url = DataRepository().getUserAvatarUrl(); - final appAuthentication = UserRepository().currentAppAuthentication; + final auth = UserRepository().currentAppAuthentication; - return ClipOval( - child: CachedNetworkImage( - cacheManager: CustomCacheManager().instance, - cacheKey: 'avatar', - fit: BoxFit.fill, - httpHeaders: { - 'Authorization': appAuthentication.basicAuth, - 'Accept': 'image/jpeg' - }, - imageUrl: url, - placeholder: (context, url) => ColoredBox( - color: Colors.grey[400]!, - child: const Center( + return CircleAvatar( + backgroundColor: Colors.grey[400], + child: ClipOval( + child: CachedNetworkImage( + cacheManager: CustomCacheManager().instance, + cacheKey: 'avatar', + fit: BoxFit.fill, + httpHeaders: { + 'Authorization': AppAuthentication.parsePassword( + auth.loginName, auth.appPassword,), + 'Accept': 'image/jpeg' + }, + imageUrl: url, + placeholder: (context, url) => const Center( child: CircularProgressIndicator(), ), - ), - errorWidget: (context, url, error) => ColoredBox( - color: Colors.grey[400]!, + errorWidget: (context, url, error) => ColoredBox( + color: Colors.grey[400]!, + ), ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index ac3e3bc3..4f32bc78 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,8 +67,6 @@ dependencies: # Screen always on wakelock: ^0.6.1 - xml: ^6.1.0 - flutter_spinkit: ^5.0.0 validators: ^3.0.0 diff --git a/test/models/app_authentication_test.dart b/test/models/app_authentication_test.dart index 389d880e..67da16b2 100644 --- a/test/models/app_authentication_test.dart +++ b/test/models/app_authentication_test.dart @@ -14,15 +14,15 @@ void main() { )}'; const isSelfSignedCertificate = false; - final auth = AppAuthentication( + const auth = AppAuthentication( server: server, loginName: loginName, - basicAuth: basicAuth, + appPassword: password, isSelfSignedCertificate: isSelfSignedCertificate, ); - final encodedJson = - '"{\\"server\\":\\"$server\\",\\"loginName\\":\\"$loginName\\",\\"basicAuth\\":\\"$basicAuth\\",\\"isSelfSignedCertificate\\":$isSelfSignedCertificate}"'; + const encodedJson = + '"{\\"server\\":\\"$server\\",\\"loginName\\":\\"$loginName\\",\\"appPassword\\":\\"$password\\",\\"isSelfSignedCertificate\\":$isSelfSignedCertificate}"'; final jsonBasicAuth = '{"server":"$server","loginName":"$loginName","basicAuth":"$basicAuth","isSelfSignedCertificate":$isSelfSignedCertificate}'; const jsonPassword = @@ -45,12 +45,12 @@ void main() { }); test('password', () { - expect(auth.password, equals(password)); + expect(auth.appPassword, equals(password)); }); test('parseBasicAuth', () { expect( - AppAuthentication.parseBasicAuth(loginName, password), + AppAuthentication.parsePassword(loginName, password), equals(basicAuth), ); }); From cd92f95b71999c733a17b85836d110d9e2b922c5 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Mon, 17 Apr 2023 09:01:06 +0200 Subject: [PATCH 2/3] use LoginFlowV2 --- android/app/build.gradle | 2 +- assets/icon.svg.vec | Bin 0 -> 1152 bytes lib/main.dart | 2 +- .../authentication/authentication_bloc.dart | 64 ++++- .../authentication_exception.dart | 5 + .../authentication/authentication_state.dart | 12 +- lib/src/blocs/login/login_bloc.dart | 57 +++-- lib/src/blocs/login/login_event.dart | 23 +- lib/src/blocs/login/login_state.dart | 7 +- lib/src/screens/form/login_form.dart | 241 ------------------ lib/src/screens/login_screen.dart | 174 ++++++++++--- lib/src/services/api_provider.dart | 7 + lib/src/services/authentication_provider.dart | 162 ------------ lib/src/services/services.dart | 7 +- lib/src/services/user_repository.dart | 84 +++--- pubspec.yaml | 12 +- 16 files changed, 320 insertions(+), 539 deletions(-) create mode 100644 assets/icon.svg.vec create mode 100644 lib/src/blocs/authentication/authentication_exception.dart delete mode 100644 lib/src/screens/form/login_form.dart delete mode 100644 lib/src/services/authentication_provider.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index faf8867c..85b5e8ad 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,7 +36,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.nextcloud_cookbook_flutter" - minSdkVersion 18 + minSdkVersion 19 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/assets/icon.svg.vec b/assets/icon.svg.vec new file mode 100644 index 0000000000000000000000000000000000000000..5a04782b4305d7a37db8802ac48cdff14ccf4850 GIT binary patch literal 1152 zcmZXUX-Je&6ox+?Ew|iqO*4tKM9gK}HBG-`*(9i$E2d&*nhKdlNovXD*_|Z%{qdQKj*V%|IZ_5F*8~ebBH~lnd6G!S7cSxm+A!Wr~`cfE0 zbGrvAt1_IF>x0z&IS8Dz@BkGx?qFq$E8_M9IU{fQlRfHe2HFwVWpW=T_gxRPf{tWQ zbLet3HG$6AZ${7^eM*5jm^>Gg=VUrvnU^~PjHtP3Mvk5}CZ%#({@S>m=4Ri^0rjS0 z{Z4j#X%7CfwgltNtg*j2Y=iiFxjT{1dbl2w>ocFndO@eQ#hX&h>}gT$Lz}}6)OpaC zhI5@LcG`~&GIvqukUuT=x`GpHZlLX=JN9+0dl7#&WFP$y`8si41EL<2>od6@llwa^ zdO*))>wf6F6ygQFPyM{1zyGoi*!;{FoEq`RzNSEl_+YCbr3NYLCUfDv}Vtu@Z-QRN_u}9wygkaz7^bA80=c zJz0^*pwDz6w~ZB-li&|d?Vg*MZYh#PBbgl}z^ z8sOW}9S!g;lYPq^|4>ICZD?QK zt*QapTl{-}uL%`sN@=6)x12LkNz%{na)xOQSzMTr>#NjcEBreoH-i^lv|xU87ib*W kO^=1o^K5#=dRl(8(nGc4J;)R1z~{u|d+>APe+Rw(4?|?VjQ{`u literal 0 HcmV?d00001 diff --git a/lib/main.dart b/lib/main.dart index e9ae2277..b9037415 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -112,7 +112,7 @@ class _AppState extends State { return const LoginScreen(); case AuthenticationStatus.invalid: return const LoginScreen( - invalidCredentials: true, + //invalidCredentials: true, ); case AuthenticationStatus.error: return LoadingErrorScreen(message: state.error!); diff --git a/lib/src/blocs/authentication/authentication_bloc.dart b/lib/src/blocs/authentication/authentication_bloc.dart index a7346b01..9b7e0124 100644 --- a/lib/src/blocs/authentication/authentication_bloc.dart +++ b/lib/src/blocs/authentication/authentication_bloc.dart @@ -1,14 +1,16 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; part 'authentication_event.dart'; +part 'authentication_exception.dart'; part 'authentication_state.dart'; class AuthenticationBloc extends Bloc { - AuthenticationBloc() : super(AuthenticationState()) { + AuthenticationBloc() : super(const AuthenticationState()) { on(_mapAppStartedEventToState); on(_mapLoggedInEventToState); on(_mapLoggedOutEventToState); @@ -19,19 +21,28 @@ class AuthenticationBloc AppStarted event, Emitter emit, ) async { - final hasToken = await userRepository.hasAppAuthentication(); - - if (hasToken) { - await userRepository.loadAppAuthentication(); + if (userRepository.hasAuthentidation) { try { + await userRepository.loadAppAuthentication(); + final validCredentials = await userRepository.checkAppAuthentication(); if (validCredentials) { - emit(AuthenticationState(status: AuthenticationStatus.authenticated)); + final apiVersion = await UserRepository().fetchApiVersion(); + emit( + AuthenticationState( + status: AuthenticationStatus.authenticated, + apiVersion: apiVersion, + ), + ); } else { await userRepository.deleteAppAuthentication(); - emit(AuthenticationState(status: AuthenticationStatus.invalid)); + emit(const AuthenticationState(status: AuthenticationStatus.invalid)); } + } on LoadAuthException { + emit( + const AuthenticationState(status: AuthenticationStatus.authenticated), + ); } catch (e) { emit( AuthenticationState( @@ -41,7 +52,11 @@ class AuthenticationBloc ); } } else { - emit(AuthenticationState(status: AuthenticationStatus.unauthenticated)); + emit( + const AuthenticationState( + status: AuthenticationStatus.unauthenticated, + ), + ); } } @@ -49,17 +64,38 @@ class AuthenticationBloc LoggedIn event, Emitter emit, ) async { - emit(AuthenticationState()); - await userRepository.persistAppAuthentication(event.appAuthentication); - emit(AuthenticationState(status: AuthenticationStatus.authenticated)); + emit(const AuthenticationState()); + try { + await userRepository.persistAppAuthentication(event.appAuthentication); + + final apiVersion = await UserRepository().fetchApiVersion(); + emit( + AuthenticationState( + status: AuthenticationStatus.authenticated, + apiVersion: apiVersion, + ), + ); + } catch (e) { + emit( + AuthenticationState( + status: AuthenticationStatus.error, + error: e.toString(), + ), + ); + } } Future _mapLoggedOutEventToState( LoggedOut event, Emitter emit, ) async { - emit(AuthenticationState()); - await userRepository.deleteAppAuthentication(); - emit(AuthenticationState(status: AuthenticationStatus.unauthenticated)); + emit(const AuthenticationState()); + try { + await userRepository.deleteAppAuthentication(); + } finally { + emit( + const AuthenticationState(status: AuthenticationStatus.unauthenticated), + ); + } } } diff --git a/lib/src/blocs/authentication/authentication_exception.dart b/lib/src/blocs/authentication/authentication_exception.dart new file mode 100644 index 00000000..f2714a4e --- /dev/null +++ b/lib/src/blocs/authentication/authentication_exception.dart @@ -0,0 +1,5 @@ +part of 'authentication_bloc.dart'; + +abstract class AuthException implements Exception {} + +class LoadAuthException extends AuthException {} diff --git a/lib/src/blocs/authentication/authentication_state.dart b/lib/src/blocs/authentication/authentication_state.dart index d1bfa5e1..ff5331ea 100644 --- a/lib/src/blocs/authentication/authentication_state.dart +++ b/lib/src/blocs/authentication/authentication_state.dart @@ -25,13 +25,17 @@ class AuthenticationState extends Equatable { const AuthenticationState({ this.status = AuthenticationStatus.loading, this.error, - }) : assert( - (status != AuthenticationStatus.error && error == null) || - (status == AuthenticationStatus.error && error != null), + this.apiVersion, + }) : assert(error == null || status == AuthenticationStatus.error), + assert( + apiVersion == null || status == AuthenticationStatus.authenticated, ); final AuthenticationStatus status; final String? error; + /// The [APIVersion] authenticated against + final APIVersion? apiVersion; + @override - List get props => [status, error]; + List get props => [status, error, apiVersion]; } diff --git a/lib/src/blocs/login/login_bloc.dart b/lib/src/blocs/login/login_bloc.dart index 85d1e27a..d055d549 100644 --- a/lib/src/blocs/login/login_bloc.dart +++ b/lib/src/blocs/login/login_bloc.dart @@ -1,6 +1,11 @@ +import 'dart:async'; + import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; @@ -10,37 +15,43 @@ part 'login_state.dart'; class LoginBloc extends Bloc { LoginBloc({ required this.authenticationBloc, - }) : super(LoginState()) { - on(_mapLoginButtonPressedEventToState); + }) : super(const LoginState()) { + on(_mapLoginFlowStartEventToState); } final UserRepository userRepository = UserRepository(); final AuthenticationBloc authenticationBloc; - Future _mapLoginButtonPressedEventToState( - LoginButtonPressed event, + Future _mapLoginFlowStartEventToState( + LoginFlowStart event, Emitter emit, ) async { - emit(LoginState(status: LoginStatus.loading)); - + assert(URLUtils.isSanitized(event.serverURL)); try { - assert(URLUtils.isSanitized(event.serverURL)); - - final appAuthentication = await userRepository.authenticateAppPassword( - event.serverURL, - event.username, - event.appPassword, - isSelfSignedCertificate: event.isSelfSignedCertificate, - ); + final client = NextcloudClient(event.serverURL); + final init = await client.core.initLoginFlow(); + emit(LoginState(status: LoginStatus.loading, url: init.login)); + Timer.periodic(const Duration(seconds: 2), (timer) async { + try { + final result = + await client.core.getLoginFlowResult(token: init.poll.token); + timer.cancel(); - authenticationBloc.add(LoggedIn(appAuthentication: appAuthentication)); - emit(LoginState()); - } catch (error) { - emit( - LoginState( - status: LoginStatus.failure, - error: error.toString(), - ), - ); + authenticationBloc.add( + LoggedIn( + appAuthentication: AppAuthentication( + server: result.server, + loginName: result.loginName, + appPassword: result.appPassword, + isSelfSignedCertificate: false, + ), + ), + ); + } catch (e) { + debugPrint(e.toString()); + } + }); + } catch (e) { + emit(LoginState(status: LoginStatus.failure, error: e.toString())); } } } diff --git a/lib/src/blocs/login/login_event.dart b/lib/src/blocs/login/login_event.dart index e9de6afc..f1035e60 100644 --- a/lib/src/blocs/login/login_event.dart +++ b/lib/src/blocs/login/login_event.dart @@ -7,25 +7,8 @@ abstract class LoginEvent extends Equatable { List get props => []; } -class LoginButtonPressed extends LoginEvent { - const LoginButtonPressed({ - required this.serverURL, - required this.username, - required this.appPassword, - required this.isAppPassword, - required this.isSelfSignedCertificate, - }); - final String serverURL; - final String username; - final String appPassword; - final bool isAppPassword; - final bool isSelfSignedCertificate; - - @override - List get props => - [serverURL, username, isAppPassword, isSelfSignedCertificate]; +class LoginFlowStart extends LoginEvent { + const LoginFlowStart(this.serverURL); - @override - String toString() => - 'LoginButtonPressed {serverURL: $serverURL, username: $username, isAppPassword: $isAppPassword}, isSelfSignedCertificate: $isSelfSignedCertificate'; + final String serverURL; } diff --git a/lib/src/blocs/login/login_state.dart b/lib/src/blocs/login/login_state.dart index baf57287..465e5c43 100644 --- a/lib/src/blocs/login/login_state.dart +++ b/lib/src/blocs/login/login_state.dart @@ -10,12 +10,11 @@ class LoginState extends Equatable { const LoginState({ this.status = LoginStatus.initial, this.error, - }) : assert( - (status != LoginStatus.failure && error == null) || - (status == LoginStatus.failure && error != null), - ); + this.url, + }) : assert(error == null || status == LoginStatus.failure); final LoginStatus status; final String? error; + final String? url; @override List get props => [status, error]; diff --git a/lib/src/screens/form/login_form.dart b/lib/src/screens/form/login_form.dart deleted file mode 100644 index 7b0e8b31..00000000 --- a/lib/src/screens/form/login_form.dart +++ /dev/null @@ -1,241 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/login/login_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; -import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; -import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/checkbox_form_field.dart'; - -class LoginForm extends StatefulWidget { - const LoginForm({super.key}); - - @override - State createState() => _LoginFormState(); -} - -// ignore: prefer_mixin -class _LoginFormState extends State with WidgetsBindingObserver { - final _serverUrl = TextEditingController(); - final _username = TextEditingController(); - final _password = TextEditingController(); - bool advancedSettingsExpanded = false; - bool advancedIsAppPassword = false; - bool advancedIsSelfSignedCertificate = false; - // Create a global key that uniquely identifies the Form widget - // and allows validation of the form. - // - // Note: This is a `GlobalKey`, - // not a GlobalKey. - final _formKey = GlobalKey(); - - late Function() authenticateInterruptCallback; - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - authenticateInterruptCallback(); - } - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _serverUrl.dispose(); - _username.dispose(); - _password.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - authenticateInterruptCallback = () { - UserRepository().stopAuthenticate(); - }; - - void onLoginButtonPressed() { - _formKey.currentState?.save(); - - if (_formKey.currentState?.validate() ?? false) { - final serverUrl = URLUtils.sanitizeUrl(_serverUrl.text); - final username = _username.text.trim(); - final password = _password.text.trim(); - - BlocProvider.of(context).add( - LoginButtonPressed( - serverURL: serverUrl, - username: username, - appPassword: password, - isAppPassword: advancedIsAppPassword, - isSelfSignedCertificate: advancedIsSelfSignedCertificate, - ), - ); - } - } - - return BlocListener( - listener: (context, state) { - if (state.status == LoginStatus.failure) { - final theme = - Theme.of(context).extension()!.errorSnackBar; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.error!, - style: theme.contentTextStyle, - ), - backgroundColor: theme.backgroundColor, - ), - ); - } - }, - child: BlocBuilder( - builder: (context, state) => SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8), - child: Form( - // Build a Form widget using the _formKey created above. - key: _formKey, - child: AutofillGroup( - child: Column( - children: [ - TextFormField( - decoration: InputDecoration( - labelText: translate('login.server_url.field'), - ), - controller: _serverUrl, - keyboardType: TextInputType.url, - validator: (value) { - if (value == null || value.isEmpty) { - return translate( - 'login.server_url.validator.empty', - ); - } - - if (!URLUtils.isValid(value)) { - return translate( - 'login.server_url.validator.pattern', - ); - } - return null; - }, - textInputAction: TextInputAction.next, - autofillHints: const [ - AutofillHints.url, - AutofillHints.name - ], - ), - TextFormField( - decoration: InputDecoration( - labelText: translate('login.username.field'), - ), - controller: _username, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.username], - ), - TextFormField( - decoration: InputDecoration( - labelText: translate('login.password.field'), - ), - controller: _password, - obscureText: true, - onFieldSubmitted: (val) { - if (state.status != LoginStatus.loading) { - onLoginButtonPressed(); - } - }, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.password], - ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: ExpansionPanelList( - expandedHeaderPadding: EdgeInsets.zero, - expansionCallback: (index, isExpanded) { - setState(() { - advancedSettingsExpanded = !isExpanded; - }); - }, - children: [ - ExpansionPanel( - isExpanded: advancedSettingsExpanded, - body: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - children: [ - CheckboxFormField( - initialValue: advancedIsAppPassword, - onSaved: (checked) { - if (checked == null) { - return; - } - setState(() { - advancedIsAppPassword = checked; - }); - }, - title: Text( - translate( - 'login.settings.app_password', - ), - ), - ), - CheckboxFormField( - initialValue: - advancedIsSelfSignedCertificate, - onSaved: (checked) { - if (checked == null) { - return; - } - - setState(() { - advancedIsSelfSignedCertificate = - checked; - }); - }, - title: Text( - translate( - 'login.settings.self_signed_certificate', - ), - ), - ), - ], - ), - ), - headerBuilder: (context, isExpanded) => Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 16), - child: Text(translate('login.settings.title')), - ), - ), - ) - ], - ), - ), - ElevatedButton( - onPressed: state.status != LoginStatus.loading - ? onLoginButtonPressed - : null, - child: Text(translate('login.button')), - ), - if (state.status == LoginStatus.loading) - SpinKitWave( - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/src/screens/login_screen.dart b/lib/src/screens/login_screen.dart index 8d6f10b6..633192c1 100644 --- a/lib/src/screens/login_screen.dart +++ b/lib/src/screens/login_screen.dart @@ -1,50 +1,160 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_translate/flutter_translate.dart'; - import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; + import 'package:nextcloud_cookbook_flutter/src/blocs/login/login_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/form/login_form.dart'; -import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; +import 'package:vector_graphics/vector_graphics.dart'; +import 'package:webview_flutter/webview_flutter.dart'; -class LoginScreen extends StatelessWidget { +class LoginScreen extends StatefulWidget { const LoginScreen({ super.key, - this.invalidCredentials = false, }); - final bool invalidCredentials; @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text(translate('login.title')), - ), - body: BlocProvider( - create: (context) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - notifyIfInvalidCredentials(context); - }); - return LoginBloc( - authenticationBloc: BlocProvider.of(context), - ); - }, - child: const LoginForm(), - ), + _LoginScreenState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _focusNode = FocusNode(); + late final LoginBloc _loginBloc; + + final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..enableZoom(false) + ..setUserAgent('CookbookFlutter'); + + @override + void didChangeDependencies() { + unawaited( + controller.setBackgroundColor(Theme.of(context).scaffoldBackgroundColor), + ); + + final authBloc = BlocProvider.of(context); + _loginBloc = LoginBloc(authenticationBloc: authBloc); + super.didChangeDependencies(); + } + + void onSubmit([String? value]) { + _formKey.currentState!.save(); + } + + @override + Widget build(BuildContext context) => BlocConsumer( + bloc: _loginBloc, + listener: listener, + builder: builder, ); - void notifyIfInvalidCredentials(BuildContext context) { - if (invalidCredentials) { - final theme = - Theme.of(context).extension()!.errorSnackBar; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate('login.errors.credentials_invalid'), - style: theme.contentTextStyle, - ), - backgroundColor: theme.backgroundColor, + Widget builder(BuildContext context, LoginState state) { + final theme = Theme.of(context); + + final webview = WebViewWidget(controller: controller); + final form = Center( + child: ListView( + padding: const EdgeInsets.symmetric( + vertical: 40, + horizontal: 20, ), + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.primaryContainer, + ), + height: 100, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: Text( + translate('categories.title'), + style: theme.textTheme.headlineLarge, + ), + ), + const SvgPicture( + AssetBytesLoader('assets/icon.svg.vec'), + height: 100, + ), + ], + ), + ), + const SizedBox(height: 20), + Form( + key: _formKey, + child: TextFormField( + focusNode: _focusNode, + decoration: InputDecoration( + labelText: translate('login.server_url.field'), + suffixIcon: InkWell( + onTap: onSubmit, + child: const Icon(Icons.arrow_forward), + ), + ), + keyboardType: TextInputType.url, + validator: validateUrl, + onFieldSubmitted: onSubmit, + onSaved: submit, + ), + ), + const IconButton( + onPressed: null, + icon: Icon( + Icons.qr_code_scanner, + size: 40, + ), + ), + ], + ), + ); + + return Scaffold( + appBar: AppBar( + title: Text(translate('login.title')), + actions: [ + if (state.status == LoginStatus.loading) ...[], + ], + leading: + state.status == LoginStatus.loading ? const BackButton() : null, + ), + body: state.status == LoginStatus.loading ? webview : form, + ); + } + + void submit(String? value) { + if (_formKey.currentState!.validate()) { + _loginBloc.add( + LoginFlowStart(URLUtils.sanitizeUrl(value!)), ); + } else { + _focusNode.requestFocus(); + } + } + + String? validateUrl(String? value) { + if (value == null || value.isEmpty) { + return translate( + 'login.server_url.validator.empty', + ); + } + + if (!URLUtils.isValid(value)) { + return translate( + 'login.server_url.validator.pattern', + ); + } + return null; + } + + Future listener(BuildContext context, LoginState state) async { + if (state.status == LoginStatus.loading) { + await controller.loadRequest(Uri.parse(state.url!)); } } } diff --git a/lib/src/services/api_provider.dart b/lib/src/services/api_provider.dart index 3e771805..9bdc1b66 100644 --- a/lib/src/services/api_provider.dart +++ b/lib/src/services/api_provider.dart @@ -27,6 +27,12 @@ class ApiProvider { dio: client, ); + nextcloudClient = NextcloudClient( + auth.server, + loginName: auth.loginName, + password: auth.appPassword, + ); + ncCookbookApi.setBasicAuth( 'app_password', auth.loginName, @@ -39,6 +45,7 @@ class ApiProvider { } static final ApiProvider _apiProvider = ApiProvider._(); + late NextcloudClient nextcloudClient; late NcCookbookApi ncCookbookApi; late RecipesApi recipeApi; late CategoriesApi categoryApi; diff --git a/lib/src/services/authentication_provider.dart b/lib/src/services/authentication_provider.dart deleted file mode 100644 index c19438c5..00000000 --- a/lib/src/services/authentication_provider.dart +++ /dev/null @@ -1,162 +0,0 @@ -part of 'services.dart'; - -class AuthenticationProvider { - final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); - final String _appAuthenticationKey = 'appAuthentication'; - AppAuthentication? currentAppAuthentication; - dio.CancelToken? _cancelToken; - - Future authenticateAppPassword({ - required String serverUrl, - required String username, - required String basicAuth, - required bool isSelfSignedCertificate, - }) async { - assert(URLUtils.isSanitized(serverUrl)); - - bool authenticated; - try { - authenticated = await checkAppAuthentication( - serverUrl, - basicAuth, - isSelfSignedCertificate: isSelfSignedCertificate, - ); - } on dio.DioError catch (e) { - if (e.message?.contains('SocketException') ?? false) { - throw translate( - 'login.errors.not_reachable', - args: {'server_url': serverUrl, 'error_msg': e}, - ); - } else if (e.message?.contains('CERTIFICATE_VERIFY_FAILED') ?? false) { - throw translate( - 'login.errors.certificate_failed', - args: {'server_url': serverUrl, 'error_msg': e}, - ); - } - throw translate('login.errors.request_failed', args: {'error_msg': e}); - } - - if (authenticated) { - return AppAuthentication( - server: serverUrl, - loginName: username, - appPassword: basicAuth, - isSelfSignedCertificate: isSelfSignedCertificate, - ); - } else { - throw translate('login.errors.auth_failed'); - } - } - - void stopAuthenticate() { - _cancelToken?.cancel('Stopped by the User!'); - _cancelToken = null; - } - - Future hasAppAuthentication() async { - if (currentAppAuthentication != null) { - return true; - } else { - final appAuthentication = - await _secureStorage.read(key: _appAuthenticationKey); - return appAuthentication != null; - } - } - - Future loadAppAuthentication() async { - final appAuthenticationString = - await _secureStorage.read(key: _appAuthenticationKey); - if (appAuthenticationString == null) { - throw translate('login.errors.authentication_not_found'); - } else { - currentAppAuthentication = - AppAuthentication.fromJsonString(appAuthenticationString); - } - } - - /// If server response is 401 Unauthorized the AppPassword is (no longer?) valid! - Future checkAppAuthentication( - String serverUrl, - String basicAuth, { - required bool isSelfSignedCertificate, - }) async { - final urlAuthCheck = '$serverUrl/index.php/apps/cookbook/api/v1/categories'; - - dio.Response response; - try { - final client = dio.Dio(); - if (isSelfSignedCertificate) { - client.httpClientAdapter = IOHttpClientAdapter( - onHttpClientCreate: (client) { - client.badCertificateCallback = (cert, host, port) => true; - return client; - }, - ); - } - response = await client.get( - urlAuthCheck, - options: dio.Options( - headers: {'authorization': basicAuth}, - validateStatus: (status) => status! < 500, - ), - ); - } on dio.DioError catch (e) { - throw translate( - 'login.errors.no_internet', - args: { - 'error_msg': e.message, - }, - ); - } - - if (response.statusCode == 401) { - return false; - } else if (response.statusCode == 200) { - return true; - } else { - throw translate( - 'login.errors.wrong_status', - args: { - 'error_msg': response.statusCode, - }, - ); - } - } - - Future persistAppAuthentication( - AppAuthentication appAuthentication, - ) async { - currentAppAuthentication = appAuthentication; - await _secureStorage.write( - key: _appAuthenticationKey, - value: appAuthentication.toJsonString(), - ); - } - - Future deleteAppAuthentication() async { - dio.Response? response; - try { - response = await Dio().delete( - '${currentAppAuthentication!.server}/ocs/v2.php/core/apppassword', - options: dio.Options( - headers: { - 'Authorization': AppAuthentication.parsePassword( - currentAppAuthentication!.loginName, - currentAppAuthentication!.appPassword, - ), - 'OCS-APIREQUEST': 'true', - }, - ), - ); - } on dio.DioError { - debugPrint(translate('login.errors.failed_remove_remote')); - } - - if (response != null && response.statusCode != 200) { - debugPrint(translate('login.errors.failed_remove_remote')); - } - - currentAppAuthentication = null; - await _secureStorage.delete(key: _appAuthenticationKey); - } -} diff --git a/lib/src/services/services.dart b/lib/src/services/services.dart index 74a19d4b..d70351e9 100644 --- a/lib/src/services/services.dart +++ b/lib/src/services/services.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'package:dio/dio.dart' as dio; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/material.dart'; @@ -12,19 +11,19 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:nc_cookbook_api/nc_cookbook_api.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/models/image_response.dart'; import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/recipe_import_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; import 'package:timezone/data/latest_10y.dart' as tz; import 'package:timezone/timezone.dart' as tz; part 'api_provider.dart'; -part 'authentication_provider.dart'; part 'data_repository.dart'; part 'intent_repository.dart'; part 'net/nextcloud_metadata_api.dart'; part 'notification_provider.dart'; -part 'user_repository.dart'; part 'timer_repository.dart'; +part 'user_repository.dart'; diff --git a/lib/src/services/user_repository.dart b/lib/src/services/user_repository.dart index 84bd5c94..613f404c 100644 --- a/lib/src/services/user_repository.dart +++ b/lib/src/services/user_repository.dart @@ -7,46 +7,68 @@ class UserRepository { // Singleton static final UserRepository _userRepository = UserRepository._(); - AuthenticationProvider authenticationProvider = AuthenticationProvider(); - - Future authenticateAppPassword( - String serverUrl, - String username, - String basicAuth, { - required bool isSelfSignedCertificate, - }) async => - authenticationProvider.authenticateAppPassword( - serverUrl: serverUrl, - username: username, - basicAuth: basicAuth, - isSelfSignedCertificate: isSelfSignedCertificate, - ); + AppAuthentication? _currentAppAuthentication; - void stopAuthenticate() { - authenticationProvider.stopAuthenticate(); - } + AppAuthentication get currentAppAuthentication => _currentAppAuthentication!; - AppAuthentication get currentAppAuthentication => - authenticationProvider.currentAppAuthentication!; + static const _authKey = 'appAuthentication'; - Future hasAppAuthentication() async => authenticationProvider.hasAppAuthentication(); + bool get hasAuthentidation => _currentAppAuthentication != null; - Future loadAppAuthentication() async => - authenticationProvider.loadAppAuthentication(); + /// Loads the authentication from storage + /// + /// Throws a [LoadAuthException] when none is saved. + Future loadAppAuthentication() async { + final auth = await const FlutterSecureStorage().read(key: _authKey); + if (auth == null || auth.isEmpty) { + throw LoadAuthException(); + } + _currentAppAuthentication = AppAuthentication.fromJsonString(auth); + } - Future checkAppAuthentication() async => authenticationProvider.checkAppAuthentication( - currentAppAuthentication.server, - currentAppAuthentication.appPassword, - isSelfSignedCertificate: currentAppAuthentication.isSelfSignedCertificate, - ); + Future checkAppAuthentication() async { + if (!hasAuthentidation) { + return false; + } + try { + await ApiProvider().miscApi.version(); + return true; + } catch (e) { + return false; + } + } Future persistAppAuthentication( AppAuthentication appAuthentication, - ) async => - authenticationProvider.persistAppAuthentication(appAuthentication); + ) async { + _currentAppAuthentication = appAuthentication; + await const FlutterSecureStorage().write( + key: _authKey, + value: appAuthentication.toJsonString(), + ); + } - Future deleteAppAuthentication() async => - authenticationProvider.deleteAppAuthentication(); + Future deleteAppAuthentication() async { + try { + await Dio().delete( + '${currentAppAuthentication.server}/ocs/v2.php/core/apppassword', + options: Options( + headers: { + 'Authorization': AppAuthentication.parsePassword( + currentAppAuthentication.loginName, + currentAppAuthentication.appPassword, + ), + 'OCS-APIREQUEST': 'true', + }, + ), + ); + } on DioError { + debugPrint(translate('login.errors.failed_remove_remote')); + } + + _currentAppAuthentication = null; + await const FlutterSecureStorage().delete(key: _authKey); + } bool isVersionSupported(APIVersion version) => ApiProvider().ncCookbookApi.isSupportedSync(version); diff --git a/pubspec.yaml b/pubspec.yaml index 4f32bc78..df2c25e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,11 +91,19 @@ dependencies: url: https://github.com/Leptopoda/nextcloud_cookbook_dart_api.git ref: 5465b2fd64fb71cb7cd588989d1709fd48884457 + nextcloud: + git: + url: https://github.com/provokateurin/nextcloud-neon.git + path: packages/nextcloud + ref: bcd0f6430c958d629ec7bd182d6b32a51152c1bf + built_collection: ^5.1.1 - intl: ^0.18.0 + intl: ^0.17.0 sliver_tools: ^0.2.8 flutter_native_splash: ^2.2.19 + webview_flutter: ^4.0.7 + vector_graphics: ^1.1.4 dev_dependencies: flutter_launcher_icons: ^0.12.0 @@ -120,7 +128,7 @@ flutter: assets: - assets/i18n/ - - assets/icon.svg + - assets/icon.svg.vec # To add assets to your application, add an assets section, like this: # assets: From feed63ea28dc46277ab8874739ba3504aa57f3ed Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Fri, 7 Apr 2023 18:03:27 +0200 Subject: [PATCH 3/3] add login via qr code --- android/app/build.gradle | 2 +- lib/src/blocs/login/login_bloc.dart | 25 +++++++ lib/src/blocs/login/login_event.dart | 6 ++ lib/src/screens/login_qr_screen.dart | 67 +++++++++++++++++++ lib/src/screens/login_screen.dart | 17 ++++- lib/src/util/nextcloud_login_qr_util.dart | 18 +++++ pubspec.yaml | 1 + test/models/nextcloud_login_qr_util_test.dart | 19 ++++++ 8 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 lib/src/screens/login_qr_screen.dart create mode 100644 lib/src/util/nextcloud_login_qr_util.dart create mode 100644 test/models/nextcloud_login_qr_util_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 85b5e8ad..522c51b5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,7 +36,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.nextcloud_cookbook_flutter" - minSdkVersion 19 + minSdkVersion 20 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/lib/src/blocs/login/login_bloc.dart b/lib/src/blocs/login/login_bloc.dart index d055d549..76049225 100644 --- a/lib/src/blocs/login/login_bloc.dart +++ b/lib/src/blocs/login/login_bloc.dart @@ -7,6 +7,7 @@ import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/nextcloud_login_qr_util.dart'; import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; part 'login_event.dart'; @@ -17,6 +18,7 @@ class LoginBloc extends Bloc { required this.authenticationBloc, }) : super(const LoginState()) { on(_mapLoginFlowStartEventToState); + on(_mapLoginQRScannedEventToState); } final UserRepository userRepository = UserRepository(); final AuthenticationBloc authenticationBloc; @@ -54,4 +56,27 @@ class LoginBloc extends Bloc { emit(LoginState(status: LoginStatus.failure, error: e.toString())); } } + + Future _mapLoginQRScannedEventToState( + LoginQRScenned event, + Emitter emit, + ) async { + assert(event.uri.isScheme('nc')); + try { + final auth = parseNCLoginQR(event.uri); + + authenticationBloc.add( + LoggedIn( + appAuthentication: AppAuthentication( + server: auth['server']!, + loginName: auth['user']!, + appPassword: auth['password']!, + isSelfSignedCertificate: false, + ), + ), + ); + } catch (e) { + emit(LoginState(status: LoginStatus.failure, error: e.toString())); + } + } } diff --git a/lib/src/blocs/login/login_event.dart b/lib/src/blocs/login/login_event.dart index f1035e60..bf188661 100644 --- a/lib/src/blocs/login/login_event.dart +++ b/lib/src/blocs/login/login_event.dart @@ -12,3 +12,9 @@ class LoginFlowStart extends LoginEvent { final String serverURL; } + +class LoginQRScenned extends LoginEvent { + const LoginQRScenned(this.uri); + + final Uri uri; +} diff --git a/lib/src/screens/login_qr_screen.dart b/lib/src/screens/login_qr_screen.dart new file mode 100644 index 00000000..37412886 --- /dev/null +++ b/lib/src/screens/login_qr_screen.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:qr_code_scanner/qr_code_scanner.dart'; + +class LoginQrScreen extends StatefulWidget { + const LoginQrScreen({super.key}); + + @override + State createState() => _LoginQrScreenState(); +} + +class _LoginQrScreenState extends State { + final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + Barcode? result; + QRViewController? controller; + + @override + void reassemble() { + super.reassemble(); + if (Platform.isAndroid) { + controller!.pauseCamera(); + } else if (Platform.isIOS) { + controller!.resumeCamera(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(), + body: QRView( + formatsAllowed: const [ + BarcodeFormat.qrcode, + ], + overlay: QrScannerOverlayShape( + borderColor: theme.colorScheme.primaryContainer, + borderWidth: 15, + borderRadius: 10, + ), + key: qrKey, + onQRViewCreated: _onQRViewCreated, + ), + ); + } + + void _onQRViewCreated(QRViewController controller) { + this.controller = controller; + controller.scannedDataStream.listen((scanData) { + final code = scanData.code; + if (code != null && code.isNotEmpty) { + final uri = Uri.tryParse(code); + if (uri != null && uri.isScheme('nc')) { + Navigator.of(context).pop(uri); + } + } + }); + } + + @override + void dispose() { + controller?.dispose(); + super.dispose(); + } +} diff --git a/lib/src/screens/login_screen.dart b/lib/src/screens/login_screen.dart index 633192c1..3a1137f7 100644 --- a/lib/src/screens/login_screen.dart +++ b/lib/src/screens/login_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/login/login_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/login_qr_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; import 'package:vector_graphics/vector_graphics.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -41,6 +42,16 @@ class _LoginScreenState extends State { super.didChangeDependencies(); } + Future _authenticateQR() async { + final uri = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LoginQrScreen()), + ); + + if (uri != null) { + _loginBloc.add(LoginQRScenned(uri)); + } + } + void onSubmit([String? value]) { _formKey.currentState!.save(); } @@ -103,9 +114,9 @@ class _LoginScreenState extends State { onSaved: submit, ), ), - const IconButton( - onPressed: null, - icon: Icon( + IconButton( + onPressed: _authenticateQR, + icon: const Icon( Icons.qr_code_scanner, size: 40, ), diff --git a/lib/src/util/nextcloud_login_qr_util.dart b/lib/src/util/nextcloud_login_qr_util.dart new file mode 100644 index 00000000..af1ad822 --- /dev/null +++ b/lib/src/util/nextcloud_login_qr_util.dart @@ -0,0 +1,18 @@ +/// Parses the content of a LoginQr +/// +/// The result will be like: +/// ```dart +/// { +/// 'user': 'admin', +/// 'password': 'superSecret', +/// 'server': 'https://example.com', +/// } +/// ``` +Map parseNCLoginQR(Uri uri) { + return uri.path.split('&').map((e) { + final parts = e.split(':'); + final key = parts[0].replaceFirst(RegExp('/'), ''); + parts.removeAt(0); + return {key: parts.join(':')}; + }).fold({}, (p, e) => p..addAll(e)); +} diff --git a/pubspec.yaml b/pubspec.yaml index df2c25e3..f50de5af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -104,6 +104,7 @@ dependencies: flutter_native_splash: ^2.2.19 webview_flutter: ^4.0.7 vector_graphics: ^1.1.4 + qr_code_scanner: ^1.0.1 dev_dependencies: flutter_launcher_icons: ^0.12.0 diff --git a/test/models/nextcloud_login_qr_util_test.dart b/test/models/nextcloud_login_qr_util_test.dart new file mode 100644 index 00000000..5b943948 --- /dev/null +++ b/test/models/nextcloud_login_qr_util_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/nextcloud_login_qr_util.dart'; + +void main() { + const username = 'admin'; + const password = 'superSecret'; + const server = 'https://example.com'; + + final content = + Uri.parse('nc://login/user:$username&password:$password&server:$server'); + + test('parseNCLoginQR', () { + expect(parseNCLoginQR(content), { + 'user': username, + 'password': password, + 'server': server, + }); + }); +}