diff --git a/lib/screens/common_widgets/auth/digest_auth_fields.dart b/lib/screens/common_widgets/auth/digest_auth_fields.dart new file mode 100644 index 00000000..7fc7850b --- /dev/null +++ b/lib/screens/common_widgets/auth/digest_auth_fields.dart @@ -0,0 +1,139 @@ +import 'package:apidash/screens/common_widgets/auth_textfield.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/widgets/widgets.dart'; +import 'package:flutter/material.dart'; + +class DigestAuthFields extends StatefulWidget { + final AuthModel? authData; + final bool readOnly; + final Function(AuthModel?) updateAuth; + + const DigestAuthFields({ + super.key, + required this.authData, + required this.updateAuth, + this.readOnly = false, + }); + + @override + State createState() => _DigestAuthFieldsState(); +} + +class _DigestAuthFieldsState extends State { + late TextEditingController _usernameController; + late TextEditingController _passwordController; + late TextEditingController _realmController; + late TextEditingController _nonceController; + late String _algorithmController; + late TextEditingController _qopController; + late TextEditingController _opaqueController; + + @override + void initState() { + super.initState(); + final digest = widget.authData?.digest; + _usernameController = TextEditingController(text: digest?.username ?? ''); + _passwordController = TextEditingController(text: digest?.password ?? ''); + _realmController = TextEditingController(text: digest?.realm ?? ''); + _nonceController = TextEditingController(text: digest?.nonce ?? ''); + _algorithmController = digest?.algorithm ?? 'MD5'; + _qopController = TextEditingController(text: digest?.qop ?? 'auth'); + _opaqueController = TextEditingController(text: digest?.opaque ?? ''); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AuthTextField( + readOnly: widget.readOnly, + controller: _usernameController, + hintText: "Username", + onChanged: (_) => _updateDigestAuth(), + ), + const SizedBox(height: 16), + AuthTextField( + readOnly: widget.readOnly, + controller: _passwordController, + hintText: "Password", + isObscureText: true, + onChanged: (_) => _updateDigestAuth(), + ), + const SizedBox(height: 16), + AuthTextField( + readOnly: widget.readOnly, + controller: _realmController, + hintText: "Realm", + onChanged: (_) => _updateDigestAuth(), + ), + const SizedBox(height: 16), + AuthTextField( + readOnly: widget.readOnly, + controller: _nonceController, + hintText: "Nonce", + onChanged: (_) => _updateDigestAuth(), + ), + const SizedBox(height: 16), + Text( + "Algorithm", + style: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + ), + SizedBox(height: 4), + ADPopupMenu( + value: _algorithmController.trim(), + values: const [ + ('MD5', 'MD5'), + ('MD5-sess', 'MD5-sess'), + ('SHA-256', 'SHA-256'), + ('SHA-256-sess', 'SHA-256-sess'), + ], + tooltip: "this algorithm will be used to produce the digest", + isOutlined: true, + onChanged: (String? newLocation) { + if (newLocation != null) { + setState(() { + _algorithmController = newLocation; + }); + _updateDigestAuth(); + } + }, + ), + const SizedBox(height: 16), + AuthTextField( + readOnly: widget.readOnly, + controller: _qopController, + hintText: "QOP (e.g. auth)", + onChanged: (_) => _updateDigestAuth(), + ), + const SizedBox(height: 16), + AuthTextField( + readOnly: widget.readOnly, + controller: _opaqueController, + hintText: "Opaque", + onChanged: (_) => _updateDigestAuth(), + ), + ], + ); + } + + void _updateDigestAuth() { + widget.updateAuth( + widget.authData?.copyWith( + type: APIAuthType.digest, + digest: AuthDigestModel( + username: _usernameController.text.trim(), + password: _passwordController.text.trim(), + realm: _realmController.text.trim(), + nonce: _nonceController.text.trim(), + algorithm: _algorithmController.trim(), + qop: _qopController.text.trim(), + opaque: _opaqueController.text.trim(), + ), + ), + ); + } +} diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart index dd5cc1c7..971d98dd 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart @@ -1,6 +1,7 @@ import 'package:apidash/screens/common_widgets/auth/api_key_auth_fields.dart'; import 'package:apidash/screens/common_widgets/auth/basic_auth_fields.dart'; import 'package:apidash/screens/common_widgets/auth/bearer_auth_fields.dart'; +import 'package:apidash/screens/common_widgets/auth/digest_auth_fields.dart'; import 'package:apidash/screens/common_widgets/auth/jwt_auth_fields.dart'; import 'package:apidash_design_system/widgets/popup_menu.dart'; import 'package:flutter/material.dart'; @@ -53,54 +54,37 @@ class EditAuthType extends ConsumerWidget { SizedBox( height: 8, ), - if (readOnly) - Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - Theme.of(context).colorScheme.outline.withOpacity(0.3), - ), - ), - child: Text( - currentAuthType.name.toUpperCase(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.primary, - ), - ), - ) - else - ADPopupMenu( - value: currentAuthType.name.capitalize(), - values: const [ - (APIAuthType.none, 'None'), - (APIAuthType.basic, 'Basic'), - (APIAuthType.apiKey, 'API Key'), - (APIAuthType.bearer, 'Bearer'), - (APIAuthType.jwt, 'JWT'), - (APIAuthType.digest, 'Digest'), - (APIAuthType.oauth1, 'OAuth 1.0'), - (APIAuthType.oauth2, 'OAuth 2.0'), - ], - tooltip: "Select Authentication Type", - isOutlined: true, - onChanged: (APIAuthType? newType) { - final selectedRequest = - ref.read(selectedRequestModelProvider); - if (newType != null) { - ref.read(collectionStateNotifierProvider.notifier).update( - authModel: selectedRequest - ?.httpRequestModel?.authModel - ?.copyWith(type: newType) ?? - AuthModel(type: newType), - ); - } - }, - ), + ADPopupMenu( + value: currentAuthType.name.capitalize(), + values: const [ + (APIAuthType.none, 'None'), + (APIAuthType.basic, 'Basic'), + (APIAuthType.apiKey, 'API Key'), + (APIAuthType.bearer, 'Bearer'), + (APIAuthType.jwt, 'JWT'), + (APIAuthType.digest, 'Digest'), + (APIAuthType.oauth1, 'OAuth 1.0'), + (APIAuthType.oauth2, 'OAuth 2.0'), + ], + tooltip: "Select Authentication Type", + isOutlined: true, + onChanged: readOnly + ? null + : (APIAuthType? newType) { + final selectedRequest = + ref.read(selectedRequestModelProvider); + if (newType != null) { + ref + .read(collectionStateNotifierProvider.notifier) + .update( + authModel: selectedRequest + ?.httpRequestModel?.authModel + ?.copyWith(type: newType) ?? + AuthModel(type: newType), + ); + } + }, + ), const SizedBox(height: 48), _buildAuthFields(context, ref, currentAuthData), ], @@ -151,6 +135,12 @@ class EditAuthType extends ConsumerWidget { authData: authData, updateAuth: updateAuth, ); + case APIAuthType.digest: + return DigestAuthFields( + readOnly: readOnly, + authData: authData, + updateAuth: updateAuth, + ); case APIAuthType.none: return Text(readOnly ? "No authentication was used for this request." diff --git a/packages/better_networking/better_networking_example/pubspec.lock b/packages/better_networking/better_networking_example/pubspec.lock index 2e6737af..908096b7 100644 --- a/packages/better_networking/better_networking_example/pubspec.lock +++ b/packages/better_networking/better_networking_example/pubspec.lock @@ -48,6 +48,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: diff --git a/packages/better_networking/lib/consts.dart b/packages/better_networking/lib/consts.dart index 493561ad..23038c7e 100644 --- a/packages/better_networking/lib/consts.dart +++ b/packages/better_networking/lib/consts.dart @@ -9,7 +9,7 @@ enum APIType { final String abbr; } -enum APIAuthType { none, basic, apiKey, bearer, jwt, digest, oauth1, oauth2} +enum APIAuthType { none, basic, apiKey, bearer, jwt, digest, oauth1, oauth2 } enum HTTPVerb { get("GET"), @@ -98,4 +98,5 @@ const LineSplitter kSplitter = LineSplitter(); const kCodeCharsPerLineLimit = 200; const kHeaderContentType = "Content-Type"; +const kHeaderWwwAuthenticate = 'www-authenticate'; const kMsgRequestCancelled = 'Request Cancelled'; diff --git a/packages/better_networking/lib/models/auth/api_auth_model.dart b/packages/better_networking/lib/models/auth/api_auth_model.dart index c9b19607..824ab543 100644 --- a/packages/better_networking/lib/models/auth/api_auth_model.dart +++ b/packages/better_networking/lib/models/auth/api_auth_model.dart @@ -4,22 +4,21 @@ import 'auth_api_key_model.dart'; import 'auth_basic_model.dart'; import 'auth_bearer_model.dart'; import 'auth_jwt_model.dart'; +import 'auth_digest_model.dart'; part 'api_auth_model.g.dart'; part 'api_auth_model.freezed.dart'; @freezed class AuthModel with _$AuthModel { - @JsonSerializable( - explicitToJson: true, - anyMap: true, - ) + @JsonSerializable(explicitToJson: true, anyMap: true) const factory AuthModel({ required APIAuthType type, AuthApiKeyModel? apikey, AuthBearerModel? bearer, AuthBasicAuthModel? basic, AuthJwtModel? jwt, + AuthDigestModel? digest, }) = _AuthModel; factory AuthModel.fromJson(Map json) => diff --git a/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart b/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart index 44fa1cb2..4e592fcc 100644 --- a/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart +++ b/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart @@ -26,6 +26,7 @@ mixin _$AuthModel { AuthBearerModel? get bearer => throw _privateConstructorUsedError; AuthBasicAuthModel? get basic => throw _privateConstructorUsedError; AuthJwtModel? get jwt => throw _privateConstructorUsedError; + AuthDigestModel? get digest => throw _privateConstructorUsedError; /// Serializes this AuthModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -48,12 +49,14 @@ abstract class $AuthModelCopyWith<$Res> { AuthBearerModel? bearer, AuthBasicAuthModel? basic, AuthJwtModel? jwt, + AuthDigestModel? digest, }); $AuthApiKeyModelCopyWith<$Res>? get apikey; $AuthBearerModelCopyWith<$Res>? get bearer; $AuthBasicAuthModelCopyWith<$Res>? get basic; $AuthJwtModelCopyWith<$Res>? get jwt; + $AuthDigestModelCopyWith<$Res>? get digest; } /// @nodoc @@ -76,6 +79,7 @@ class _$AuthModelCopyWithImpl<$Res, $Val extends AuthModel> Object? bearer = freezed, Object? basic = freezed, Object? jwt = freezed, + Object? digest = freezed, }) { return _then( _value.copyWith( @@ -99,6 +103,10 @@ class _$AuthModelCopyWithImpl<$Res, $Val extends AuthModel> ? _value.jwt : jwt // ignore: cast_nullable_to_non_nullable as AuthJwtModel?, + digest: freezed == digest + ? _value.digest + : digest // ignore: cast_nullable_to_non_nullable + as AuthDigestModel?, ) as $Val, ); @@ -159,6 +167,20 @@ class _$AuthModelCopyWithImpl<$Res, $Val extends AuthModel> return _then(_value.copyWith(jwt: value) as $Val); }); } + + /// Create a copy of AuthModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AuthDigestModelCopyWith<$Res>? get digest { + if (_value.digest == null) { + return null; + } + + return $AuthDigestModelCopyWith<$Res>(_value.digest!, (value) { + return _then(_value.copyWith(digest: value) as $Val); + }); + } } /// @nodoc @@ -176,6 +198,7 @@ abstract class _$$AuthModelImplCopyWith<$Res> AuthBearerModel? bearer, AuthBasicAuthModel? basic, AuthJwtModel? jwt, + AuthDigestModel? digest, }); @override @@ -186,6 +209,8 @@ abstract class _$$AuthModelImplCopyWith<$Res> $AuthBasicAuthModelCopyWith<$Res>? get basic; @override $AuthJwtModelCopyWith<$Res>? get jwt; + @override + $AuthDigestModelCopyWith<$Res>? get digest; } /// @nodoc @@ -207,6 +232,7 @@ class __$$AuthModelImplCopyWithImpl<$Res> Object? bearer = freezed, Object? basic = freezed, Object? jwt = freezed, + Object? digest = freezed, }) { return _then( _$AuthModelImpl( @@ -230,6 +256,10 @@ class __$$AuthModelImplCopyWithImpl<$Res> ? _value.jwt : jwt // ignore: cast_nullable_to_non_nullable as AuthJwtModel?, + digest: freezed == digest + ? _value.digest + : digest // ignore: cast_nullable_to_non_nullable + as AuthDigestModel?, ), ); } @@ -245,6 +275,7 @@ class _$AuthModelImpl implements _AuthModel { this.bearer, this.basic, this.jwt, + this.digest, }); factory _$AuthModelImpl.fromJson(Map json) => @@ -260,10 +291,12 @@ class _$AuthModelImpl implements _AuthModel { final AuthBasicAuthModel? basic; @override final AuthJwtModel? jwt; + @override + final AuthDigestModel? digest; @override String toString() { - return 'AuthModel(type: $type, apikey: $apikey, bearer: $bearer, basic: $basic, jwt: $jwt)'; + return 'AuthModel(type: $type, apikey: $apikey, bearer: $bearer, basic: $basic, jwt: $jwt, digest: $digest)'; } @override @@ -275,13 +308,14 @@ class _$AuthModelImpl implements _AuthModel { (identical(other.apikey, apikey) || other.apikey == apikey) && (identical(other.bearer, bearer) || other.bearer == bearer) && (identical(other.basic, basic) || other.basic == basic) && - (identical(other.jwt, jwt) || other.jwt == jwt)); + (identical(other.jwt, jwt) || other.jwt == jwt) && + (identical(other.digest, digest) || other.digest == digest)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => - Object.hash(runtimeType, type, apikey, bearer, basic, jwt); + Object.hash(runtimeType, type, apikey, bearer, basic, jwt, digest); /// Create a copy of AuthModel /// with the given fields replaced by the non-null parameter values. @@ -304,6 +338,7 @@ abstract class _AuthModel implements AuthModel { final AuthBearerModel? bearer, final AuthBasicAuthModel? basic, final AuthJwtModel? jwt, + final AuthDigestModel? digest, }) = _$AuthModelImpl; factory _AuthModel.fromJson(Map json) = @@ -319,6 +354,8 @@ abstract class _AuthModel implements AuthModel { AuthBasicAuthModel? get basic; @override AuthJwtModel? get jwt; + @override + AuthDigestModel? get digest; /// Create a copy of AuthModel /// with the given fields replaced by the non-null parameter values. diff --git a/packages/better_networking/lib/models/auth/api_auth_model.g.dart b/packages/better_networking/lib/models/auth/api_auth_model.g.dart index fd867324..7b6ac418 100644 --- a/packages/better_networking/lib/models/auth/api_auth_model.g.dart +++ b/packages/better_networking/lib/models/auth/api_auth_model.g.dart @@ -26,6 +26,11 @@ _$AuthModelImpl _$$AuthModelImplFromJson(Map json) => _$AuthModelImpl( jwt: json['jwt'] == null ? null : AuthJwtModel.fromJson(Map.from(json['jwt'] as Map)), + digest: json['digest'] == null + ? null + : AuthDigestModel.fromJson( + Map.from(json['digest'] as Map), + ), ); Map _$$AuthModelImplToJson(_$AuthModelImpl instance) => @@ -35,6 +40,7 @@ Map _$$AuthModelImplToJson(_$AuthModelImpl instance) => 'bearer': instance.bearer?.toJson(), 'basic': instance.basic?.toJson(), 'jwt': instance.jwt?.toJson(), + 'digest': instance.digest?.toJson(), }; const _$APIAuthTypeEnumMap = { diff --git a/packages/better_networking/lib/models/auth/auth_digest_model.dart b/packages/better_networking/lib/models/auth/auth_digest_model.dart new file mode 100644 index 00000000..2c2bc3ac --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_digest_model.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_digest_model.g.dart'; +part 'auth_digest_model.freezed.dart'; + +@freezed +class AuthDigestModel with _$AuthDigestModel { + const factory AuthDigestModel({ + required String username, + required String password, + required String realm, + required String nonce, + required String algorithm, + required String qop, + required String opaque, + }) = _AuthDigestModel; + + factory AuthDigestModel.fromJson(Map json) => + _$AuthDigestModelFromJson(json); +} diff --git a/packages/better_networking/lib/models/auth/auth_digest_model.freezed.dart b/packages/better_networking/lib/models/auth/auth_digest_model.freezed.dart new file mode 100644 index 00000000..2dd2f05f --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_digest_model.freezed.dart @@ -0,0 +1,314 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_digest_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +AuthDigestModel _$AuthDigestModelFromJson(Map json) { + return _AuthDigestModel.fromJson(json); +} + +/// @nodoc +mixin _$AuthDigestModel { + String get username => throw _privateConstructorUsedError; + String get password => throw _privateConstructorUsedError; + String get realm => throw _privateConstructorUsedError; + String get nonce => throw _privateConstructorUsedError; + String get algorithm => throw _privateConstructorUsedError; + String get qop => throw _privateConstructorUsedError; + String get opaque => throw _privateConstructorUsedError; + + /// Serializes this AuthDigestModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuthDigestModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuthDigestModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuthDigestModelCopyWith<$Res> { + factory $AuthDigestModelCopyWith( + AuthDigestModel value, + $Res Function(AuthDigestModel) then, + ) = _$AuthDigestModelCopyWithImpl<$Res, AuthDigestModel>; + @useResult + $Res call({ + String username, + String password, + String realm, + String nonce, + String algorithm, + String qop, + String opaque, + }); +} + +/// @nodoc +class _$AuthDigestModelCopyWithImpl<$Res, $Val extends AuthDigestModel> + implements $AuthDigestModelCopyWith<$Res> { + _$AuthDigestModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuthDigestModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? username = null, + Object? password = null, + Object? realm = null, + Object? nonce = null, + Object? algorithm = null, + Object? qop = null, + Object? opaque = null, + }) { + return _then( + _value.copyWith( + username: null == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String, + password: null == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String, + realm: null == realm + ? _value.realm + : realm // ignore: cast_nullable_to_non_nullable + as String, + nonce: null == nonce + ? _value.nonce + : nonce // ignore: cast_nullable_to_non_nullable + as String, + algorithm: null == algorithm + ? _value.algorithm + : algorithm // ignore: cast_nullable_to_non_nullable + as String, + qop: null == qop + ? _value.qop + : qop // ignore: cast_nullable_to_non_nullable + as String, + opaque: null == opaque + ? _value.opaque + : opaque // ignore: cast_nullable_to_non_nullable + as String, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuthDigestModelImplCopyWith<$Res> + implements $AuthDigestModelCopyWith<$Res> { + factory _$$AuthDigestModelImplCopyWith( + _$AuthDigestModelImpl value, + $Res Function(_$AuthDigestModelImpl) then, + ) = __$$AuthDigestModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String username, + String password, + String realm, + String nonce, + String algorithm, + String qop, + String opaque, + }); +} + +/// @nodoc +class __$$AuthDigestModelImplCopyWithImpl<$Res> + extends _$AuthDigestModelCopyWithImpl<$Res, _$AuthDigestModelImpl> + implements _$$AuthDigestModelImplCopyWith<$Res> { + __$$AuthDigestModelImplCopyWithImpl( + _$AuthDigestModelImpl _value, + $Res Function(_$AuthDigestModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuthDigestModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? username = null, + Object? password = null, + Object? realm = null, + Object? nonce = null, + Object? algorithm = null, + Object? qop = null, + Object? opaque = null, + }) { + return _then( + _$AuthDigestModelImpl( + username: null == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String, + password: null == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String, + realm: null == realm + ? _value.realm + : realm // ignore: cast_nullable_to_non_nullable + as String, + nonce: null == nonce + ? _value.nonce + : nonce // ignore: cast_nullable_to_non_nullable + as String, + algorithm: null == algorithm + ? _value.algorithm + : algorithm // ignore: cast_nullable_to_non_nullable + as String, + qop: null == qop + ? _value.qop + : qop // ignore: cast_nullable_to_non_nullable + as String, + opaque: null == opaque + ? _value.opaque + : opaque // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuthDigestModelImpl implements _AuthDigestModel { + const _$AuthDigestModelImpl({ + required this.username, + required this.password, + required this.realm, + required this.nonce, + required this.algorithm, + required this.qop, + required this.opaque, + }); + + factory _$AuthDigestModelImpl.fromJson(Map json) => + _$$AuthDigestModelImplFromJson(json); + + @override + final String username; + @override + final String password; + @override + final String realm; + @override + final String nonce; + @override + final String algorithm; + @override + final String qop; + @override + final String opaque; + + @override + String toString() { + return 'AuthDigestModel(username: $username, password: $password, realm: $realm, nonce: $nonce, algorithm: $algorithm, qop: $qop, opaque: $opaque)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuthDigestModelImpl && + (identical(other.username, username) || + other.username == username) && + (identical(other.password, password) || + other.password == password) && + (identical(other.realm, realm) || other.realm == realm) && + (identical(other.nonce, nonce) || other.nonce == nonce) && + (identical(other.algorithm, algorithm) || + other.algorithm == algorithm) && + (identical(other.qop, qop) || other.qop == qop) && + (identical(other.opaque, opaque) || other.opaque == opaque)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + username, + password, + realm, + nonce, + algorithm, + qop, + opaque, + ); + + /// Create a copy of AuthDigestModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuthDigestModelImplCopyWith<_$AuthDigestModelImpl> get copyWith => + __$$AuthDigestModelImplCopyWithImpl<_$AuthDigestModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuthDigestModelImplToJson(this); + } +} + +abstract class _AuthDigestModel implements AuthDigestModel { + const factory _AuthDigestModel({ + required final String username, + required final String password, + required final String realm, + required final String nonce, + required final String algorithm, + required final String qop, + required final String opaque, + }) = _$AuthDigestModelImpl; + + factory _AuthDigestModel.fromJson(Map json) = + _$AuthDigestModelImpl.fromJson; + + @override + String get username; + @override + String get password; + @override + String get realm; + @override + String get nonce; + @override + String get algorithm; + @override + String get qop; + @override + String get opaque; + + /// Create a copy of AuthDigestModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuthDigestModelImplCopyWith<_$AuthDigestModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/better_networking/lib/models/auth/auth_digest_model.g.dart b/packages/better_networking/lib/models/auth/auth_digest_model.g.dart new file mode 100644 index 00000000..ebbf878b --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_digest_model.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_digest_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AuthDigestModelImpl _$$AuthDigestModelImplFromJson( + Map json, +) => _$AuthDigestModelImpl( + username: json['username'] as String, + password: json['password'] as String, + realm: json['realm'] as String, + nonce: json['nonce'] as String, + algorithm: json['algorithm'] as String, + qop: json['qop'] as String, + opaque: json['opaque'] as String, +); + +Map _$$AuthDigestModelImplToJson( + _$AuthDigestModelImpl instance, +) => { + 'username': instance.username, + 'password': instance.password, + 'realm': instance.realm, + 'nonce': instance.nonce, + 'algorithm': instance.algorithm, + 'qop': instance.qop, + 'opaque': instance.opaque, +}; diff --git a/packages/better_networking/lib/models/models.dart b/packages/better_networking/lib/models/models.dart index f5ac6b1f..2987393d 100644 --- a/packages/better_networking/lib/models/models.dart +++ b/packages/better_networking/lib/models/models.dart @@ -5,3 +5,4 @@ export 'auth/auth_api_key_model.dart'; export 'auth/auth_basic_model.dart'; export 'auth/auth_bearer_model.dart'; export 'auth/auth_jwt_model.dart'; +export 'auth/auth_digest_model.dart'; diff --git a/packages/better_networking/lib/services/http_service.dart b/packages/better_networking/lib/services/http_service.dart index 78c75f50..7c670f9d 100644 --- a/packages/better_networking/lib/services/http_service.dart +++ b/packages/better_networking/lib/services/http_service.dart @@ -20,14 +20,22 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequest( HttpRequestModel requestModel, { SupportedUriSchemes defaultUriScheme = kDefaultUriScheme, bool noSSL = false, + bool enableAuth = true, }) async { if (httpClientManager.wasRequestCancelled(requestId)) { httpClientManager.removeCancelledRequest(requestId); } final client = httpClientManager.createClient(requestId, noSSL: noSSL); - // Handle authentication - final authenticatedRequestModel = handleAuth(requestModel, authData); + HttpRequestModel authenticatedRequestModel = requestModel.copyWith(); + + try { + if (enableAuth) { + authenticatedRequestModel = await handleAuth(requestModel, authData); + } + } catch (e) { + return (null, null, e.toString()); + } (Uri?, String?) uriRec = getValidRequestUri( authenticatedRequestModel.url, diff --git a/packages/better_networking/lib/utils/auth/digest_auth_utils.dart b/packages/better_networking/lib/utils/auth/digest_auth_utils.dart new file mode 100644 index 00000000..fc4a9072 --- /dev/null +++ b/packages/better_networking/lib/utils/auth/digest_auth_utils.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart' as crypto; +import '../../models/auth/auth_digest_model.dart'; +import '../../models/models.dart'; + +Map? splitAuthenticateHeader(String header) { + if (!header.startsWith('Digest ')) { + return null; + } + header = header.substring(7); // remove 'Digest ' + + var ret = {}; + + final components = header.split(',').map((token) => token.trim()); + for (var component in components) { + final kv = component.split('='); + ret[kv[0]] = kv.getRange(1, kv.length).join('=').replaceAll('"', ''); + } + return ret; +} + +String sha256Hash(String data) { + var content = const Utf8Encoder().convert(data); + var sha256 = crypto.sha256; + var digest = sha256.convert(content).toString(); + return digest; +} + +String md5Hash(String data) { + var content = const Utf8Encoder().convert(data); + var md5 = crypto.md5; + var digest = md5.convert(content).toString(); + return digest; +} + +String _formatNonceCount(int nc) { + return nc.toRadixString(16).padLeft(8, '0'); +} + +String _computeHA1( + String realm, + String? algorithm, + String username, + String password, + String? nonce, + String? cnonce, +) { + if (algorithm == 'MD5') { + final token1 = '$username:$realm:$password'; + return md5Hash(token1); + } else if (algorithm == 'MD5-sess') { + final token1 = '$username:$realm:$password'; + final md51 = md5Hash(token1); + final token2 = '$md51:$nonce:$cnonce'; + return md5Hash(token2); + } else if (algorithm == 'SHA-256') { + final token1 = '$username:$realm:$password'; + return sha256Hash(token1); + } else if (algorithm == 'SHA-256-sess') { + final token1 = '$username:$realm:$password'; + final sha256_1 = sha256Hash(token1); + final token2 = '$sha256_1:$nonce:$cnonce'; + return sha256Hash(token2); + } else { + throw ArgumentError.value(algorithm, 'algorithm', 'Unsupported algorithm'); + } +} + +Map computeResponse( + String method, + String path, + String body, + String? algorithm, + String? qop, + String? opaque, + String realm, + String? cnonce, + String? nonce, + int nc, + String username, + String password, +) { + var ret = {}; + + algorithm ??= 'MD5'; + final ha1 = _computeHA1(realm, algorithm, username, password, nonce, cnonce); + + String ha2; + + if (algorithm.startsWith('MD5')) { + if (qop == 'auth-int') { + final bodyHash = md5Hash(body); + final token2 = '$method:$path:$bodyHash'; + ha2 = md5Hash(token2); + } else { + // qop in [null, auth] + final token2 = '$method:$path'; + ha2 = md5Hash(token2); + } + } else { + if (qop == 'auth-int') { + final bodyHash = sha256Hash(body); + final token2 = '$method:$path:$bodyHash'; + ha2 = sha256Hash(token2); + } else { + // qop in [null, auth] + final token2 = '$method:$path'; + ha2 = sha256Hash(token2); + } + } + + final nonceCount = _formatNonceCount(nc); + ret['username'] = username; + ret['realm'] = realm; + ret['nonce'] = nonce; + ret['uri'] = path; + if (qop != null) { + ret['qop'] = qop; + } + ret['nc'] = nonceCount; + ret['cnonce'] = cnonce; + if (opaque != null) { + ret['opaque'] = opaque; + } + ret['algorithm'] = algorithm; + + if (algorithm.startsWith('MD5')) { + if (qop == null) { + final token3 = '$ha1:$nonce:$ha2'; + ret['response'] = md5Hash(token3); + } else if (qop == 'auth' || qop == 'auth-int') { + final token3 = '$ha1:$nonce:$nonceCount:$cnonce:$qop:$ha2'; + ret['response'] = md5Hash(token3); + } + } else { + if (qop == null) { + final token3 = '$ha1:$nonce:$ha2'; + ret['response'] = sha256Hash(token3); + } else if (qop == 'auth' || qop == 'auth-int') { + final token3 = '$ha1:$nonce:$nonceCount:$cnonce:$qop:$ha2'; + ret['response'] = sha256Hash(token3); + } + } + + return ret; +} + +class DigestAuth { + String username; + String password; + + // must get from first response + String? _algorithm; + String? _qop; + String? _realm; + String? _nonce; + String? _opaque; + + int _nc = 0; // request counter + + DigestAuth(this.username, this.password); + + // Constructor that takes an AuthDigestModel + DigestAuth.fromModel(AuthDigestModel model) + : username = model.username, + password = model.password, + _realm = model.realm, + _nonce = model.nonce, + _algorithm = model.algorithm, + _qop = model.qop, + _opaque = model.opaque.isNotEmpty ? model.opaque : null; + + String _computeNonce() { + final rnd = math.Random.secure(); + + final values = List.generate(16, (i) => rnd.nextInt(256)); + + return hex.encode(values); + } + + String getAuthString(HttpRequestModel res) { + final cnonce = _computeNonce(); + final url = Uri.parse(res.url); + final method = res.method.name.toUpperCase(); + final body = res.body ?? ''; + _nc += 1; + // if url has query parameters, append query to path + final path = url.hasQuery ? '${url.path}?${url.query}' : url.path; + + // after the first request we have the nonce, so we can provide credentials + final authValues = computeResponse( + method, + path, + body, + _algorithm, + _qop, + _opaque, + _realm!, + cnonce, + _nonce, + _nc, + username, + password, + ); + final authValuesString = authValues.entries + .where((e) => e.value != null) + .map( + (e) => [ + e.key, + '=', + ['algorithm', 'qop', 'nc'].contains(e.key) ? '' : '"', + e.value, + ['algorithm', 'qop', 'nc'].contains(e.key) ? '' : '"', + ].join(''), + ) + .toList() + .join(', '); + final authString = 'Digest $authValuesString'; + return authString; + } + + // TODO: Use this function + void initFromAuthenticateHeader(String /*!*/ authInfo) { + final values = splitAuthenticateHeader(authInfo); + if (values != null) { + _algorithm = values['algorithm'] ?? _algorithm; + _qop = values['qop'] ?? _qop; + _realm = values['realm'] ?? _realm; + _nonce = values['nonce'] ?? _nonce; + _opaque = values['opaque'] ?? _opaque; + _nc = 0; + } + } +} diff --git a/packages/better_networking/lib/utils/auth/handle_auth.dart b/packages/better_networking/lib/utils/auth/handle_auth.dart new file mode 100644 index 00000000..231b7643 --- /dev/null +++ b/packages/better_networking/lib/utils/auth/handle_auth.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:better_networking/utils/auth/jwt_auth_utils.dart'; +import 'package:better_networking/utils/auth/digest_auth_utils.dart'; +import 'package:better_networking/better_networking.dart'; + +Future handleAuth( + HttpRequestModel httpRequestModel, + AuthModel? authData, +) async { + if (authData == null || authData.type == APIAuthType.none) { + return httpRequestModel; + } + + List updatedHeaders = List.from( + httpRequestModel.headers ?? [], + ); + List updatedParams = List.from(httpRequestModel.params ?? []); + List updatedHeaderEnabledList = List.from( + httpRequestModel.isHeaderEnabledList ?? [], + ); + List updatedParamEnabledList = List.from( + httpRequestModel.isParamEnabledList ?? [], + ); + + switch (authData.type) { + case APIAuthType.basic: + if (authData.basic != null) { + final basicAuth = authData.basic!; + final encoded = base64Encode( + utf8.encode('${basicAuth.username}:${basicAuth.password}'), + ); + updatedHeaders.add( + NameValueModel(name: 'Authorization', value: 'Basic $encoded'), + ); + updatedHeaderEnabledList.add(true); + } + break; + + case APIAuthType.bearer: + if (authData.bearer != null) { + final bearerAuth = authData.bearer!; + updatedHeaders.add( + NameValueModel( + name: 'Authorization', + value: 'Bearer ${bearerAuth.token}', + ), + ); + updatedHeaderEnabledList.add(true); + } + break; + + case APIAuthType.jwt: + if (authData.jwt != null) { + final jwtAuth = authData.jwt!; + + // Generate JWT token + final jwtToken = generateJWT(jwtAuth); + + if (jwtAuth.addTokenTo == 'header') { + // Add to request header with prefix + final headerValue = jwtAuth.headerPrefix.isNotEmpty + ? '${jwtAuth.headerPrefix} $jwtToken' + : jwtToken; + updatedHeaders.add( + NameValueModel(name: 'Authorization', value: headerValue), + ); + updatedHeaderEnabledList.add(true); + } else if (jwtAuth.addTokenTo == 'query') { + // Add to query parameters(if selected) + final paramKey = jwtAuth.queryParamKey.isNotEmpty + ? jwtAuth.queryParamKey + : 'token'; + updatedParams.add(NameValueModel(name: paramKey, value: jwtToken)); + updatedParamEnabledList.add(true); + } + } + break; + + case APIAuthType.apiKey: + if (authData.apikey != null) { + final apiKeyAuth = authData.apikey!; + if (apiKeyAuth.location == 'header') { + updatedHeaders.add( + NameValueModel(name: apiKeyAuth.name, value: apiKeyAuth.key), + ); + updatedHeaderEnabledList.add(true); + } else if (apiKeyAuth.location == 'query') { + updatedParams.add( + NameValueModel(name: apiKeyAuth.name, value: apiKeyAuth.key), + ); + updatedParamEnabledList.add(true); + } + } + break; + + case APIAuthType.none: + break; + case APIAuthType.digest: + if (authData.digest != null) { + final digestAuthModel = authData.digest!; + + if (digestAuthModel.realm.isNotEmpty && + digestAuthModel.nonce.isNotEmpty) { + final digestAuth = DigestAuth.fromModel(digestAuthModel); + final authString = digestAuth.getAuthString(httpRequestModel); + + updatedHeaders.add( + NameValueModel(name: 'Authorization', value: authString), + ); + updatedHeaderEnabledList.add(true); + } else { + final httpResult = await sendHttpRequest( + "digest-${Random.secure()}", + APIType.rest, + authData, + httpRequestModel, + enableAuth: false, + ); + final httpResponse = httpResult.$1; + + if (httpResponse == null) { + throw Exception("Initial Digest request failed: no response"); + } + + if (httpResponse.statusCode == 401) { + final wwwAuthHeader = httpResponse.headers[kHeaderWwwAuthenticate]; + + if (wwwAuthHeader == null) { + throw Exception("401 response missing www-authenticate header"); + } + + final authParams = splitAuthenticateHeader(wwwAuthHeader); + + if (authParams == null) { + throw Exception("Invalid Digest header format"); + } + + final updatedDigestModel = digestAuthModel.copyWith( + realm: authParams['realm'] ?? '', + nonce: authParams['nonce'] ?? '', + algorithm: authParams['algorithm'] ?? 'MD5', + qop: authParams['qop'] ?? 'auth', + opaque: authParams['opaque'] ?? '', + ); + + final digestAuth = DigestAuth.fromModel(updatedDigestModel); + final authString = digestAuth.getAuthString(httpRequestModel); + updatedHeaders.add( + NameValueModel(name: 'Authorization', value: authString), + ); + updatedHeaderEnabledList.add(true); + } else { + throw Exception( + "Initial Digest request failed due to unexpected status code: ${httpResponse.body}. Status Code: ${httpResponse.statusCode}", + ); + } + } + } + break; + case APIAuthType.oauth1: + // TODO: Handle this case. + throw UnimplementedError(); + case APIAuthType.oauth2: + // TODO: Handle this case. + throw UnimplementedError(); + } + + return httpRequestModel.copyWith( + headers: updatedHeaders, + params: updatedParams, + isHeaderEnabledList: updatedHeaderEnabledList, + isParamEnabledList: updatedParamEnabledList, + ); +} diff --git a/packages/better_networking/lib/utils/auth_utils.dart b/packages/better_networking/lib/utils/auth/jwt_auth_utils.dart similarity index 100% rename from packages/better_networking/lib/utils/auth_utils.dart rename to packages/better_networking/lib/utils/auth/jwt_auth_utils.dart diff --git a/packages/better_networking/lib/utils/handle_auth.dart b/packages/better_networking/lib/utils/handle_auth.dart deleted file mode 100644 index 45a3f08c..00000000 --- a/packages/better_networking/lib/utils/handle_auth.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:convert'; -import 'package:better_networking/utils/auth_utils.dart'; -import 'package:better_networking/better_networking.dart'; - -import '../models/auth/api_auth_model.dart'; - -HttpRequestModel handleAuth(HttpRequestModel httpRequestModel,AuthModel? authData) { - if (authData == null || authData.type == APIAuthType.none) { - return httpRequestModel; - } - - List updatedHeaders = - List.from(httpRequestModel.headers ?? []); - List updatedParams = List.from(httpRequestModel.params ?? []); - List updatedHeaderEnabledList = - List.from(httpRequestModel.isHeaderEnabledList ?? []); - List updatedParamEnabledList = - List.from(httpRequestModel.isParamEnabledList ?? []); - - switch (authData.type) { - case APIAuthType.basic: - if (authData.basic != null) { - final basicAuth = authData.basic!; - final encoded = base64Encode( - utf8.encode('${basicAuth.username}:${basicAuth.password}')); - updatedHeaders.add( - NameValueModel(name: 'Authorization', value: 'Basic $encoded')); - updatedHeaderEnabledList.add(true); - } - break; - - case APIAuthType.bearer: - if (authData.bearer != null) { - final bearerAuth = authData.bearer!; - updatedHeaders.add(NameValueModel( - name: 'Authorization', value: 'Bearer ${bearerAuth.token}')); - updatedHeaderEnabledList.add(true); - } - break; - - case APIAuthType.jwt: - if (authData.jwt != null) { - final jwtAuth = authData.jwt!; - - // Generate JWT token - final jwtToken = generateJWT(jwtAuth); - - if (jwtAuth.addTokenTo == 'header') { - // Add to request header with prefix - final headerValue = jwtAuth.headerPrefix.isNotEmpty - ? '${jwtAuth.headerPrefix} $jwtToken' - : jwtToken; - updatedHeaders - .add(NameValueModel(name: 'Authorization', value: headerValue)); - updatedHeaderEnabledList.add(true); - } else if (jwtAuth.addTokenTo == 'query') { - // Add to query parameters(if selected) - final paramKey = jwtAuth.queryParamKey.isNotEmpty - ? jwtAuth.queryParamKey - : 'token'; - updatedParams.add(NameValueModel(name: paramKey, value: jwtToken)); - updatedParamEnabledList.add(true); - } - } - break; - - case APIAuthType.apiKey: - if (authData.apikey != null) { - final apiKeyAuth = authData.apikey!; - if (apiKeyAuth.location == 'header') { - updatedHeaders.add( - NameValueModel(name: apiKeyAuth.name, value: apiKeyAuth.key)); - updatedHeaderEnabledList.add(true); - } else if (apiKeyAuth.location == 'query') { - updatedParams.add( - NameValueModel(name: apiKeyAuth.name, value: apiKeyAuth.key)); - updatedParamEnabledList.add(true); - } - } - break; - - case APIAuthType.none: - break; - case APIAuthType.digest: - // TODO: Handle this case. - throw UnimplementedError(); - case APIAuthType.oauth1: - // TODO: Handle this case. - throw UnimplementedError(); - case APIAuthType.oauth2: - // TODO: Handle this case. - throw UnimplementedError(); - } - - return httpRequestModel.copyWith( - headers: updatedHeaders, - params: updatedParams, - isHeaderEnabledList: updatedHeaderEnabledList, - isParamEnabledList: updatedParamEnabledList, - ); -} diff --git a/packages/better_networking/lib/utils/utils.dart b/packages/better_networking/lib/utils/utils.dart index 95bfa931..7857ed81 100644 --- a/packages/better_networking/lib/utils/utils.dart +++ b/packages/better_networking/lib/utils/utils.dart @@ -4,4 +4,4 @@ export 'http_request_utils.dart'; export 'http_response_utils.dart'; export 'string_utils.dart' hide RandomStringGenerator; export 'uri_utils.dart'; -export 'handle_auth.dart'; +export 'auth/handle_auth.dart'; diff --git a/packages/better_networking/pubspec.yaml b/packages/better_networking/pubspec.yaml index 6cd11959..eeabb911 100644 --- a/packages/better_networking/pubspec.yaml +++ b/packages/better_networking/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: seed: ^0.0.3 xml: ^6.3.0 crypto: ^3.0.6 + convert: ^3.1.2 dev_dependencies: flutter_test: