diff --git a/app/lib/api/api.dart b/app/lib/api/api.dart index 4ed40e53..c15b69da 100644 --- a/app/lib/api/api.dart +++ b/app/lib/api/api.dart @@ -28,18 +28,42 @@ class Response { final dynamic body; } +class BasicAuth { + BasicAuth(this.username, this.password); + + BasicAuth.fromAccount(Account account) + : this( + account.username2, + account.password, + ); + + @override + toString() { + final authString = base64.encode(utf8.encode("$username:$password")); + return "Basic $authString"; + } + + final String username; + final String password; +} + class Api { - Api(this._account); + Api(Account account) + : _baseUrl = Uri.parse(account.url), + _auth = BasicAuth.fromAccount(account); + + Api.fromBaseUrl(Uri baseUrl) : _baseUrl = baseUrl; ApiFiles files() => ApiFiles(this); + ApiOcs ocs() => ApiOcs(this); + ApiSystemtags systemtags() => ApiSystemtags(this); + ApiSystemtagsRelations systemtagsRelations() => ApiSystemtagsRelations(this); static String getAuthorizationHeaderValue(Account account) { - final auth = - base64.encode(utf8.encode("${account.username2}:${account.password}")); - return "Basic $auth"; + return BasicAuth.fromAccount(account).toString(); } Future request( @@ -52,10 +76,12 @@ class Api { bool isResponseString = true, }) async { final url = _makeUri(endpoint, queryParameters: queryParameters); - final req = http.Request(method, url) - ..headers.addAll({ - "authorization": getAuthorizationHeaderValue(_account), + final req = http.Request(method, url); + if (_auth != null) { + req.headers.addAll({ + "authorization": _auth.toString(), }); + } if (header != null) { // turn all to lower case, since HTTP headers are case-insensitive, this // smooths our processing (if any) @@ -89,19 +115,16 @@ class Api { String endpoint, { Map? queryParameters, }) { - final splits = _account.address.split("/"); - final authority = splits[0]; - final path = splits.length > 1 - ? splits.sublist(1).join("/") + "/$endpoint" - : endpoint; - if (_account.scheme == "http") { - return Uri.http(authority, path, queryParameters); + final path = _baseUrl.path + "/$endpoint"; + if (_baseUrl.scheme == "http") { + return Uri.http(_baseUrl.authority, path, queryParameters); } else { - return Uri.https(authority, path, queryParameters); + return Uri.https(_baseUrl.authority, path, queryParameters); } } - final Account _account; + final Uri _baseUrl; + BasicAuth? _auth; static final _log = Logger("api.api.Api"); } @@ -451,7 +474,9 @@ class ApiOcs { ApiOcs(this._api); ApiOcsDav dav() => ApiOcsDav(this); + ApiOcsFacerecognition facerecognition() => ApiOcsFacerecognition(this); + ApiOcsFilesSharing filesSharing() => ApiOcsFilesSharing(this); final Api _api; @@ -499,6 +524,7 @@ class ApiOcsFacerecognition { ApiOcsFacerecognition(this._ocs); ApiOcsFacerecognitionPersons persons() => ApiOcsFacerecognitionPersons(this); + ApiOcsFacerecognitionPerson person(String name) => ApiOcsFacerecognitionPerson(this, name); @@ -571,8 +597,10 @@ class ApiOcsFilesSharing { ApiOcsFilesSharing(this._ocs); ApiOcsFilesSharingShares shares() => ApiOcsFilesSharingShares(this); + ApiOcsFilesSharingShare share(String shareId) => ApiOcsFilesSharingShare(this, shareId); + ApiOcsFilesSharingSharees sharees() => ApiOcsFilesSharingSharees(this); final ApiOcs _ocs; diff --git a/app/lib/api/api_util.dart b/app/lib/api/api_util.dart index 14da9fb7..b8c86a59 100644 --- a/app/lib/api/api_util.dart +++ b/app/lib/api/api_util.dart @@ -1,4 +1,7 @@ /// Helper functions working with remote Nextcloud server +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; @@ -104,37 +107,130 @@ String getFacePreviewUrlRelative( return "index.php/apps/facerecognition/face/$faceId/thumb/$size"; } -/// Query the app password for [account] -Future exchangePassword(Account account) async { - final response = await Api(account).request( - "GET", - "ocs/v2.php/core/getapppassword", +/// Initiate a login with Nextclouds login flow v2: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 +Future initiateLogin(Uri uri) async { + final response = await Api.fromBaseUrl(uri).request( + "POST", + "index.php/login/v2", header: { - "OCS-APIRequest": "true", + "User-Agent": "nc-photos", }, ); if (response.isGood) { - try { - final appPwdRegex = RegExp(r"(.*)"); - final appPwdMatch = appPwdRegex.firstMatch(response.body); - return appPwdMatch!.group(1)!; - } catch (_) { - // this happens when the address is not the base URL and so Nextcloud - // returned the login page - throw InvalidBaseUrlException(); - } - } else if (response.statusCode == 403) { - // If the client is authenticated with an app password a 403 will be - // returned - _log.info("[exchangePassword] Already an app password"); - return account.password; + return InitiateLoginResponse.fromJsonString(response.body); } else { _log.severe( - "[exchangePassword] Failed while requesting app password: $response"); + "[initiateLogin] Failed while requesting app password: $response"); throw ApiException( response: response, - message: "Server responed with an error: HTTP ${response.statusCode}"); + message: "Server responded with an error: HTTP ${response.statusCode}"); } } +/// Retrieve App Password after successful initiation of login with Nextclouds login flow v2: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 +Future _getAppPassword( + InitiateLoginPollOptions options) async { + Uri baseUrl; + if (options.endpoint.scheme == "http") { + baseUrl = Uri.http(options.endpoint.authority); + } else { + baseUrl = Uri.https(options.endpoint.authority); + } + final response = await Api.fromBaseUrl(baseUrl).request( + "POST", options.endpoint.pathSegments.join("/"), + header: { + "User-Agent": "nc-photos", + "Content-Type": "application/x-www-form-urlencoded" + }, + body: "token=${options.token}"); + if (response.statusCode == 200) { + return AppPasswordSuccess.fromJsonString(response.body); + } else if (response.statusCode == 404) { + return AppPasswordPending(); + } else { + _log.severe( + "[getAppPassword] Failed while requesting app password: $response"); + throw ApiException( + response: response, + message: "Server responded with an error: HTTP ${response.statusCode}"); + } +} + +/// Polls the app password endpoint every 5 seconds as lang as the token is valid (currently fixed to 20 min) +/// See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 +Stream> pollAppPassword( + InitiateLoginPollOptions options) { + return Stream.periodic( + const Duration(seconds: 5), (_) => _getAppPassword(options)) + .takeWhile((_) => options.isTokenValid()); +} + +class InitiateLoginResponse { + InitiateLoginResponse.fromJsonString(String jsonString) { + final json = jsonDecode(jsonString); + poll = InitiateLoginPollOptions( + json['poll']['token'], json['poll']['endpoint']); + login = json['login']; + } + + @override + toString() { + return "$runtimeType {" + "poll: $poll, " + "login: $login, " + "}"; + } + + late InitiateLoginPollOptions poll; + late String login; +} + +class InitiateLoginPollOptions { + InitiateLoginPollOptions(this.token, String endpoint) + : endpoint = Uri.parse(endpoint), + _validUntil = DateTime.now().add(const Duration(minutes: 20)); + + @override + toString() { + return "$runtimeType {" + "token: ${kDebugMode ? token : '***'}, " + "endpoint: $endpoint, " + "}"; + } + + bool isTokenValid() { + return DateTime.now().isBefore(_validUntil); + } + + final String token; + final Uri endpoint; + final DateTime _validUntil; +} + +abstract class AppPasswordResponse {} + +class AppPasswordSuccess implements AppPasswordResponse { + AppPasswordSuccess.fromJsonString(String jsonString) { + final json = jsonDecode(jsonString); + server = Uri.parse(json['server']); + loginName = json['loginName']; + appPassword = json['appPassword']; + } + + @override + toString() { + return "$runtimeType {" + "server: $server, " + "loginName: $loginName, " + "appPassword: ${kDebugMode ? appPassword : '***'}, " + "}"; + } + + late Uri server; + late String loginName; + late String appPassword; +} + +class AppPasswordPending implements AppPasswordResponse {} + final _log = Logger("api.api_util"); diff --git a/app/lib/bloc/app_password_exchange.dart b/app/lib/bloc/app_password_exchange.dart index 08e962f4..f8a2b0e2 100644 --- a/app/lib/bloc/app_password_exchange.dart +++ b/app/lib/bloc/app_password_exchange.dart @@ -1,9 +1,9 @@ +import 'dart:async'; import 'dart:io'; import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; + import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/exception.dart'; @@ -11,17 +11,58 @@ abstract class AppPasswordExchangeBlocEvent { const AppPasswordExchangeBlocEvent(); } -class AppPasswordExchangeBlocConnect extends AppPasswordExchangeBlocEvent { - const AppPasswordExchangeBlocConnect(this.account); +class AppPasswordExchangeBlocInitiateLogin + extends AppPasswordExchangeBlocEvent { + const AppPasswordExchangeBlocInitiateLogin(this.uri); @override toString() { return "$runtimeType {" - "account: $account, " + "uri: $uri, " "}"; } - final Account account; + final Uri uri; +} + +class AppPasswordExchangeBlocPoll extends AppPasswordExchangeBlocEvent { + const AppPasswordExchangeBlocPoll(this.pollOptions); + + @override + toString() { + return "$runtimeType {" + "pollOptions: $pollOptions, " + "}"; + } + + final api_util.InitiateLoginPollOptions pollOptions; +} + +class _AppPasswordExchangeBlocAppPwReceived + extends AppPasswordExchangeBlocEvent { + const _AppPasswordExchangeBlocAppPwReceived(this.appPasswordResponse); + + @override + toString() { + return "$runtimeType {" + "appPasswordResponse: $appPasswordResponse, " + "}"; + } + + final api_util.AppPasswordSuccess appPasswordResponse; +} + +class _AppPasswordExchangeBlocAppPwFailed extends AppPasswordExchangeBlocEvent { + const _AppPasswordExchangeBlocAppPwFailed(this.exception); + + @override + toString() { + return "$runtimeType {" + "exception: $exception, " + "}"; + } + + final dynamic exception; } abstract class AppPasswordExchangeBlocState { @@ -32,17 +73,31 @@ class AppPasswordExchangeBlocInit extends AppPasswordExchangeBlocState { const AppPasswordExchangeBlocInit(); } -class AppPasswordExchangeBlocSuccess extends AppPasswordExchangeBlocState { - const AppPasswordExchangeBlocSuccess(this.password); +class AppPasswordExchangeBlocInitiateLoginSuccess + extends AppPasswordExchangeBlocState { + const AppPasswordExchangeBlocInitiateLoginSuccess(this.result); @override toString() { return "$runtimeType {" - "password: ${kDebugMode ? password : '***'}, " + "result: $result, " "}"; } - final String password; + final api_util.InitiateLoginResponse result; +} + +class AppPasswordExchangeBlocAppPwSuccess extends AppPasswordExchangeBlocState { + const AppPasswordExchangeBlocAppPwSuccess(this.result); + + @override + toString() { + return "$runtimeType {" + "result: $result, " + "}"; + } + + final api_util.AppPasswordSuccess result; } class AppPasswordExchangeBlocFailure extends AppPasswordExchangeBlocState { @@ -58,6 +113,20 @@ class AppPasswordExchangeBlocFailure extends AppPasswordExchangeBlocState { final dynamic exception; } +/// Business Logic Component (BLoC) which handles the App password exchange. +/// +/// The flow followed by this component is described in the Nextcloud documentation under +/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 +/// +/// ``` +/// Event [AppPasswordExchangeBlocInitiateLogin] -> State [AppPasswordExchangeBlocInitiateLoginSuccess] +/// -> State [AppPasswordExchangeBlocFailure] +/// Event [AppPasswordExchangeBlocPoll] -> Event [AppPasswordExchangeBlocAppPwReceived] +/// -> Event [AppPasswordExchangeBlocAppPwFailed] +/// Event [AppPasswordExchangeBlocAppPwReceived] -> State [AppPasswordExchangeBlocAppPwSuccess] +/// -> State [AppPasswordExchangeBlocFailure] +/// Event [AppPasswordExchangeBlocAppPwFailed] -> State [AppPasswordExchangeBlocFailure] +/// ``` class AppPasswordExchangeBloc extends Bloc { AppPasswordExchangeBloc() : super(const AppPasswordExchangeBlocInit()) { @@ -67,36 +136,96 @@ class AppPasswordExchangeBloc Future _onEvent(AppPasswordExchangeBlocEvent event, Emitter emit) async { _log.info("[_onEvent] $event"); - if (event is AppPasswordExchangeBlocConnect) { - await _onEventConnect(event, emit); + if (event is AppPasswordExchangeBlocInitiateLogin) { + await _onEventInitiateLogin(event, emit); + } else if (event is AppPasswordExchangeBlocPoll) { + await _onEventPoll(event, emit); + } else if (event is _AppPasswordExchangeBlocAppPwReceived) { + await _onEventAppPasswordReceived(event, emit); + } else if (event is _AppPasswordExchangeBlocAppPwFailed) { + await _onEventAppPasswordFailure(event, emit); } } - Future _onEventConnect(AppPasswordExchangeBlocConnect ev, + Future _onEventInitiateLogin(AppPasswordExchangeBlocInitiateLogin ev, Emitter emit) async { - final account = ev.account; + final uri = ev.uri; try { - final appPwd = await api_util.exchangePassword(account); - emit(AppPasswordExchangeBlocSuccess(appPwd)); + final initiateLoginResponse = await api_util.initiateLogin(uri); + emit(AppPasswordExchangeBlocInitiateLoginSuccess(initiateLoginResponse)); } on InvalidBaseUrlException catch (e) { - _log.warning("[_exchangePassword] Invalid base url"); + _log.warning("[_onEventInitiateLogin] Invalid base url"); emit(AppPasswordExchangeBlocFailure(e)); } on HandshakeException catch (e) { - _log.info("[_exchangePassword] Self-signed cert"); + _log.info("[_onEventInitiateLogin] Self-signed cert"); emit(AppPasswordExchangeBlocFailure(e)); } catch (e, stacktrace) { - if (e is ApiException && e.response.statusCode == 401) { - // wrong password, normal - _log.warning( - "[_exchangePassword] Server response 401, wrong password?"); - } else { - _log.shout("[_exchangePassword] Failed while exchanging password", e, - stacktrace); - } + _log.shout("[_onEventInitiateLogin] Failed while exchanging password", e, + stacktrace); emit(AppPasswordExchangeBlocFailure(e)); } } + Future _onEventPoll(AppPasswordExchangeBlocPoll ev, + Emitter emit) async { + final pollOptions = ev.pollOptions; + try { + await _pollPasswordSubscription?.cancel(); + _pollPasswordSubscription = api_util + .pollAppPassword(pollOptions) + .listen(_pollAppPasswordStreamListener); + } catch (e, stacktrace) { + await _pollPasswordSubscription?.cancel(); + _log.shout( + "[_onEventPoll] Failed while polling for password", e, stacktrace); + emit(AppPasswordExchangeBlocFailure(e)); + } + } + + Future _pollAppPasswordStreamListener(event) async { + try { + final appPasswordResponse = await event; + if (appPasswordResponse is api_util.AppPasswordSuccess) { + await _pollPasswordSubscription?.cancel(); + add(_AppPasswordExchangeBlocAppPwReceived(appPasswordResponse)); + } + } catch (e, stacktrace) { + _log.shout( + "[_pollAppPasswordStreamListener] Failed while polling for password", e, stacktrace); + add(_AppPasswordExchangeBlocAppPwFailed(e)); + } + } + + Future _onEventAppPasswordReceived( + _AppPasswordExchangeBlocAppPwReceived ev, + Emitter emit) async { + try { + emit(AppPasswordExchangeBlocAppPwSuccess(ev.appPasswordResponse)); + } catch (e, stacktrace) { + _log.shout( + "[_onEventAppPasswordReceived] Failed while exchanging password", + e, + stacktrace); + emit(AppPasswordExchangeBlocFailure(e)); + } + } + + Future _onEventAppPasswordFailure( + _AppPasswordExchangeBlocAppPwFailed ev, + Emitter emit) async { + await _pollPasswordSubscription?.cancel(); + emit(AppPasswordExchangeBlocFailure(ev.exception)); + } + + @override + Future close() { + _pollPasswordSubscription?.cancel(); + return super.close(); + } + static final _log = Logger("bloc.app_password_exchange.AppPasswordExchangeBloc"); + + StreamSubscription>? + _pollPasswordSubscription; } diff --git a/app/lib/widget/connect.dart b/app/lib/widget/connect.dart index 50d5fc38..e3d329ef 100644 --- a/app/lib/widget/connect.dart +++ b/app/lib/widget/connect.dart @@ -22,9 +22,9 @@ import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/use_case/ls_single_file.dart'; class ConnectArguments { - ConnectArguments(this.account); + ConnectArguments(this.uri); - final Account account; + final Uri uri; } class Connect extends StatefulWidget { @@ -36,19 +36,19 @@ class Connect extends StatefulWidget { const Connect({ Key? key, - required this.account, + required this.uri, }) : super(key: key); Connect.fromArgs(ConnectArguments args, {Key? key}) : this( key: key, - account: args.account, + uri: args.uri, ); @override createState() => _ConnectState(); - final Account account; + final Uri uri; } class _ConnectState extends State { @@ -58,6 +58,12 @@ class _ConnectState extends State { _initBloc(); } + @override + void dispose() { + _bloc.close(); + super.dispose(); + } + @override build(BuildContext context) { return Scaffold( @@ -71,7 +77,7 @@ class _ConnectState extends State { void _initBloc() { _log.info("[_initBloc] Initialize bloc"); - _connect(); + _initiateLogin(); } Widget _buildContent(BuildContext context) { @@ -88,7 +94,7 @@ class _ConnectState extends State { color: Theme.of(context).colorScheme.primary, ), Text( - L10n.global().connectingToServer(widget.account.url), + L10n.global().connectingToServer(widget.uri), textAlign: TextAlign.center, style: Theme.of(context).textTheme.headline6, ) @@ -100,9 +106,19 @@ class _ConnectState extends State { void _onStateChange( BuildContext context, AppPasswordExchangeBlocState state) { - if (state is AppPasswordExchangeBlocSuccess) { - final newAccount = widget.account.copyWith(password: state.password); - _log.info("[_onStateChange] Password exchanged: $newAccount"); + if (state is AppPasswordExchangeBlocInitiateLoginSuccess) { + // launch the login url + launch(state.result.login); + // and start polling the API for login credentials + _bloc.add(AppPasswordExchangeBlocPoll(state.result.poll)); + } else if (state is AppPasswordExchangeBlocAppPwSuccess) { + final newAccount = Account( + Account.newId(), + state.result.server.scheme, + state.result.server.authority, + state.result.loginName.toCi(), + state.result.loginName, + state.result.appPassword, []); _checkWebDavUrl(context, newAccount); } else if (state is AppPasswordExchangeBlocFailure) { if (features.isSupportSelfSignedCert && @@ -185,8 +201,8 @@ class _ConnectState extends State { }); } - void _connect() { - _bloc.add(AppPasswordExchangeBlocConnect(widget.account)); + void _initiateLogin() { + _bloc.add(AppPasswordExchangeBlocInitiateLogin(widget.uri)); } Future _onCheckWebDavUrlFailed( diff --git a/app/lib/widget/sign_in.dart b/app/lib/widget/sign_in.dart index c1cbca46..500fbe88 100644 --- a/app/lib/widget/sign_in.dart +++ b/app/lib/widget/sign_in.dart @@ -5,17 +5,14 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; -import 'package:nc_photos/help_utils.dart' as help_utils; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref_util.dart' as pref_util; import 'package:nc_photos/string_extension.dart'; import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/widget/connect.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/root_picker.dart'; @@ -90,35 +87,6 @@ class _SignInState extends State { child: _buildForm(context), ), ), - Container( - alignment: AlignmentDirectional.centerStart, - constraints: BoxConstraints( - maxWidth: - Theme.of(context).widthLimitedContentMaxWidth, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: InkWell( - onTap: () { - launch(help_utils.twoFactorAuthUrl); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 16), - child: Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.help_outline, size: 16), - const SizedBox(width: 4), - Expanded( - child: - Text(L10n.global().signIn2faHintText), - ), - ], - ), - ), - ), - ), if (!platform_k.isWeb) Expanded(child: Container()), Container( constraints: BoxConstraints( @@ -223,55 +191,17 @@ class _SignInState extends State { ), ], ), - const SizedBox(height: 8), - TextFormField( - decoration: InputDecoration( - hintText: L10n.global().usernameInputHint, - ), - validator: (value) { - if (value!.trim().isEmpty) { - return L10n.global().usernameInputInvalidEmpty; - } - return null; - }, - onSaved: (value) { - _formValue.username = value!; - }, - ), - const SizedBox(height: 8), - TextFormField( - decoration: InputDecoration( - hintText: L10n.global().passwordInputHint, - ), - obscureText: true, - validator: (value) { - if (value!.trim().isEmpty) { - return L10n.global().passwordInputInvalidEmpty; - } - return null; - }, - onSaved: (value) { - _formValue.password = value!; - }, - ), ], ); } Future _connect() async { _formKey.currentState!.save(); - Account? account = Account( - Account.newId(), - _formValue.scheme, - _formValue.address, - _formValue.username.toCi(), - _formValue.username, - _formValue.password, - [""], - ); - _log.info("[_connect] Try connecting with account: $account"); - account = await Navigator.pushNamed(context, Connect.routeName, - arguments: ConnectArguments(account)); + Uri url = Uri.parse("${_formValue.scheme}://${_formValue.address}"); + _log.info("[_connect] Try connecting with url: $url"); + Account? account = await Navigator.pushNamed( + context, Connect.routeName, + arguments: ConnectArguments(url)); if (account == null) { // connection failed return; @@ -351,6 +281,4 @@ extension on _Scheme { class _FormValue { late String scheme; late String address; - late String username; - late String password; }