added support for 2fa login with browser flow
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user