[123] change login form to nextcloud login flow v2 (#1)

This commit is contained in:
steffenmalisi 2022-11-20 16:35:53 +01:00 committed by GitHub
parent c19003e5b9
commit 34066215c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 350 additions and 153 deletions

View file

@ -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<Response> 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<String, String>? 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;

View file

@ -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<String> 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<InitiateLoginResponse> 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"<apppassword>(.*)</apppassword>");
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<AppPasswordResponse> _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<Future<AppPasswordResponse>> 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");

View file

@ -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<AppPasswordExchangeBlocEvent, AppPasswordExchangeBlocState> {
AppPasswordExchangeBloc() : super(const AppPasswordExchangeBlocInit()) {
@ -67,36 +136,96 @@ class AppPasswordExchangeBloc
Future<void> _onEvent(AppPasswordExchangeBlocEvent event,
Emitter<AppPasswordExchangeBlocState> 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<void> _onEventConnect(AppPasswordExchangeBlocConnect ev,
Future<void> _onEventInitiateLogin(AppPasswordExchangeBlocInitiateLogin ev,
Emitter<AppPasswordExchangeBlocState> 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<void> _onEventPoll(AppPasswordExchangeBlocPoll ev,
Emitter<AppPasswordExchangeBlocState> 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<void> _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<void> _onEventAppPasswordReceived(
_AppPasswordExchangeBlocAppPwReceived ev,
Emitter<AppPasswordExchangeBlocState> emit) async {
try {
emit(AppPasswordExchangeBlocAppPwSuccess(ev.appPasswordResponse));
} catch (e, stacktrace) {
_log.shout(
"[_onEventAppPasswordReceived] Failed while exchanging password",
e,
stacktrace);
emit(AppPasswordExchangeBlocFailure(e));
}
}
Future<void> _onEventAppPasswordFailure(
_AppPasswordExchangeBlocAppPwFailed ev,
Emitter<AppPasswordExchangeBlocState> emit) async {
await _pollPasswordSubscription?.cancel();
emit(AppPasswordExchangeBlocFailure(ev.exception));
}
@override
Future<void> close() {
_pollPasswordSubscription?.cancel();
return super.close();
}
static final _log =
Logger("bloc.app_password_exchange.AppPasswordExchangeBloc");
StreamSubscription<Future<api_util.AppPasswordResponse>>?
_pollPasswordSubscription;
}

View file

@ -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<Connect> {
@ -58,6 +58,12 @@ class _ConnectState extends State<Connect> {
_initBloc();
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
@override
build(BuildContext context) {
return Scaffold(
@ -71,7 +77,7 @@ class _ConnectState extends State<Connect> {
void _initBloc() {
_log.info("[_initBloc] Initialize bloc");
_connect();
_initiateLogin();
}
Widget _buildContent(BuildContext context) {
@ -88,7 +94,7 @@ class _ConnectState extends State<Connect> {
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<Connect> {
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<Connect> {
});
}
void _connect() {
_bloc.add(AppPasswordExchangeBlocConnect(widget.account));
void _initiateLogin() {
_bloc.add(AppPasswordExchangeBlocInitiateLogin(widget.uri));
}
Future<void> _onCheckWebDavUrlFailed(

View file

@ -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<SignIn> {
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<SignIn> {
),
],
),
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<void> _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<Account>(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<Account>(
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;
}