Files
Client/lib/api/marianumcloud/login_flow/login_flow_api.dart
T

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,
);
}
}