diff --git a/lib/api/marianumcloud/app_password/get_app_password.dart b/lib/api/marianumcloud/app_password/get_app_password.dart index 8e3eb7d..30c920c 100644 --- a/lib/api/marianumcloud/app_password/get_app_password.dart +++ b/lib/api/marianumcloud/app_password/get_app_password.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../../../model/account_data.dart'; +import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; /// Exchanges the user's real Nextcloud password for a scoped app password via @@ -18,21 +19,29 @@ class GetAppPassword { GetAppPassword({http.Client? client}) : _client = client ?? http.Client(); /// Returns the freshly minted app password. Throws on any transport or - /// protocol error — callers treat push registration as best-effort and swallow - /// failures. + /// protocol error — a 401 becomes an [AuthException], which the login flow + /// reads as "Nextcloud rejects the password" (two-factor authentication or + /// password mismatch) and answers with the interactive Login Flow v2. Future run() async { - final response = await _client.get( - NextcloudOcs.uri('core/getapppassword'), - headers: { - ...NextcloudOcs.headers(), - // Deliberately NOT the shared Authorization value: that one prefers - // the app password, but an app password cannot mint another one — - // this endpoint requires the real password. - 'Authorization': AccountData().getRealPasswordBasicAuthHeader(), - }, - ); + const label = 'Nextcloud getapppassword'; + final response = (await sendGuarded( + label, + () => _client.get( + NextcloudOcs.uri('core/getapppassword'), + headers: { + ...NextcloudOcs.headers(), + // Deliberately NOT the shared Authorization value: that one prefers + // the app password, but an app password cannot mint another one — + // this endpoint requires the real password. + 'Authorization': AccountData().getRealPasswordBasicAuthHeader(), + }, + ), + ))!; if (response.statusCode < 200 || response.statusCode >= 300) { - throw Exception('getapppassword HTTP ${response.statusCode}'); + throwForStatus( + response.statusCode, + httpErrorDetail(label, response.body, response.statusCode), + ); } final json = jsonDecode(utf8.decode(response.bodyBytes)); final data = (json as Map)['ocs']?['data']; diff --git a/lib/api/marianumcloud/login_flow/login_flow_api.dart b/lib/api/marianumcloud/login_flow/login_flow_api.dart new file mode 100644 index 0000000..49ff413 --- /dev/null +++ b/lib/api/marianumcloud/login_flow/login_flow_api.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../model/endpoint_data.dart'; +import '../../http_errors.dart'; +import '../../marianumconnect/auth/device_token_name.dart'; + +/// Nextcloud Login Flow v2 (`/index.php/login/v2`): interactive browser login +/// that yields an app password. It is the only way to obtain working Nextcloud +/// credentials when the account is protected by two-factor authentication — +/// Basic auth with the real password is rejected server-side in that case. +class LoginFlowApi { + final http.Client _client; + + LoginFlowApi({http.Client? client}) : _client = client ?? http.Client(); + + /// Starts a new flow. Nextcloud displays the request's User-Agent as the + /// token name in the user's security settings, so the device token label is + /// sent (`"Marianum Fulda App (Pixel 10)"`). + Future start() async { + final userAgent = await DeviceTokenName.resolve(); + final uri = _initUri(); + const label = 'Nextcloud login flow init'; + final response = (await sendGuarded( + label, + () => _client.post( + uri, + headers: {'Accept': 'application/json', 'User-Agent': userAgent}, + ), + ))!; + if (response.statusCode < 200 || response.statusCode >= 300) { + throwForStatus( + response.statusCode, + httpErrorDetail(label, response.body, response.statusCode), + ); + } + return LoginFlowInit.fromJson( + jsonDecode(utf8.decode(response.bodyBytes)) as Map, + ); + } + + /// Polls for the flow result: `null` while the browser login has not been + /// completed yet (HTTP 404), the final credentials once it has. + Future poll(LoginFlowInit flow) async { + const label = 'Nextcloud login flow poll'; + final response = (await sendGuarded( + label, + () => _client.post( + Uri.parse(flow.pollEndpoint), + headers: {'Accept': 'application/json'}, + body: {'token': flow.pollToken}, + ), + ))!; + if (response.statusCode == 404) return null; + if (response.statusCode < 200 || response.statusCode >= 300) { + throwForStatus( + response.statusCode, + httpErrorDetail(label, response.body, response.statusCode), + ); + } + return LoginFlowCredentials.fromJson( + jsonDecode(utf8.decode(response.bodyBytes)) as Map, + ); + } + + static Uri _initUri() { + final endpoint = EndpointData().nextcloud(); + return Uri.https(endpoint.domain, '${endpoint.path}/index.php/login/v2'); + } + + /// Whether the login name reported by the completed flow belongs to the + /// account this app session expects — the browser login could have been + /// completed with a different Nextcloud account. + static bool loginNameMatches({ + required String expected, + required String actual, + }) => actual.trim().toLowerCase() == expected.trim().toLowerCase(); +} + +/// Response of the flow init call: the URL the user opens in the browser plus +/// the token/endpoint pair the app polls until the login is confirmed. +class LoginFlowInit { + final String loginUrl; + final String pollToken; + final String pollEndpoint; + + const LoginFlowInit({ + required this.loginUrl, + required this.pollToken, + required this.pollEndpoint, + }); + + factory LoginFlowInit.fromJson(Map json) { + final poll = json['poll']; + final loginUrl = json['login'] as String?; + final token = poll is Map ? poll['token'] as String? : null; + final endpoint = poll is Map ? poll['endpoint'] as String? : null; + if (loginUrl == null || loginUrl.isEmpty) { + throw const FormatException('login flow init: missing login url'); + } + if (token == null || token.isEmpty || endpoint == null || endpoint.isEmpty) { + throw const FormatException('login flow init: missing poll token/endpoint'); + } + return LoginFlowInit( + loginUrl: loginUrl, + pollToken: token, + pollEndpoint: endpoint, + ); + } +} + +/// Credentials returned once the user confirmed the login in the browser. +class LoginFlowCredentials { + final String server; + final String loginName; + final String appPassword; + + const LoginFlowCredentials({ + required this.server, + required this.loginName, + required this.appPassword, + }); + + factory LoginFlowCredentials.fromJson(Map json) { + final loginName = json['loginName'] as String?; + final appPassword = json['appPassword'] as String?; + if (loginName == null || loginName.isEmpty) { + throw const FormatException('login flow poll: missing loginName'); + } + if (appPassword == null || appPassword.isEmpty) { + throw const FormatException('login flow poll: missing appPassword'); + } + return LoginFlowCredentials( + server: json['server'] as String? ?? '', + loginName: loginName, + appPassword: appPassword, + ); + } +} diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 76bbafc..942b560 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -7,18 +7,31 @@ import '../../api_response.dart'; abstract class WebdavApi { T genericParams; - WebdavApi(this.genericParams) { - establishWebdavConnection(); - } + WebdavApi(this.genericParams); Future run(); - static Future webdav = establishWebdavConnection(); + static Future? _webdav; + static String? _webdavSecret; + + /// Shared WebDAV client. Rebuilt whenever the effective Nextcloud secret + /// changes (app password minted/renewed, account switch) so it never keeps + /// authenticating with stale credentials. + static Future get webdav { + final secret = AccountData().getNextcloudSecret(); + if (_webdav == null || _webdavSecret != secret) { + _webdavSecret = secret; + _webdav = establishWebdavConnection(); + } + return _webdav!; + } static Future establishWebdavConnection() async => NextcloudClient( Uri.parse('https://${EndpointData().nextcloud().full()}'), - password: AccountData().getPassword(), + // App password preferred — with 2FA the real password is not accepted + // by Nextcloud at all (see AccountData.usesLoginFlow). + password: AccountData().getNextcloudSecret(), loginName: AccountData().getUsername(), ).webdav; diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 3b92fdb..29c0f3b 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -17,6 +17,10 @@ class AccountData { // token, so two registrations need two app passwords. static const _appPasswordField = 'nextcloud_app_password'; static const _appPasswordTalkField = 'nextcloud_app_password_talk'; + // Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA): + // the real password is not valid against Nextcloud, only the flow-issued + // app password is — and no further app passwords can be minted silently. + static const _loginFlowField = 'nextcloud_login_flow'; // Persists the demo session across cold starts (see DemoMode). static const _demoField = 'is_demo'; // Keeps isPopulated()/getPassword() valid; demo mode never uses a real one. @@ -38,10 +42,17 @@ class AccountData { String? _appPassword; String? _appPasswordTalk; bool _isDemo = false; + bool _usesLoginFlow = false; /// True while the active session is a local demo session (see DemoMode). bool get isDemo => _isDemo; + /// True when the Nextcloud credentials were obtained via Login Flow v2 + /// (browser login, e.g. because the account has two-factor authentication). + /// In that mode the stored real password only authenticates MarianumConnect; + /// every Nextcloud call must use the flow-issued app password. + bool get usesLoginFlow => _usesLoginFlow; + String getUsername() { if (_username == null) throw Exception('Username not initialized'); return _username!; @@ -86,9 +97,11 @@ class AccountData { _appPassword = null; _appPasswordTalk = null; _isDemo = false; + _usesLoginFlow = false; await _secureStorage.delete(key: _usernameField); await _secureStorage.delete(key: _passwordField); await _secureStorage.delete(key: _demoField); + await _secureStorage.delete(key: _loginFlowField); await _clearAppPasswordStorage(); await _clearAppPasswordTalkStorage(); } @@ -111,6 +124,17 @@ class AccountData { await _clearAppPasswordStorage(); } + /// Adopts an app password obtained via Login Flow v2 and switches the + /// account into flow mode (see [usesLoginFlow]). Any previously stored Talk + /// app password belonged to the old session era and is dropped — the second + /// (optional) flow pass stores a fresh one via [setAppPasswordTalk]. + Future setLoginFlow(String appPassword) async { + await setAppPassword(appPassword); + await clearAppPasswordTalk(); + _usesLoginFlow = true; + await _secureStorage.write(key: _loginFlowField, value: 'true'); + } + bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty; /// Persists the app password backing the Talk push registration. @@ -156,6 +180,7 @@ class AccountData { _username = await _secureStorage.read(key: _usernameField); _password = await _secureStorage.read(key: _passwordField); _isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; + _usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true'; try { _appPassword = await pushSecureStorage.read(key: _appPasswordField); _appPasswordTalk = await pushSecureStorage.read( @@ -209,6 +234,10 @@ class AccountData { String getTalkBasicAuthHeader() { _requirePopulated(); if (!hasAppPasswordTalk()) { + // Login-flow account whose second (talk) flow pass was skipped: no + // silent minting possible, the talk registration shares the single + // flow-issued credential. + if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!); throw StateError('Talk app password not available yet'); } return _basicAuth(_appPasswordTalk!); @@ -222,6 +251,15 @@ class AccountData { return _basicAuth(_password!); } + /// Secret authenticating against Nextcloud: the app password once available + /// (minted or flow-issued), otherwise the real password. Mirrors the + /// preference of [getBasicAuthHeader] for clients that need the raw secret + /// (WebDAV client construction). + String getNextcloudSecret() { + _requirePopulated(); + return _appPassword ?? _password!; + } + void _requirePopulated() { if (!isPopulated()) { throw Exception( diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 1d2556d..287de6c 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -67,6 +67,13 @@ class PushRegistration { /// registration binds to it, so it must be obtained before registering. Future ensureAppPassword() async { if (AccountData().hasAppPassword()) return; + if (AccountData().usesLoginFlow) { + // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt, + // stilles Minting ist unmöglich. Reparatur nur interaktiv über + // Einstellungen → „Nextcloud neu verbinden". + log('Push: login-flow account without app password, cannot mint silently'); + return; + } try { final appPassword = await GetAppPassword().run(); await AccountData().setAppPassword(appPassword); @@ -79,6 +86,11 @@ class PushRegistration { /// (each `getapppassword` call with the real password mints a fresh one). Future ensureTalkAppPassword() async { if (AccountData().hasAppPasswordTalk()) return; + // Flow-Konten können still kein zweites App-Passwort münzen — das + // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin + // teilt sich die Talk-Registrierung das eine App-Passwort (siehe + // AccountData.getTalkBasicAuthHeader). + if (AccountData().usesLoginFlow) return; try { final appPassword = await GetAppPassword().run(); await AccountData().setAppPasswordTalk(appPassword); @@ -129,8 +141,19 @@ class PushRegistration { appVersion = null; } + final types = registrationTypesFor( + usesLoginFlow: AccountData().usesLoginFlow, + hasTalkAppPassword: AccountData().hasAppPasswordTalk(), + ); + if (!types.contains(PushRegistrationType.general)) { + await _recordAttempt( + PushRegistrationType.general, + 'Ohne zweite Nextcloud-Freigabe nicht verfügbar (nur Talk-Push)', + ); + } + var allOk = true; - for (final type in PushRegistrationType.values) { + for (final type in types) { final ok = await _registerType( type: type, fcmToken: fcmToken, @@ -260,6 +283,19 @@ class PushRegistration { await _store.clear(); } + /// Pure decision which Nextcloud registrations this session can maintain. + /// Flow-Konten (2FA), die nur den ersten Login-Flow-Durchlauf abgeschlossen + /// haben, besitzen eine einzige NC-Session — Nextcloud bindet pro Session + /// genau eine Subscription, also bleibt nur die (wichtigere) + /// Talk-Registrierung. Mit dem zweiten (Talk-)App-Passwort aus dem + /// optionalen zweiten Durchlauf laufen wieder beide. + static List registrationTypesFor({ + required bool usesLoginFlow, + required bool hasTalkAppPassword, + }) => usesLoginFlow && !hasTalkAppPassword + ? const [PushRegistrationType.talk] + : PushRegistrationType.values; + /// Pure decision for whether a persisted registration endpoint no longer /// matches the currently active one. A missing/empty stored value never /// forces a re-registration — old installs (pre endpoint-tracking) heal via diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 2c389dc..fb26356 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -18,6 +18,7 @@ import '../state/app/modules/app_modules.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../view/login/nextcloud_login_flow_page.dart'; import '../view/pages/files/files.dart'; import '../view/pages/files/sharing/sharee_picker_page.dart'; import '../view/pages/foreign_timetable/element_picker_page.dart'; @@ -114,6 +115,20 @@ class AppRoutes { ); } + /// Runs the Nextcloud Login Flow v2 (browser login, e.g. for accounts with + /// two-factor authentication) and resolves to `true` once an app password + /// was adopted. Used from the login flow and the settings "reconnect" + /// action. + static Future openNextcloudLoginFlow(BuildContext context) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => const NextcloudLoginFlowPage(), + ), + ); + return result ?? false; + } + /// Opens the tappable, zoomable profile-picture viewer for [id]. static void openLargeProfilePicture(BuildContext context, String id) { Navigator.of(context).push( diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index 83ca987..cb25b36 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; @@ -6,13 +5,29 @@ import 'package:flutter/foundation.dart'; import '../../api/demo/demo_mode.dart'; import '../../api/errors/auth_exception.dart'; import '../../api/errors/error_mapper.dart'; +import '../../api/marianumcloud/app_password/get_app_password.dart'; import '../../api/marianumconnect/auth/device_token_name.dart'; import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; +import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../model/account_data.dart'; -import '../../push/push_registration.dart'; import '../../widget_data/widget_sync.dart'; +/// Outcome of a login attempt. +enum LoginResult { + /// Fully logged in — the view transitions to `loggedIn`. + success, + + /// Credentials rejected or a transport problem; the error is exposed via + /// [LoginController.errorMessage]. + failure, + + /// MarianumConnect accepted the credentials, but Nextcloud rejects them + /// (two-factor authentication active or diverging password). The view must + /// complete the Nextcloud Login Flow v2 in the browser before proceeding. + nextcloudLoginRequired, +} + /// Owns the login flow's transient state (loading, last error) so it can be /// driven from a thin Stateful view and unit-tested without a widget tree. class LoginController extends ChangeNotifier { @@ -24,10 +39,8 @@ class LoginController extends ChangeNotifier { String? get errorMessage => _errorMessage; String? get errorDetails => _errorDetails; - /// Returns `true` when the credential probe succeeded. The view should - /// then transition the AccountBloc to `loggedIn`. - Future submit(String username, String password) async { - if (_loading) return false; + Future submit(String username, String password) async { + if (_loading) return LoginResult.failure; _loading = true; _errorMessage = null; _errorDetails = null; @@ -45,7 +58,7 @@ class LoginController extends ChangeNotifier { await AccountData().setDemo(user); _loading = false; notifyListeners(); - return true; + return LoginResult.success; } try { @@ -65,13 +78,13 @@ class LoginController extends ChangeNotifier { tokenName: await DeviceTokenName.resolve(), ); await AccountData().setData(user, password); - // Mint the Nextcloud app password now so it's ready for the push - // registration and subsequent NC calls. Non-blocking: on failure push - // stays off and retries on the next start. - unawaited(PushRegistration().ensureAppPassword()); + // Mint the Nextcloud app password now — it doubles as the Nextcloud + // credential probe: a rejection means 2FA is active (or the NC password + // diverges) and the login must finish interactively in the browser. + final ncReady = await _prepareNextcloudAppPassword(); _loading = false; notifyListeners(); - return true; + return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired; } catch (e) { log(e.toString()); await AccountData().removeData(); @@ -83,7 +96,41 @@ class LoginController extends ChangeNotifier { _errorDetails = errorToTechnicalDetails(e); _loading = false; notifyListeners(); - return false; + return LoginResult.failure; } } + + /// Tries to mint the Nextcloud app password with the just-verified password. + /// `false` = Nextcloud rejected the credentials → Login Flow v2 required. + /// Transport/server problems stay non-blocking (like the previous + /// fire-and-forget mint): the mint retries with the push registration. + Future _prepareNextcloudAppPassword() async { + try { + final appPassword = await GetAppPassword().run(); + await AccountData().setAppPassword(appPassword); + return true; + } on AuthException { + return false; + } on Object catch (e) { + log('Nextcloud app password mint failed (non-blocking): $e'); + return true; + } + } + + /// Rolls the half-finished login back after the user cancelled the + /// Nextcloud browser login: revoke the fresh MarianumConnect token and wipe + /// the stored credentials, then surface why the login did not complete. + Future abortNextcloudLogin() async { + try { + await AuthLogout().run(); + } on Object catch (e) { + log('Login rollback: MC logout failed: $e'); + } + await AccountData().removeData(); + await const MarianumConnectTokenStorage().clear(); + _errorMessage = + 'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.'; + _errorDetails = null; + notifyListeners(); + } } diff --git a/lib/view/login/nextcloud_login_flow_page.dart b/lib/view/login/nextcloud_login_flow_page.dart new file mode 100644 index 0000000..bcd784f --- /dev/null +++ b/lib/view/login/nextcloud_login_flow_page.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; + +import '../../api/errors/error_mapper.dart'; +import '../../api/marianumcloud/app_password/delete_app_password.dart'; +import '../../api/marianumcloud/login_flow/login_flow_api.dart'; +import '../../model/account_data.dart'; +import '../../routing/app_routes.dart'; +import '../../widget/app_progress_indicator.dart'; + +/// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine +/// App-Passwort (voller Browser-Login inkl. 2FA), der zweite das +/// Talk-App-Passwort für die zweite Push-Subscription — der Browser hat dann +/// bereits eine Session, es bleibt nur der „Zugriff gewähren"-Tipp. +enum _FlowStep { primary, talk } + +/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the +/// user confirmed it there (2FA happens inside the browser) and adopts the +/// returned app password via [AccountData.setLoginFlow]. A second, skippable +/// pass mints the Talk app password so flow accounts keep BOTH push +/// subscriptions (see PushRegistrationType). Pops `true` once the primary +/// credential was adopted, `false`/`null` when the user backs out before that. +class NextcloudLoginFlowPage extends StatefulWidget { + const NextcloudLoginFlowPage({super.key}); + + @override + State createState() => _NextcloudLoginFlowPageState(); +} + +class _NextcloudLoginFlowPageState extends State + with WidgetsBindingObserver { + static const _pollInterval = Duration(seconds: 3); + // Serverseitig verfällt der Flow-Token nach 20 Minuten — danach würde der + // Poll für immer 404 liefern, also vorher mit klarer Meldung abbrechen. + static const _flowTimeout = Duration(minutes: 15); + + final LoginFlowApi _api = LoginFlowApi(); + _FlowStep _step = _FlowStep.primary; + LoginFlowInit? _flow; + Timer? _timer; + DateTime? _startedAt; + bool _polling = false; + bool _finished = false; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + unawaited(_start()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _timer?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Der Nutzer kommt gerade aus dem Browser zurück — sofort pollen statt + // bis zu einem Intervall zu warten. + if (state == AppLifecycleState.resumed) unawaited(_poll()); + } + + Future _start() async { + _timer?.cancel(); + setState(() { + _error = null; + _flow = null; + }); + try { + final flow = await _api.start(); + if (!mounted) return; + setState(() => _flow = flow); + _startedAt = DateTime.now(); + _timer = Timer.periodic(_pollInterval, (_) => _poll()); + unawaited(AppRoutes.openExternalUrl(flow.loginUrl)); + } catch (e) { + if (!mounted) return; + setState(() => _error = errorToUserMessage(e)); + } + } + + Future _poll() async { + final flow = _flow; + if (flow == null || _polling || _finished || _error != null) return; + final startedAt = _startedAt; + if (startedAt != null && + DateTime.now().difference(startedAt) > _flowTimeout) { + _timer?.cancel(); + setState( + () => _error = + 'Zeitüberschreitung — die Anmeldung im Browser wurde nicht abgeschlossen.', + ); + return; + } + _polling = true; + try { + final credentials = await _api.poll(flow); + if (credentials == null || _finished || !mounted) return; + if (!LoginFlowApi.loginNameMatches( + expected: AccountData().getUsername(), + actual: credentials.loginName, + )) { + _timer?.cancel(); + // Das versehentlich für das fremde Konto ausgestellte App-Passwort + // nicht liegen lassen. + unawaited(_revokeForeignAppPassword(credentials)); + setState( + () => _error = + 'Im Browser wurde ein anderes Konto angemeldet („${credentials.loginName}“). ' + 'Bitte versuche es erneut mit deinem Konto.', + ); + return; + } + switch (_step) { + case _FlowStep.primary: + await AccountData().setLoginFlow(credentials.appPassword); + if (!mounted) return; + // Zweite Freigabe direkt anstoßen: die Browser-Session besteht + // bereits, es fehlt nur noch der Grant-Tipp. + setState(() => _step = _FlowStep.talk); + unawaited(_start()); + case _FlowStep.talk: + _finished = true; + _timer?.cancel(); + await AccountData().setAppPasswordTalk(credentials.appPassword); + if (!mounted) return; + Navigator.of(context).pop(true); + } + } on Object catch (e) { + // Transienter Poll-Fehler (z.B. kurz offline) — der nächste Tick + // versucht es erneut. + log('Login flow poll failed (retrying): $e'); + } finally { + _polling = false; + } + } + + /// Der Talk-Schritt ist optional: ohne zweites App-Passwort funktioniert + /// alles außer den allgemeinen Nextcloud-Pushes (Talk-Push bleibt erhalten). + void _skipTalkStep() { + _finished = true; + _timer?.cancel(); + Navigator.of(context).pop(true); + } + + static Future _revokeForeignAppPassword( + LoginFlowCredentials credentials, + ) async { + try { + final basic = base64Encode( + utf8.encode('${credentials.loginName}:${credentials.appPassword}'), + ); + await DeleteAppPassword().run(authorizationHeader: 'Basic $basic'); + } on Object catch (e) { + log('Login flow: could not revoke foreign app password: $e'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final flow = _flow; + final error = _error; + final isTalkStep = _step == _FlowStep.talk; + // Ab dem Talk-Schritt ist das primäre App-Passwort bereits übernommen — + // Zurück heißt dann „überspringen" (pop true), nicht „Login abbrechen": + // die Aufrufer würden bei false den kompletten Login zurückrollen. + return PopScope( + canPop: !isTalkStep, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && !_finished) _skipTalkStep(); + }, + child: Scaffold( + appBar: AppBar(title: const Text('Nextcloud-Anmeldung')), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + isTalkStep + ? Icons.notifications_active_outlined + : Icons.verified_user_outlined, + size: 56, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + isTalkStep + ? 'Fast geschafft!' + : 'Bestätigung erforderlich', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + isTalkStep + ? 'Damit Benachrichtigungen vollständig ankommen, braucht ' + 'die App eine zweite Freigabe. Du bist im Browser ' + 'bereits angemeldet — es genügt ein Tipp auf ' + '„Zugriff gewähren“.' + : 'Dein Konto ist zusätzlich geschützt (z.B. durch ' + 'Zwei-Faktor-Authentifizierung). Schließe die ' + 'Anmeldung im Browser ab — danach geht es hier ' + 'automatisch weiter.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + if (error != null) ...[ + Text( + error, + textAlign: TextAlign.center, + style: TextStyle(color: theme.colorScheme.error), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _start, + child: const Text('Erneut versuchen'), + ), + ] else if (flow == null) ...[ + const Center(child: AppProgressIndicator.medium()), + ] else ...[ + FilledButton.icon( + icon: const Icon(Icons.open_in_browser), + onPressed: () => + unawaited(AppRoutes.openExternalUrl(flow.loginUrl)), + label: Text( + isTalkStep + ? 'Freigabe im Browser bestätigen' + : 'Anmeldung im Browser öffnen', + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AppProgressIndicator.small(), + const SizedBox(width: 10), + Text( + 'Warte auf Bestätigung im Browser…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + if (isTalkStep) ...[ + const SizedBox(height: 12), + TextButton( + onPressed: _skipTalkStep, + child: const Text('Überspringen'), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index 3f48dcc..a259d98 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../routing/app_routes.dart'; import '../login_controller.dart'; import 'login_error_banner.dart'; @@ -51,11 +52,27 @@ class _LoginCardState extends State { Future _submit() async { if (widget.controller.loading) return; if (!(_formKey.currentState?.validate() ?? false)) return; - final ok = await widget.controller.submit( + final result = await widget.controller.submit( _usernameController.text, _passwordController.text, ); - if (ok && mounted) widget.onSuccess(); + if (!mounted) return; + switch (result) { + case LoginResult.success: + widget.onSuccess(); + case LoginResult.nextcloudLoginRequired: + // 2FA (oder abweichendes NC-Passwort): Anmeldung im Browser über den + // Login Flow v2 abschließen; ohne Erfolg wird der Login zurückgerollt. + final ok = await AppRoutes.openNextcloudLoginFlow(context); + if (!mounted) return; + if (ok) { + widget.onSuccess(); + } else { + await widget.controller.abortNextcloudLogin(); + } + case LoginResult.failure: + break; + } } InputDecoration _decoration(ThemeData theme, String label, IconData icon) => diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 1bd678a..797bc93 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -167,10 +169,35 @@ class _AccountSectionState extends State { ], ), ), + // Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen + // sich still über das App-Passwort-Minting und sollen von dem ganzen + // Flow-Mechanismus nichts mitbekommen. + if (!AccountData().isDemo && AccountData().usesLoginFlow) + AsyncListTile( + leading: const Icon(Icons.cloud_sync_outlined), + title: const Text('Nextcloud neu verbinden'), + subtitle: const Text( + 'Bei Anmeldeproblemen in Talk oder Dateien', + ), + closeOnSuccess: false, + onPressed: _reconnectNextcloud, + ), ], ); } + /// Erneuert die Nextcloud-Zugangsdaten über den Login Flow v2 (inkl. des + /// zweiten Talk-Durchlaufs) und bindet die Push-Subscription neu. + Future _reconnectNextcloud() async { + final ok = await AppRoutes.openNextcloudLoginFlow(context); + if (!ok || !mounted) return; + // Neues App-Passwort = neue NC-Session: die Push-Subscription neu binden. + unawaited(PushRegistration().register()); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')), + ); + } + Future _showLogoutDialog(BuildContext context) async { // Flip AccountBloc state only after the dialog fully closes: doing it from // inside removeData (the previous approach) raced AsyncDialogAction's diff --git a/pubspec.yaml b/pubspec.yaml index fddbfeb..6c3e298 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration publish_to: 'none' -version: 1.5.1+61 +version: 1.5.2+62 environment: sdk: ">=3.8.0 <4.0.0" diff --git a/test/api/marianumcloud/login_flow_api_test.dart b/test/api/marianumcloud/login_flow_api_test.dart new file mode 100644 index 0000000..13e78a0 --- /dev/null +++ b/test/api/marianumcloud/login_flow_api_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/login_flow/login_flow_api.dart'; + +void main() { + group('LoginFlowInit.fromJson', () { + test('parses a complete init response', () { + final init = LoginFlowInit.fromJson({ + 'poll': { + 'token': 'abc123', + 'endpoint': 'https://cloud.example.org/login/v2/poll', + }, + 'login': 'https://cloud.example.org/login/v2/flow/xyz', + }); + expect(init.loginUrl, 'https://cloud.example.org/login/v2/flow/xyz'); + expect(init.pollToken, 'abc123'); + expect(init.pollEndpoint, 'https://cloud.example.org/login/v2/poll'); + }); + + test('throws on missing login url', () { + expect( + () => LoginFlowInit.fromJson({ + 'poll': {'token': 't', 'endpoint': 'e'}, + }), + throwsFormatException, + ); + }); + + test('throws on missing poll token or endpoint', () { + expect( + () => LoginFlowInit.fromJson({ + 'poll': {'endpoint': 'e'}, + 'login': 'l', + }), + throwsFormatException, + ); + expect( + () => LoginFlowInit.fromJson({ + 'poll': {'token': 't'}, + 'login': 'l', + }), + throwsFormatException, + ); + expect( + () => LoginFlowInit.fromJson({'login': 'l'}), + throwsFormatException, + ); + }); + }); + + group('LoginFlowCredentials.fromJson', () { + test('parses a complete poll response', () { + final credentials = LoginFlowCredentials.fromJson({ + 'server': 'https://cloud.example.org', + 'loginName': 'jdoe', + 'appPassword': 'secret-app-password', + }); + expect(credentials.server, 'https://cloud.example.org'); + expect(credentials.loginName, 'jdoe'); + expect(credentials.appPassword, 'secret-app-password'); + }); + + test('tolerates a missing server field', () { + final credentials = LoginFlowCredentials.fromJson({ + 'loginName': 'jdoe', + 'appPassword': 'secret', + }); + expect(credentials.server, ''); + }); + + test('throws on missing loginName or appPassword', () { + expect( + () => LoginFlowCredentials.fromJson({'appPassword': 'secret'}), + throwsFormatException, + ); + expect( + () => LoginFlowCredentials.fromJson({'loginName': 'jdoe'}), + throwsFormatException, + ); + expect( + () => LoginFlowCredentials.fromJson({ + 'loginName': 'jdoe', + 'appPassword': '', + }), + throwsFormatException, + ); + }); + }); + + group('LoginFlowApi.loginNameMatches', () { + test('matches case-insensitively and ignores surrounding whitespace', () { + expect( + LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: 'JDoe'), + isTrue, + ); + expect( + LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: ' jdoe '), + isTrue, + ); + }); + + test('rejects a different account', () { + expect( + LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: 'other'), + isFalse, + ); + }); + }); +} diff --git a/test/push/push_dual_registration_test.dart b/test/push/push_dual_registration_test.dart index acc416c..70f89fc 100644 --- a/test/push/push_dual_registration_test.dart +++ b/test/push/push_dual_registration_test.dart @@ -53,6 +53,45 @@ void main() { }); }); + group('registrationTypesFor', () { + test('password accounts maintain both registrations', () { + expect( + PushRegistration.registrationTypesFor( + usesLoginFlow: false, + hasTalkAppPassword: false, + ), + PushRegistrationType.values, + ); + expect( + PushRegistration.registrationTypesFor( + usesLoginFlow: false, + hasTalkAppPassword: true, + ), + PushRegistrationType.values, + ); + }); + + test('flow account without talk app password is talk-only', () { + expect( + PushRegistration.registrationTypesFor( + usesLoginFlow: true, + hasTalkAppPassword: false, + ), + [PushRegistrationType.talk], + ); + }); + + test('flow account with second (talk) app password maintains both', () { + expect( + PushRegistration.registrationTypesFor( + usesLoginFlow: true, + hasTalkAppPassword: true, + ), + PushRegistrationType.values, + ); + }); + }); + group('pushTokenVariant', () { test('general uses the raw token, talk appends the suffix', () { expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');