added support for 2fa login with browser flow
This commit is contained in:
@@ -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<bool> submit(String username, String password) async {
|
||||
if (_loading) return false;
|
||||
Future<LoginResult> 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<bool> _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<void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NextcloudLoginFlowPage> createState() => _NextcloudLoginFlowPageState();
|
||||
}
|
||||
|
||||
class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
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<void> _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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LoginCard> {
|
||||
Future<void> _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) =>
|
||||
|
||||
@@ -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<AccountSection> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 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<void> _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<void> _showLogoutDialog(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
||||
|
||||
Reference in New Issue
Block a user