added support for 2fa login with browser flow

This commit is contained in:
2026-08-10 20:00:38 +02:00
parent 889d8f67c5
commit ccb22a497d
13 changed files with 805 additions and 35 deletions
+38
View File
@@ -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<void> 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(