added support for 2fa login with browser flow
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../model/account_data.dart';
|
||||||
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
/// 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();
|
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
/// Returns the freshly minted app password. Throws on any transport or
|
/// Returns the freshly minted app password. Throws on any transport or
|
||||||
/// protocol error — callers treat push registration as best-effort and swallow
|
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||||
/// failures.
|
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||||
|
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||||
Future<String> run() async {
|
Future<String> run() async {
|
||||||
final response = await _client.get(
|
const label = 'Nextcloud getapppassword';
|
||||||
NextcloudOcs.uri('core/getapppassword'),
|
final response = (await sendGuarded(
|
||||||
headers: {
|
label,
|
||||||
...NextcloudOcs.headers(),
|
() => _client.get(
|
||||||
// Deliberately NOT the shared Authorization value: that one prefers
|
NextcloudOcs.uri('core/getapppassword'),
|
||||||
// the app password, but an app password cannot mint another one —
|
headers: {
|
||||||
// this endpoint requires the real password.
|
...NextcloudOcs.headers(),
|
||||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
// 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) {
|
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 json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
final data = (json as Map)['ocs']?['data'];
|
final data = (json as Map)['ocs']?['data'];
|
||||||
|
|||||||
@@ -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<LoginFlowInit> 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<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls for the flow result: `null` while the browser login has not been
|
||||||
|
/// completed yet (HTTP 404), the final credentials once it has.
|
||||||
|
Future<LoginFlowCredentials?> 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<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,31 @@ import '../../api_response.dart';
|
|||||||
abstract class WebdavApi<T> {
|
abstract class WebdavApi<T> {
|
||||||
T genericParams;
|
T genericParams;
|
||||||
|
|
||||||
WebdavApi(this.genericParams) {
|
WebdavApi(this.genericParams);
|
||||||
establishWebdavConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ApiResponse> run();
|
Future<ApiResponse> run();
|
||||||
|
|
||||||
static Future<WebDavClient> webdav = establishWebdavConnection();
|
static Future<WebDavClient>? _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<WebDavClient> get webdav {
|
||||||
|
final secret = AccountData().getNextcloudSecret();
|
||||||
|
if (_webdav == null || _webdavSecret != secret) {
|
||||||
|
_webdavSecret = secret;
|
||||||
|
_webdav = establishWebdavConnection();
|
||||||
|
}
|
||||||
|
return _webdav!;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<WebDavClient> establishWebdavConnection() async =>
|
static Future<WebDavClient> establishWebdavConnection() async =>
|
||||||
NextcloudClient(
|
NextcloudClient(
|
||||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
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(),
|
loginName: AccountData().getUsername(),
|
||||||
).webdav;
|
).webdav;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class AccountData {
|
|||||||
// token, so two registrations need two app passwords.
|
// token, so two registrations need two app passwords.
|
||||||
static const _appPasswordField = 'nextcloud_app_password';
|
static const _appPasswordField = 'nextcloud_app_password';
|
||||||
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
|
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).
|
// Persists the demo session across cold starts (see DemoMode).
|
||||||
static const _demoField = 'is_demo';
|
static const _demoField = 'is_demo';
|
||||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
||||||
@@ -38,10 +42,17 @@ class AccountData {
|
|||||||
String? _appPassword;
|
String? _appPassword;
|
||||||
String? _appPasswordTalk;
|
String? _appPasswordTalk;
|
||||||
bool _isDemo = false;
|
bool _isDemo = false;
|
||||||
|
bool _usesLoginFlow = false;
|
||||||
|
|
||||||
/// True while the active session is a local demo session (see DemoMode).
|
/// True while the active session is a local demo session (see DemoMode).
|
||||||
bool get isDemo => _isDemo;
|
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() {
|
String getUsername() {
|
||||||
if (_username == null) throw Exception('Username not initialized');
|
if (_username == null) throw Exception('Username not initialized');
|
||||||
return _username!;
|
return _username!;
|
||||||
@@ -86,9 +97,11 @@ class AccountData {
|
|||||||
_appPassword = null;
|
_appPassword = null;
|
||||||
_appPasswordTalk = null;
|
_appPasswordTalk = null;
|
||||||
_isDemo = false;
|
_isDemo = false;
|
||||||
|
_usesLoginFlow = false;
|
||||||
await _secureStorage.delete(key: _usernameField);
|
await _secureStorage.delete(key: _usernameField);
|
||||||
await _secureStorage.delete(key: _passwordField);
|
await _secureStorage.delete(key: _passwordField);
|
||||||
await _secureStorage.delete(key: _demoField);
|
await _secureStorage.delete(key: _demoField);
|
||||||
|
await _secureStorage.delete(key: _loginFlowField);
|
||||||
await _clearAppPasswordStorage();
|
await _clearAppPasswordStorage();
|
||||||
await _clearAppPasswordTalkStorage();
|
await _clearAppPasswordTalkStorage();
|
||||||
}
|
}
|
||||||
@@ -111,6 +124,17 @@ class AccountData {
|
|||||||
await _clearAppPasswordStorage();
|
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;
|
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
||||||
|
|
||||||
/// Persists the app password backing the Talk push registration.
|
/// Persists the app password backing the Talk push registration.
|
||||||
@@ -156,6 +180,7 @@ class AccountData {
|
|||||||
_username = await _secureStorage.read(key: _usernameField);
|
_username = await _secureStorage.read(key: _usernameField);
|
||||||
_password = await _secureStorage.read(key: _passwordField);
|
_password = await _secureStorage.read(key: _passwordField);
|
||||||
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
||||||
|
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||||
try {
|
try {
|
||||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||||
_appPasswordTalk = await pushSecureStorage.read(
|
_appPasswordTalk = await pushSecureStorage.read(
|
||||||
@@ -209,6 +234,10 @@ class AccountData {
|
|||||||
String getTalkBasicAuthHeader() {
|
String getTalkBasicAuthHeader() {
|
||||||
_requirePopulated();
|
_requirePopulated();
|
||||||
if (!hasAppPasswordTalk()) {
|
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');
|
throw StateError('Talk app password not available yet');
|
||||||
}
|
}
|
||||||
return _basicAuth(_appPasswordTalk!);
|
return _basicAuth(_appPasswordTalk!);
|
||||||
@@ -222,6 +251,15 @@ class AccountData {
|
|||||||
return _basicAuth(_password!);
|
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() {
|
void _requirePopulated() {
|
||||||
if (!isPopulated()) {
|
if (!isPopulated()) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ class PushRegistration {
|
|||||||
/// registration binds to it, so it must be obtained before registering.
|
/// registration binds to it, so it must be obtained before registering.
|
||||||
Future<void> ensureAppPassword() async {
|
Future<void> ensureAppPassword() async {
|
||||||
if (AccountData().hasAppPassword()) return;
|
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 {
|
try {
|
||||||
final appPassword = await GetAppPassword().run();
|
final appPassword = await GetAppPassword().run();
|
||||||
await AccountData().setAppPassword(appPassword);
|
await AccountData().setAppPassword(appPassword);
|
||||||
@@ -79,6 +86,11 @@ class PushRegistration {
|
|||||||
/// (each `getapppassword` call with the real password mints a fresh one).
|
/// (each `getapppassword` call with the real password mints a fresh one).
|
||||||
Future<void> ensureTalkAppPassword() async {
|
Future<void> ensureTalkAppPassword() async {
|
||||||
if (AccountData().hasAppPasswordTalk()) return;
|
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 {
|
try {
|
||||||
final appPassword = await GetAppPassword().run();
|
final appPassword = await GetAppPassword().run();
|
||||||
await AccountData().setAppPasswordTalk(appPassword);
|
await AccountData().setAppPasswordTalk(appPassword);
|
||||||
@@ -129,8 +141,19 @@ class PushRegistration {
|
|||||||
appVersion = null;
|
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;
|
var allOk = true;
|
||||||
for (final type in PushRegistrationType.values) {
|
for (final type in types) {
|
||||||
final ok = await _registerType(
|
final ok = await _registerType(
|
||||||
type: type,
|
type: type,
|
||||||
fcmToken: fcmToken,
|
fcmToken: fcmToken,
|
||||||
@@ -260,6 +283,19 @@ class PushRegistration {
|
|||||||
await _store.clear();
|
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<PushRegistrationType> registrationTypesFor({
|
||||||
|
required bool usesLoginFlow,
|
||||||
|
required bool hasTalkAppPassword,
|
||||||
|
}) => usesLoginFlow && !hasTalkAppPassword
|
||||||
|
? const [PushRegistrationType.talk]
|
||||||
|
: PushRegistrationType.values;
|
||||||
|
|
||||||
/// Pure decision for whether a persisted registration endpoint no longer
|
/// Pure decision for whether a persisted registration endpoint no longer
|
||||||
/// matches the currently active one. A missing/empty stored value never
|
/// matches the currently active one. A missing/empty stored value never
|
||||||
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
|
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
|
||||||
|
|||||||
@@ -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/bloc/chat_bloc.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_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 '../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/files.dart';
|
||||||
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
||||||
import '../view/pages/foreign_timetable/element_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<bool> openNextcloudLoginFlow(BuildContext context) async {
|
||||||
|
final result = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) => const NextcloudLoginFlowPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens the tappable, zoomable profile-picture viewer for [id].
|
/// Opens the tappable, zoomable profile-picture viewer for [id].
|
||||||
static void openLargeProfilePicture(BuildContext context, String id) {
|
static void openLargeProfilePicture(BuildContext context, String id) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -6,13 +5,29 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../../api/demo/demo_mode.dart';
|
import '../../api/demo/demo_mode.dart';
|
||||||
import '../../api/errors/auth_exception.dart';
|
import '../../api/errors/auth_exception.dart';
|
||||||
import '../../api/errors/error_mapper.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/device_token_name.dart';
|
||||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||||
import '../../api/marianumconnect/queries/auth_login/auth_login.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 '../../model/account_data.dart';
|
||||||
import '../../push/push_registration.dart';
|
|
||||||
import '../../widget_data/widget_sync.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
|
/// 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.
|
/// driven from a thin Stateful view and unit-tested without a widget tree.
|
||||||
class LoginController extends ChangeNotifier {
|
class LoginController extends ChangeNotifier {
|
||||||
@@ -24,10 +39,8 @@ class LoginController extends ChangeNotifier {
|
|||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
String? get errorDetails => _errorDetails;
|
String? get errorDetails => _errorDetails;
|
||||||
|
|
||||||
/// Returns `true` when the credential probe succeeded. The view should
|
Future<LoginResult> submit(String username, String password) async {
|
||||||
/// then transition the AccountBloc to `loggedIn`.
|
if (_loading) return LoginResult.failure;
|
||||||
Future<bool> submit(String username, String password) async {
|
|
||||||
if (_loading) return false;
|
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_errorDetails = null;
|
_errorDetails = null;
|
||||||
@@ -45,7 +58,7 @@ class LoginController extends ChangeNotifier {
|
|||||||
await AccountData().setDemo(user);
|
await AccountData().setDemo(user);
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return LoginResult.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -65,13 +78,13 @@ class LoginController extends ChangeNotifier {
|
|||||||
tokenName: await DeviceTokenName.resolve(),
|
tokenName: await DeviceTokenName.resolve(),
|
||||||
);
|
);
|
||||||
await AccountData().setData(user, password);
|
await AccountData().setData(user, password);
|
||||||
// Mint the Nextcloud app password now so it's ready for the push
|
// Mint the Nextcloud app password now — it doubles as the Nextcloud
|
||||||
// registration and subsequent NC calls. Non-blocking: on failure push
|
// credential probe: a rejection means 2FA is active (or the NC password
|
||||||
// stays off and retries on the next start.
|
// diverges) and the login must finish interactively in the browser.
|
||||||
unawaited(PushRegistration().ensureAppPassword());
|
final ncReady = await _prepareNextcloudAppPassword();
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
await AccountData().removeData();
|
await AccountData().removeData();
|
||||||
@@ -83,7 +96,41 @@ class LoginController extends ChangeNotifier {
|
|||||||
_errorDetails = errorToTechnicalDetails(e);
|
_errorDetails = errorToTechnicalDetails(e);
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../routing/app_routes.dart';
|
||||||
import '../login_controller.dart';
|
import '../login_controller.dart';
|
||||||
import 'login_error_banner.dart';
|
import 'login_error_banner.dart';
|
||||||
|
|
||||||
@@ -51,11 +52,27 @@ class _LoginCardState extends State<LoginCard> {
|
|||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
if (widget.controller.loading) return;
|
if (widget.controller.loading) return;
|
||||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
final ok = await widget.controller.submit(
|
final result = await widget.controller.submit(
|
||||||
_usernameController.text,
|
_usernameController.text,
|
||||||
_passwordController.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) =>
|
InputDecoration _decoration(ThemeData theme, String label, IconData icon) =>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 {
|
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
// inside removeData (the previous approach) raced AsyncDialogAction's
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
|||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.5.1+61
|
version: 1.5.2+62
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.8.0 <4.0.0"
|
sdk: ">=3.8.0 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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', () {
|
group('pushTokenVariant', () {
|
||||||
test('general uses the raw token, talk appends the suffix', () {
|
test('general uses the raw token, talk appends the suffix', () {
|
||||||
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
|
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
|
||||||
|
|||||||
Reference in New Issue
Block a user