141 lines
4.8 KiB
Dart
141 lines
4.8 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|