Skip to content

Commit

Permalink
Implement digest authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
isaaclyman committed May 5, 2024
1 parent 7ed2e75 commit 8100912
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 61 deletions.
10 changes: 10 additions & 0 deletions lib/db/source.db.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:bookoscope/db/db.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -35,6 +37,14 @@ class Source {
required this.isEditable,
required this.isEnabled,
});

String? getBasicAuthHeader() {
if ((username?.isNotEmpty ?? false) && (password?.isNotEmpty ?? false)) {
return "Basic ${base64.encode(utf8.encode('$username:$password'))}";
}

return null;
}
}

class DBSources extends ChangeNotifier {
Expand Down
3 changes: 1 addition & 2 deletions lib/format/crawl_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ class BKCrawlManager {
Stream<OPDSCrawlEvent> crawlOpdsUri(Source source) async* {
final crawler = OPDSCrawler(
opdsRootUri: source.url,
username: source.username?.isNotEmpty ?? false ? source.username : null,
password: source.password?.isNotEmpty ?? false ? source.password : null,
source: source,
);
await dbSources.upsert(source);

Expand Down
8 changes: 3 additions & 5 deletions lib/format/opds/opds_crawler.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bookoscope/db/source.db.dart';
import 'package:bookoscope/format/opds/opds_events.dart';
import 'package:bookoscope/format/opds/opds_extractor.dart';
import 'package:bookoscope/format/opds/opds_resource.dart';
Expand All @@ -8,17 +9,14 @@ import 'package:collection/collection.dart';
/// Class for crawling an OPDS catalog starting from [opdsRootUri].
class OPDSCrawler {
final String opdsRootUri;
final String? username;
final String? password;
final Set<String> visitedEndpoints = {};
final extractor = OPDSExtractor();

OPDSCrawler({
required this.opdsRootUri,
required this.username,
required this.password,
required Source source,
}) {
extractor.useBasicAuth(username, password);
extractor.useAuth(source);
}

/// Recursively crawls the XML response from [opdsRootUri], then
Expand Down
64 changes: 53 additions & 11 deletions lib/format/opds/opds_extractor.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import 'dart:convert';
import 'dart:io';

import 'package:bookoscope/db/source.db.dart';
import 'package:bookoscope/format/opds/opds_xml.dart';
import 'package:bookoscope/util/authenticate.dart';
import 'package:collection/collection.dart';
import 'package:xml/xml.dart';
import 'package:xml/xml_events.dart';

/// Uses [client] to extract [OPDSFeed]s from [Uri]s.
class OPDSExtractor {
HttpClient client = HttpClient();
String? username;
String? password;
Source? source;
String? authHeader;

void useBasicAuth(String? username, String? password) {
this.username = username;
this.password = password;
void useAuth(Source source) {
this.source = source;
}

/// Extracts an [OPDSFeed] from a [Uri]. Uses a [Stream] internally
Expand Down Expand Up @@ -66,16 +68,25 @@ class OPDSExtractor {
);
}

Future<Stream<List<XmlEvent>>> _fetchXmlEvents(Uri uri) async {
Future<Stream<List<XmlEvent>>> _fetchXmlEvents(Uri uri,
{int retries = 0}) async {
final request = await client.getUrl(uri);

if (username != null && password != null) {
final basicAuth =
"Basic ${base64.encode(utf8.encode('$username:$password'))}";
request.headers.set("authorization", basicAuth);
if (authHeader?.isNotEmpty ?? false) {
request.headers.set('authorization', authHeader ?? "");
}

final response = await request.close();
if (response.statusCode == HttpStatus.unauthorized && retries < 3) {
final authenticateHeader = response.headers['www-authenticate'];
if (authenticateHeader != null && authenticateHeader.isNotEmpty) {
await attemptAuthentication(uri, authenticateHeader);
return _fetchXmlEvents(uri, retries: retries + 1);
}
} else if (response.statusCode == HttpStatus.unauthorized) {
throw Exception('Request to [$uri] returned [${response.statusCode}]. '
'Please check your credentials.');
}

if (response.statusCode != HttpStatus.ok) {
throw Exception('Request to [$uri] returned [${response.statusCode}].');
}
Expand All @@ -86,4 +97,35 @@ class OPDSExtractor {
.normalizeEvents()
.withParentEvents();
}

Future<void> attemptAuthentication(
Uri uri,
List<String> authenticateHeaders,
) async {
final options =
authenticateHeaders.map((header) => parseAuthenticateHeader(header));

if (options.any((option) => option.scheme == 'basic')) {
authHeader = source?.getBasicAuthHeader();
return;
}

final digestOption =
options.firstWhereOrNull((option) => option.scheme == 'digest');
if (digestOption != null) {
client.addCredentials(
uri,
digestOption.realm ?? "",
HttpClientDigestCredentials(
source?.username ?? "",
source?.password ?? "",
),
);
return;
}

throw Exception(
"None of these authorization schemes are supported: [${options.map((option) => option.scheme).join(', ')}].",
);
}
}
17 changes: 16 additions & 1 deletion lib/render/labeled_link_accordion.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bookoscope/db/source.db.dart';
import 'package:bookoscope/events/event_handler.dart';
import 'package:bookoscope/render/accordion.dart';
import 'package:bookoscope/render/link.dart';
Expand All @@ -10,12 +11,14 @@ class CRenderLabeledResultLinkAccordion extends StatelessWidget {
final String label;
final String? innerLabel;
final List<CLink> links;
final Source? source;

const CRenderLabeledResultLinkAccordion({
super.key,
required this.label,
this.innerLabel,
required this.links,
required this.source,
});

@override
Expand Down Expand Up @@ -54,7 +57,19 @@ class CRenderLabeledResultLinkAccordion extends StatelessWidget {
throw Exception("Link URI was null.");
}

await launchUrl(Uri.parse(uri));
final headers = <String, String>{};
if (source != null) {
headers["authorization"] =
source?.getBasicAuthHeader() ?? "";
}

await launchUrl(
Uri.parse(uri),
mode: LaunchMode.inAppWebView,
webViewConfiguration: WebViewConfiguration(
headers: headers,
),
);
} catch (e) {
// Swallow error because sometimes it fires for no reason
debugPrint(e.toString());
Expand Down
2 changes: 2 additions & 0 deletions lib/search/searchable_books.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class BKSearchableBook extends BKSearchable {
child: CRenderLabeledResultLinkAccordion(
label: "Download",
links: downloadUrls,
source: source,
),
),
];
Expand Down Expand Up @@ -199,6 +200,7 @@ class _GutenbergLinksState extends State<_GutenbergLinks> {
),
)
.toList(),
source: null,
),
);
}
Expand Down
21 changes: 9 additions & 12 deletions lib/sources/page_edit_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class _BKPageEditSourceState extends State<BKPageEditSource> {
String? _urlFieldError;
String? _nameFieldError;
bool confirmedOwnership = false;
bool useBasicAuth = false;
bool useCredentials = false;

bool? endpointTestStatus;
String? endpointTestError;
Expand All @@ -48,7 +48,7 @@ class _BKPageEditSourceState extends State<BKPageEditSource> {
}

confirmedOwnership = !isNew;
useBasicAuth = (source.username?.isNotEmpty ?? false) &&
useCredentials = (source.username?.isNotEmpty ?? false) &&
(source.password?.isNotEmpty ?? false);
}

Expand Down Expand Up @@ -110,17 +110,17 @@ class _BKPageEditSourceState extends State<BKPageEditSource> {
controlAffinity: ListTileControlAffinity.leading,
onChanged: (value) {
setState(() {
useBasicAuth = value ?? false;
if (!useBasicAuth) {
useCredentials = value ?? false;
if (!useCredentials) {
source.username = null;
source.password = null;
}
});
},
title: const Text("Use Basic Authentication"),
value: useBasicAuth,
title: const Text("Use credentials"),
value: useCredentials,
),
if (useBasicAuth) ...[
if (useCredentials) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
Expand Down Expand Up @@ -175,10 +175,7 @@ class _BKPageEditSourceState extends State<BKPageEditSource> {
endpointTestError = null;

final extractor = OPDSExtractor()
..useBasicAuth(
source.username,
source.password,
);
..useAuth(source);

try {
await extractor.getFeed(Uri.parse(source.url));
Expand All @@ -201,8 +198,8 @@ class _BKPageEditSourceState extends State<BKPageEditSource> {
? const Text("Valid feed detected.")
: Text("Invalid endpoint.\n\n$endpointTestError"),
),
const Divider(),
],
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
Expand Down
34 changes: 34 additions & 0 deletions lib/util/authenticate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class WWWAuthenticateOption {
final String scheme;
final String? realm;
final String? nonce;
final String? algorithm;

WWWAuthenticateOption({
required this.scheme,
this.realm,
this.nonce,
this.algorithm,
});
}

WWWAuthenticateOption parseAuthenticateHeader(String headerValue) {
headerValue = headerValue.trim();
final whitespaceRx = RegExp(r"\s");
final scheme = headerValue
.substring(
0,
headerValue.contains(whitespaceRx)
? headerValue.indexOf(whitespaceRx)
: null)
.toLowerCase();
final keyValueRx = RegExp('([a-zA-Z0-9]+)="?([^,"]+)"?');
final matches = keyValueRx.allMatches(headerValue);
final valueMap = {for (var match in matches) match.group(1): match.group(2)};
return WWWAuthenticateOption(
scheme: scheme,
realm: valueMap['realm'],
nonce: valueMap['nonce'],
algorithm: valueMap['algorithm'],
);
}
Loading

0 comments on commit 8100912

Please sign in to comment.